Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View file

@ -0,0 +1,3 @@
## Core - UI - Compose - Common
This module contains common code for the compose UI.

View file

@ -0,0 +1,13 @@
plugins {
id(ThunderbirdPlugins.Library.androidCompose)
}
android {
namespace = "app.k9mail.core.ui.compose.common"
resourcePrefix = "core_ui_common_"
}
dependencies {
testImplementation(projects.core.ui.compose.testing)
testImplementation(projects.core.ui.compose.designsystem)
}

View file

@ -0,0 +1,16 @@
package app.k9mail.core.ui.compose.common.annotation
import androidx.compose.ui.tooling.preview.Preview
/**
* A marker annotation for device previews.
*
* It's used to provide previews for a set of different devices and form factors.
*/
@Preview(name = "Small phone", device = "spec:width=360dp,height=640dp,dpi=160")
@Preview(name = "Phone", device = "spec:width=411dp,height=891dp,dpi=420")
@Preview(name = "Phone landscape", device = "spec:width=891dp,height=411dp,dpi=420")
@Preview(name = "Foldable", device = "spec:width=673dp,height=841dp,dpi=420")
@Preview(name = "Tablet", device = "spec:width=1280dp,height=800dp,dpi=240")
@Preview(name = "Desktop", device = "spec:width=1920dp,height=1080dp,dpi=160")
annotation class PreviewDevices

View file

@ -0,0 +1,16 @@
package app.k9mail.core.ui.compose.common.annotation
import androidx.compose.ui.tooling.preview.Preview
/**
* A marker annotation for device previews with background.
*
* It's used to provide previews for a set of different devices and form factors.
*/
@Preview(name = "Small phone", device = "spec:width=360dp,height=640dp,dpi=160", showBackground = true)
@Preview(name = "Phone", device = "spec:width=411dp,height=891dp,dpi=420", showBackground = true)
@Preview(name = "Phone landscape", device = "spec:width=891dp,height=411dp,dpi=420", showBackground = true)
@Preview(name = "Foldable", device = "spec:width=673dp,height=841dp,dpi=420", showBackground = true)
@Preview(name = "Tablet", device = "spec:width=1280dp,height=800dp,dpi=240", showBackground = true)
@Preview(name = "Desktop", device = "spec:width=1920dp,height=1080dp,dpi=160", showBackground = true)
annotation class PreviewDevicesWithBackground

View file

@ -0,0 +1,31 @@
package app.k9mail.core.ui.compose.common.baseline
import androidx.compose.foundation.layout.RowScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.FirstBaseline
import androidx.compose.ui.layout.LastBaseline
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Dp
/**
* Adds a baseline to a Composable that typically doesn't have one.
*
* This can be used to align e.g. an icon to the baseline of some text next to it. See e.g. [RowScope.alignByBaseline].
*
* @param baseline The number of device-independent pixels (dp) the baseline is from the top of the Composable.
*/
fun Modifier.withBaseline(baseline: Dp) = layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val baselineInPx = baseline.roundToPx()
layout(
width = placeable.width,
height = placeable.height,
alignmentLines = mapOf(
FirstBaseline to baselineInPx,
LastBaseline to baselineInPx,
),
) {
placeable.placeRelative(x = 0, y = 0)
}
}

View file

@ -0,0 +1,11 @@
package app.k9mail.core.ui.compose.common.image
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
@Immutable
data class ImageWithBaseline(
val image: ImageVector,
val baseline: Dp,
)

View file

@ -0,0 +1,18 @@
package app.k9mail.core.ui.compose.common.image
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
/**
* An image with a coordinate to draw a (smaller) overlay icon on top of it.
*
* Example: An icon representing an Android permission with an overlay icon to indicate whether the permission has been
* granted.
*/
@Immutable
data class ImageWithOverlayCoordinate(
val image: ImageVector,
val overlayOffsetX: Dp,
val overlayOffsetY: Dp,
)

View file

@ -0,0 +1,27 @@
package app.k9mail.core.ui.compose.common.koin
import androidx.compose.runtime.Composable
import org.koin.compose.KoinContext
import org.koin.core.Koin
import org.koin.dsl.ModuleDeclaration
import org.koin.dsl.module
/**
* Helper to make Compose previews work when some dependencies are injected via Koin.
*/
fun koinPreview(moduleDeclaration: ModuleDeclaration): KoinPreview {
val koin = Koin().apply {
loadModules(listOf(module(moduleDeclaration = moduleDeclaration)))
}
return KoinPreview(koin)
}
class KoinPreview internal constructor(private val koin: Koin) {
@Composable
infix fun WithContent(content: @Composable () -> Unit) {
KoinContext(context = koin) {
content()
}
}
}

View file

@ -0,0 +1,75 @@
package app.k9mail.core.ui.compose.common.mvi
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* An abstract base ViewModel that implements [UnidirectionalViewModel] and provides basic
* functionality for managing state and effects.
*
* @param STATE The type that represents the state of the ViewModel. For example, the UI state of a screen can be
* represented as a state.
* @param EVENT The type that represents user actions that can occur and should be handled by the ViewModel. For
* example, a button click can be represented as an event.
* @param EFFECT The type that represents side-effects that can occur in response to the state changes. For example,
* a navigation event can be represented as an effect.
*
* @param initialState The initial [STATE] of the ViewModel.
*/
abstract class BaseViewModel<STATE, EVENT, EFFECT>(
initialState: STATE,
) : ViewModel(),
UnidirectionalViewModel<STATE, EVENT, EFFECT> {
private val _state = MutableStateFlow(initialState)
override val state: StateFlow<STATE> = _state.asStateFlow()
private val _effect = MutableSharedFlow<EFFECT>()
override val effect: SharedFlow<EFFECT> = _effect.asSharedFlow()
private val handledOneTimeEvents = mutableSetOf<EVENT>()
/**
* Updates the [STATE] of the ViewModel.
*
* @param update A function that takes the current [STATE] and produces a new [STATE].
*/
protected fun updateState(update: (STATE) -> STATE) {
_state.update(update)
}
/**
* Emits a side effect.
*
* @param effect The [EFFECT] to emit.
*/
protected fun emitEffect(effect: EFFECT) {
viewModelScope.launch {
_effect.emit(effect)
}
}
/**
* Ensures that one-time events are only handled once.
*
* When you can't ensure that an event is only sent once, but you want the event to only be handled once, call this
* method. It will ensure [block] is only executed the first time this function is called. Subsequent calls with an
* [event] argument equal to that of a previous invocation will not execute [block].
*
* Multiple one-time events are supported.
*/
protected fun handleOneTimeEvent(event: EVENT, block: () -> Unit) {
if (event !in handledOneTimeEvents) {
handledOneTimeEvents.add(event)
block()
}
}
}

View file

@ -0,0 +1,147 @@
package app.k9mail.core.ui.compose.common.mvi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Interface for a unidirectional view model with side-effects ([EFFECT]). It has a [STATE] and can handle [EVENT]'s.
*
* @param STATE The type that represents the state of the ViewModel. For example, the UI state of a screen can be
* represented as a state.
* @param EVENT The type that represents user actions that can occur and should be handled by the ViewModel. For
* example, a button click can be represented as an event.
* @param EFFECT The type that represents side-effects that can occur in response to the state changes. For example,
* a navigation event can be represented as an effect.
*/
interface UnidirectionalViewModel<STATE, EVENT, EFFECT> {
/**
* The current [STATE] of the view model.
*/
val state: StateFlow<STATE>
/**
* The side-effects ([EFFECT]) produced by the view model.
*/
val effect: SharedFlow<EFFECT>
/**
* Handles an [EVENT] and updates the [STATE] of the view model.
*
* @param event The [EVENT] to handle.
*/
fun event(event: EVENT)
}
/**
* Data class representing a state and a dispatch function, used for destructuring in [observe].
*/
data class StateDispatch<STATE, EVENT>(
val state: State<STATE>,
val dispatch: (EVENT) -> Unit,
)
/**
* Composable function that observes a UnidirectionalViewModel and handles its side effects.
*
* Example usage:
* ```
* @Composable
* fun MyScreen(
* onNavigateNext: () -> Unit,
* onNavigateBack: () -> Unit,
* viewModel: MyUnidirectionalViewModel<MyState, MyEvent, MyEffect>,
* ) {
* val (state, dispatch) = viewModel.observe { effect ->
* when (effect) {
* MyEffect.OnBackPressed -> onNavigateBack()
* MyEffect.OnNextPressed -> onNavigateNext()
* }
* }
*
* MyContent(
* onNextClick = {
* dispatch(MyEvent.OnNext)
* },
* onBackClick = {
* dispatch(MyEvent.OnBack)
* },
* state = state.value,
* )
* }
* ```
*
* @param STATE The type that represents the state of the ViewModel.
* @param EVENT The type that represents user actions that can occur and should be handled by the ViewModel.
* @param EFFECT The type that represents side-effects that can occur in response to the state changes.
*
* @param handleEffect A function to handle side effects ([EFFECT]).
*
* @return A [StateDispatch] containing the state and a dispatch function.
*/
@Composable
inline fun <reified STATE, EVENT, EFFECT> UnidirectionalViewModel<STATE, EVENT, EFFECT>.observe(
crossinline handleEffect: (EFFECT) -> Unit,
): StateDispatch<STATE, EVENT> {
val collectedState = state.collectAsStateWithLifecycle()
val dispatch: (EVENT) -> Unit = { event(it) }
LaunchedEffect(key1 = effect) {
effect.collect {
handleEffect(it)
}
}
return StateDispatch(
state = collectedState,
dispatch = dispatch,
)
}
/**
* Composable function that observes a UnidirectionalViewModel without handling side effects.
*
* Example usage:
* ```
* @Composable
* fun MyScreen(
* viewModel: MyUnidirectionalViewModel<MyState, MyEvent, MyEffect>,
* onNavigateNext: () -> Unit,
* onNavigateBack: () -> Unit,
* ) {
* val (state, dispatch) = viewModel.observeWithoutEffect()
*
* MyContent(
* onNextClick = {
* dispatch(MyEvent.OnNext)
* },
* onBackClick = {
* dispatch(MyEvent.OnBack)
* },
* state = state.value,
* )
* }
* ```
*
* @param STATE The type that represents the state of the ViewModel.
* @param EVENT The type that represents user actions that can occur and should be handled by the ViewModel.
*
* @return A [StateDispatch] containing the state and a dispatch function.
*/
@Suppress("MaxLineLength")
@Composable
inline fun <reified STATE, EVENT, EFFECT> UnidirectionalViewModel<STATE, EVENT, EFFECT>.observeWithoutEffect(
// no effect handler
): StateDispatch<STATE, EVENT> {
val collectedState = state.collectAsStateWithLifecycle()
val dispatch: (EVENT) -> Unit = { event(it) }
return StateDispatch(
state = collectedState,
dispatch = dispatch,
)
}

View file

@ -0,0 +1,20 @@
package app.k9mail.core.ui.compose.common.padding
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import app.k9mail.core.ui.compose.common.window.WindowSizeClass
import app.k9mail.core.ui.compose.common.window.getWindowSizeInfo
@Composable
fun calculateResponsiveWidthPadding(): PaddingValues {
val windowSizeInfo = getWindowSizeInfo()
val horizontalPadding = when (windowSizeInfo.screenWidthSizeClass) {
WindowSizeClass.Compact -> 0.dp
WindowSizeClass.Medium -> (windowSizeInfo.screenWidth - WindowSizeClass.COMPACT_MAX_WIDTH.dp) / 2
WindowSizeClass.Expanded -> (windowSizeInfo.screenWidth - WindowSizeClass.MEDIUM_MAX_WIDTH.dp) / 2
}
return PaddingValues(horizontal = horizontalPadding)
}

View file

@ -0,0 +1,38 @@
package app.k9mail.core.ui.compose.common.resources
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
private const val PLACE_HOLDER = "{placeHolder}"
/**
* Loads a string resource with a single string parameter that will be replaced with the given [AnnotatedString].
*/
@Composable
@ReadOnlyComposable
fun annotatedStringResource(@StringRes id: Int, argument: AnnotatedString): AnnotatedString {
val stringWithPlaceHolder = stringResource(id, PLACE_HOLDER)
return buildAnnotatedString {
// In Android Studio previews loading string resources with formatting is not working
if (LocalInspectionMode.current) {
append(stringWithPlaceHolder)
return@buildAnnotatedString
}
val placeHolderStartIndex = stringWithPlaceHolder.indexOf(PLACE_HOLDER)
require(placeHolderStartIndex != -1)
append(text = stringWithPlaceHolder, start = 0, end = placeHolderStartIndex)
append(argument)
append(
text = stringWithPlaceHolder,
start = placeHolderStartIndex + PLACE_HOLDER.length,
end = stringWithPlaceHolder.length,
)
}
}

View file

@ -0,0 +1,18 @@
package app.k9mail.core.ui.compose.common.text
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
/**
* Converts a [String] into an [AnnotatedString] with all of the text being bold.
*/
fun String.bold(): AnnotatedString {
return buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(this@bold)
}
}
}

View file

@ -0,0 +1,16 @@
package app.k9mail.core.ui.compose.common.visibility
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.semantics.clearAndSetSemantics
/**
* Sets a composable to be fully transparent when it should be hidden (but still present for layout purposes).
*/
fun Modifier.hide(hide: Boolean): Modifier {
return if (hide) {
alpha(0f).clearAndSetSemantics {}
} else {
alpha(1f)
}
}

View file

@ -0,0 +1,38 @@
package app.k9mail.core.ui.compose.common.window
/**
* WindowSizeClass as defined by supporting different screen sizes.
*
* See: https://developer.android.com/guide/topics/large-screens/support-different-screen-sizes#window_size_classes
*/
enum class WindowSizeClass {
Compact,
Medium,
Expanded,
;
companion object {
const val COMPACT_MAX_WIDTH = 600
const val COMPACT_MAX_HEIGHT = 480
const val MEDIUM_MAX_WIDTH = 840
const val MEDIUM_MAX_HEIGHT = 900
fun fromWidth(width: Int): WindowSizeClass {
return when {
width < COMPACT_MAX_WIDTH -> Compact
width < MEDIUM_MAX_WIDTH -> Medium
else -> Expanded
}
}
fun fromHeight(height: Int): WindowSizeClass {
return when {
height < COMPACT_MAX_HEIGHT -> Compact
height < MEDIUM_MAX_HEIGHT -> Medium
else -> Expanded
}
}
}
}

View file

@ -0,0 +1,30 @@
package app.k9mail.core.ui.compose.common.window
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Returns the current window size info based on current Configuration.
*/
@Composable
fun getWindowSizeInfo(): WindowSizeInfo {
val configuration = LocalConfiguration.current
return WindowSizeInfo(
screenWidthSizeClass = WindowSizeClass.fromWidth(configuration.screenWidthDp),
screenHeightSizeClass = WindowSizeClass.fromHeight(configuration.screenHeightDp),
screenWidth = configuration.screenWidthDp.dp,
screenHeight = configuration.screenHeightDp.dp,
)
}
@Immutable
data class WindowSizeInfo(
val screenWidthSizeClass: WindowSizeClass,
val screenHeightSizeClass: WindowSizeClass,
val screenWidth: Dp,
val screenHeight: Dp,
)

View file

@ -0,0 +1,28 @@
package net.thunderbird.core.ui.compose.common.date
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.datetime.format.DayOfWeekNames
import kotlinx.datetime.format.MonthNames
/**
* Configuration for date and time formatting.
*
* @property monthNames The names of the months.
* @property dayOfWeekNames The names of the days of the week.
*/
data class DateTimeConfiguration(
val monthNames: MonthNames,
val dayOfWeekNames: DayOfWeekNames,
)
/**
* CompositionLocal that provides the default [DateTimeConfiguration] for date and time formatting.
* This configuration uses abbreviated English month and day of the week names by default.
* It can be overridden at a lower level in the composition tree to customize date and time formatting.
*/
val LocalDateTimeConfiguration = staticCompositionLocalOf {
DateTimeConfiguration(
monthNames = MonthNames.ENGLISH_ABBREVIATED,
dayOfWeekNames = DayOfWeekNames.ENGLISH_ABBREVIATED,
)
}

View file

@ -0,0 +1,17 @@
package net.thunderbird.core.ui.compose.common.modifier
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
/**
* Adds a test tag to the element with testTagsAsResourceId set to true.
* This allows the element to be found by its test tag during UI testing.
*
* @param tag The test tag to be assigned to the element.
* @return A [Modifier] with the test tag applied.
*/
fun Modifier.testTagAsResourceId(tag: String): Modifier = this
.semantics { testTagsAsResourceId = true }
.testTag(tag)

View file

@ -0,0 +1,33 @@
package app.k9mail.core.ui.compose.common.koin
import androidx.compose.runtime.Composable
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge
import app.k9mail.core.ui.compose.testing.ComposeTest
import app.k9mail.core.ui.compose.testing.onNodeWithText
import app.k9mail.core.ui.compose.testing.setContentWithTheme
import kotlin.test.Test
import org.koin.compose.koinInject
class KoinPreviewTest : ComposeTest() {
@Test
fun `koinPreview should make dependencies available in WithContent block`() = runComposeTest {
val injectString = "Test"
setContentWithTheme {
koinPreview {
factory { injectString }
} WithContent {
TestComposable()
}
}
onNodeWithText(injectString).assertExists()
}
}
@Composable
private fun TestComposable(
injected: String = koinInject(),
) {
TextBodyLarge(text = injected)
}

View file

@ -0,0 +1,109 @@
package app.k9mail.core.ui.compose.common.mvi
import app.cash.turbine.test
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import kotlinx.coroutines.test.runTest
import net.thunderbird.core.testing.coroutines.MainDispatcherRule
import org.junit.Rule
import org.junit.Test
class BaseViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `should emit initial state`() = runTest {
val viewModel = TestBaseViewModel()
assertThat(viewModel.state.value).isEqualTo("Initial state")
}
@Test
fun `should update state`() = runTest {
val viewModel = TestBaseViewModel()
viewModel.event("Test event")
assertThat(viewModel.state.value).isEqualTo("Test event")
viewModel.event("Another test event")
assertThat(viewModel.state.value).isEqualTo("Another test event")
}
@Test
fun `should emit effects`() = runTest {
val viewModel = TestBaseViewModel()
viewModel.effect.test {
viewModel.event("Test effect")
assertThat(awaitItem()).isEqualTo("Test effect")
viewModel.event("Another test effect")
assertThat(awaitItem()).isEqualTo("Another test effect")
}
}
@Test
fun `handleOneTimeEvent() should execute block`() = runTest {
val viewModel = TestBaseViewModel()
var eventHandled = false
viewModel.callHandleOneTimeEvent(event = "event") {
eventHandled = true
}
assertThat(eventHandled).isTrue()
}
@Test
fun `handleOneTimeEvent() should execute block only once`() = runTest {
val viewModel = TestBaseViewModel()
var eventHandledCount = 0
repeat(2) {
viewModel.callHandleOneTimeEvent(event = "event") {
eventHandledCount++
}
}
assertThat(eventHandledCount).isEqualTo(1)
}
@Test
fun `handleOneTimeEvent() should support multiple one-time events`() = runTest {
val viewModel = TestBaseViewModel()
var eventOneHandled = false
var eventTwoHandled = false
viewModel.callHandleOneTimeEvent(event = "eventOne") {
eventOneHandled = true
}
assertThat(eventOneHandled).isTrue()
assertThat(eventTwoHandled).isFalse()
viewModel.callHandleOneTimeEvent(event = "eventTwo") {
eventTwoHandled = true
}
assertThat(eventOneHandled).isTrue()
assertThat(eventTwoHandled).isTrue()
}
private class TestBaseViewModel : BaseViewModel<String, String, String>("Initial state") {
override fun event(event: String) {
updateState { event }
emitEffect(event)
}
fun callHandleOneTimeEvent(event: String, block: () -> Unit) {
handleOneTimeEvent(event, block)
}
}
}

View file

@ -0,0 +1,78 @@
package app.k9mail.core.ui.compose.common.mvi
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.k9mail.core.ui.compose.testing.ComposeTest
import app.k9mail.core.ui.compose.testing.setContent
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import net.thunderbird.core.testing.coroutines.MainDispatcherRule
import org.junit.Rule
import org.junit.Test
class UnidirectionalViewModelKtTest : ComposeTest() {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `observe should emit state changes, allow event dispatch and expose effects`() = runTest {
val viewModel = TestViewModel()
val effects = mutableListOf<TestEffect>()
lateinit var stateDispatch: StateDispatch<TestState, TestEvent>
setContent {
stateDispatch = viewModel.observe { effect ->
effects.add(effect)
}
}
val (state, dispatch) = stateDispatch
// Initial state
assertThat(state.value.data).isEqualTo("TestState: Initial")
// Dispatch an event
dispatch(TestEvent("Event 1"))
assertThat(state.value.data).isEqualTo("TestState: Event 1")
assertThat(effects.last().result).isEqualTo("TestEffect: Event 1")
// Dispatch another event
dispatch(TestEvent("Event 2"))
assertThat(state.value.data).isEqualTo("TestState: Event 2")
assertThat(effects.last().result).isEqualTo("TestEffect: Event 2")
}
private data class TestState(val data: String)
private data class TestEvent(val action: String)
private data class TestEffect(val result: String)
private class TestViewModel(
initialState: TestState = TestState("TestState: Initial"),
) : ViewModel(), UnidirectionalViewModel<TestState, TestEvent, TestEffect> {
private val _state = MutableStateFlow(initialState)
override val state: StateFlow<TestState> = _state.asStateFlow()
private val _effect = MutableSharedFlow<TestEffect>()
override val effect: SharedFlow<TestEffect> = _effect.asSharedFlow()
override fun event(event: TestEvent) {
_state.update { it.copy(data = "TestState: ${event.action}") }
viewModelScope.launch {
_effect.emit(TestEffect(result = "TestEffect: ${event.action}"))
}
}
}
}

View file

@ -0,0 +1,28 @@
package app.k9mail.core.ui.compose.common.resources
import androidx.compose.ui.text.buildAnnotatedString
import app.k9mail.core.ui.compose.common.test.R
import app.k9mail.core.ui.compose.common.text.bold
import app.k9mail.core.ui.compose.testing.ComposeTest
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Test
class StringResourcesTest : ComposeTest() {
@Test
fun `annotatedStringResource() with bold text`() = runComposeTest {
val argument = "text".bold()
setContent {
val result = annotatedStringResource(id = R.string.StringResourcesTest, argument)
assertThat(result).isEqualTo(
buildAnnotatedString {
append("prefix ")
append(argument)
append(" suffix")
},
)
}
}
}

View file

@ -0,0 +1,27 @@
package app.k9mail.core.ui.compose.common.text
import androidx.compose.ui.text.AnnotatedString.Range
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isEqualTo
import kotlin.test.Test
class AnnotatedStringsTest {
@Test
fun bold() {
val input = "text"
val result = input.bold()
assertThat(result.toString()).isEqualTo(input)
assertThat(result.spanStyles).containsExactly(
Range(
item = SpanStyle(fontWeight = FontWeight.Bold),
start = 0,
end = input.length,
),
)
}
}

View file

@ -0,0 +1,80 @@
package app.k9mail.core.ui.compose.common.window
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Test
class WindowSizeClassTest {
@Test
fun `should return compact when width is less than 600`() {
val width = 599
val windowSizeClass = WindowSizeClass.fromWidth(width)
assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Compact)
}
@Test
fun `should return medium when width is 600`() {
val width = 600
val windowSizeClass = WindowSizeClass.fromWidth(width)
assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Medium)
}
@Test
fun `should return medium when width is less than 840`() {
val width = 839
val windowSizeClass = WindowSizeClass.fromWidth(width)
assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Medium)
}
@Test
fun `should return expanded when width is 840`() {
val width = 840
val windowSizeClass = WindowSizeClass.fromWidth(width)
assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Expanded)
}
@Test
fun `should return compact when height is less than 480`() {
val height = 479
val windowSizeClass = WindowSizeClass.fromHeight(height)
assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Compact)
}
@Test
fun `should return medium when height is 480`() {
val height = 480
val windowSizeClass = WindowSizeClass.fromHeight(height)
assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Medium)
}
@Test
fun `should return medium when height is less than 900`() {
val height = 899
val windowSizeClass = WindowSizeClass.fromHeight(height)
assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Medium)
}
@Test
fun `should return expanded when height is 900`() {
val height = 900
val windowSizeClass = WindowSizeClass.fromHeight(height)
assertThat(windowSizeClass).isEqualTo(WindowSizeClass.Expanded)
}
}

View file

@ -0,0 +1,4 @@
<?xml version='1.0' encoding='UTF-8'?>
<resources>
<string name="StringResourcesTest">prefix %s suffix</string>
</resources>