Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
3
core/ui/compose/common/README.md
Normal file
3
core/ui/compose/common/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
## Core - UI - Compose - Common
|
||||
|
||||
This module contains common code for the compose UI.
|
||||
13
core/ui/compose/common/build.gradle.kts
Normal file
13
core/ui/compose/common/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<resources>
|
||||
<string name="StringResourcesTest">prefix %s suffix</string>
|
||||
</resources>
|
||||
Loading…
Add table
Add a link
Reference in a new issue