Skip to content

Commit

Permalink
Gallery Mock Client (#615)
Browse files Browse the repository at this point in the history
* Adjust default HttpClient for MockEngine injection

* Simplify Gallery state object for inclusion of a user state

* Remove out variant type

* Resolve test failures

* Remove unused import statement
  • Loading branch information
ashdavies authored Nov 7, 2023
1 parent b3ce6d5 commit 340f969
Show file tree
Hide file tree
Showing 13 changed files with 194 additions and 207 deletions.
1 change: 1 addition & 0 deletions gallery-app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ kotlin {
implementation(projects.sqlDriver)

implementation(libs.essenty.parcelable)
implementation(libs.ktor.client.mock)
implementation(libs.slack.circuit.foundation)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package io.ashdavies.gallery

import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import io.ashdavies.content.PlatformContext

private val DefaultState = GalleryScreen.State(
itemList = emptyList(),
showCapture = false,
isLoggedIn = false,
eventSink = { },
)

private val EmptyStorageManager = object : StorageManager {
override fun create(context: PlatformContext): File = TempFile
override fun delete(file: File): Boolean = false
}

private val TempFile = File.createTempFile(
/* prefix = */ "tmp",
/* suffix = */ null,
)

@Preview
@Composable
internal fun GalleryCaptureDefaultPreview() {
GalleryCapture(EmptyStorageManager) { }
}

@Preview
@Composable
internal fun GalleryBottomBarDefaultPreview() {
GalleryBottomBar(DefaultState, isSelecting = false)
}

@Preview
@Composable
internal fun GalleryBottomBarSelectingPreview() {
GalleryBottomBar(DefaultState, isSelecting = true)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,6 @@ internal actual fun StorageManager(parent: File): StorageManager = object : Stor
return file
}

override fun list(): List<File> {
val files = parent.listFiles() ?: return emptyList()
return files.toList()
}

override fun delete(file: File): Boolean {
if (!file.exists()) throw IllegalArgumentException()
return file.delete()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
package io.ashdavies.gallery

import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
Expand Down Expand Up @@ -34,50 +33,29 @@ public object GalleryScreen : Parcelable, Screen {
data object Sync : Event
}

internal sealed interface State : CircuitUiState {
data class Empty(val eventSink: (Event) -> Unit) : State

data class Success(
val itemList: List<Item>,
val showCapture: Boolean,
val eventSink: (Event) -> Unit,
) : State {
constructor(
itemList: List<Image>,
isSelected: (Image) -> Boolean,
state: (Image) -> SyncState,
showCapture: Boolean,
eventSink: (Event) -> Unit,
) : this(
itemList = itemList.map { image ->
Item(
name = image.name,
file = File(image.path),
isSelected = isSelected(image),
state = state(image),
)
},
showCapture = showCapture,
eventSink = eventSink,
)

data class Item(
val name: String,
val file: File,
val isSelected: Boolean,
val state: SyncState,
)
}

data object Loading : State
internal data class State(
val itemList: List<Item>,
val showCapture: Boolean,
val isLoggedIn: Boolean,
val eventSink: (Event) -> Unit,
) : CircuitUiState {

data class Item(
val name: String,
val file: File,
val isSelected: Boolean,
val state: SyncState,
)
}
}

public fun GalleryPresenterFactory(context: PlatformContext): Presenter.Factory {
val database = DatabaseFactory(PlaygroundDatabase.Schema, context) { PlaygroundDatabase(it) }
val storage = StorageManager(PathProvider(context).images)
val images = ImageManager(storage, database.imageQueries)
val sync = SyncManager(DefaultHttpClient())

val engine = InMemoryHttpClientEngine(emptyList())
val sync = SyncManager(DefaultHttpClient(engine))

return Presenter.Factory { screen, _, _ ->
when (screen) {
Expand Down Expand Up @@ -111,20 +89,29 @@ internal fun GalleryPresenter(
sync: SyncManager,
): GalleryScreen.State {
val itemList by produceState(emptyList<Image>(), images) {
images.list().collect { value = it }
images.list.collect { value = it }
}

var selected by remember { mutableStateOf(emptyList<Image>()) }
var takePhoto by remember { mutableStateOf(false) }

val syncState by sync.state().collectAsState(emptyMap())
val syncState by produceState(emptyMap<String, SyncState>()) {
sync.state.collect { value = it }
}

val coroutineScope = rememberCoroutineScope()

return GalleryScreen.State.Success(
itemList = itemList,
isSelected = { it in selected },
state = { syncState[it.name] ?: SyncState.NOT_SYNCED },
return GalleryScreen.State(
itemList = itemList.map {
GalleryScreen.State.Item(
name = it.name,
file = File(it.path),
isSelected = it in selected,
state = syncState[it.name] ?: SyncState.NOT_SYNCED,
)
},
showCapture = takePhoto,
isLoggedIn = false,
) { event ->
when (event) {
is GalleryScreen.Event.Capture -> takePhoto = true
Expand All @@ -136,6 +123,7 @@ internal fun GalleryPresenter(

is GalleryScreen.Event.Sync -> coroutineScope.launch {
selected.forEach { sync.sync(it.path) }
selected = emptyList()
}

is GalleryScreen.Event.Result -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
Expand Down Expand Up @@ -89,18 +88,17 @@ internal fun GalleryScreen(
manager: StorageManager,
modifier: Modifier = Modifier,
) {
val isSelecting = state is GalleryScreen.State.Success && state.itemList.any { it.isSelected }
val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState())
val isSelecting = state.itemList.any { it.isSelected }

Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { GalleryTopAppBar(scrollBehavior) },
bottomBar = { GalleryBottomBar(state, isSelecting) },
) { paddingValues ->
when (state) {
GalleryScreen.State.Loading -> GalleryProgressIndicator()
is GalleryScreen.State.Empty -> GalleryEmpty()
is GalleryScreen.State.Success -> {
when {
state.itemList.isEmpty() -> GalleryEmpty()
else -> {
GalleryGrid(
itemList = state.itemList.toImmutableList(),
isSelecting = isSelecting,
Expand All @@ -121,7 +119,7 @@ internal fun GalleryScreen(

@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun GalleryTopAppBar(
internal fun GalleryTopAppBar(
scrollBehavior: TopAppBarScrollBehavior,
title: String = "Gallery",
modifier: Modifier = Modifier,
Expand All @@ -143,19 +141,7 @@ private fun GalleryTopAppBar(
}

@Composable
private fun GalleryProgressIndicator(modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.onSurface,
)
}
}

@Composable
private fun GalleryEmpty(modifier: Modifier = Modifier) {
internal fun GalleryEmpty(modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
Expand All @@ -166,8 +152,8 @@ private fun GalleryEmpty(modifier: Modifier = Modifier) {

@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun GalleryGrid(
itemList: ImmutableList<GalleryScreen.State.Success.Item>,
internal fun GalleryGrid(
itemList: ImmutableList<GalleryScreen.State.Item>,
isSelecting: Boolean = false,
modifier: Modifier = Modifier,
onSelect: (Int) -> Unit,
Expand Down Expand Up @@ -250,7 +236,7 @@ private fun GalleryGrid(
}

@Composable
private fun SyncIndicator(isSyncing: Boolean, modifier: Modifier = Modifier) {
internal fun SyncIndicator(isSyncing: Boolean, modifier: Modifier = Modifier) {
val tint by animateColorAsState(if (isSyncing) Color.Orange else Color.LightGreen)
val scale by animateFloatAsState(if (isSyncing) 0.75f else 1f)

Expand Down Expand Up @@ -298,7 +284,7 @@ private fun SyncIndicator(isSyncing: Boolean, modifier: Modifier = Modifier) {
}

@Composable
private fun GalleryCapture(
internal fun GalleryCapture(
manager: StorageManager,
modifier: Modifier = Modifier,
eventSink: (GalleryScreen.Event) -> Unit,
Expand All @@ -311,33 +297,25 @@ private fun GalleryCapture(
}

@Composable
private fun GalleryBottomBar(
internal fun GalleryBottomBar(
state: GalleryScreen.State,
isSelecting: Boolean,
modifier: Modifier = Modifier,
) {
val eventSink = when (state) {
is GalleryScreen.State.Success -> state.eventSink
is GalleryScreen.State.Empty -> state.eventSink
else -> null
}
val eventSink = state.eventSink

BottomAppBar(
actions = {
AnimatedVisibility(
visible = eventSink != null && isSelecting,
visible = isSelecting,
enter = slideIn(initialOffset = { IntOffset(0, 200) }),
exit = slideOut(targetOffset = { IntOffset(0, 200) }),
) {
check(eventSink != null) { "Event sink cannot be null" }

Row {
Box(modifier = Modifier.padding(horizontal = 4.dp)) {
IconButton(
onClick = { eventSink(GalleryScreen.Event.Sync) },
enabled = state is GalleryScreen.State.Success && state.itemList.none {
it.state == SyncState.SYNCING
},
enabled = state.itemList.none { it.state == SyncState.SYNCING },
) {
Icon(
imageVector = Icons.Filled.Sync,
Expand All @@ -359,19 +337,11 @@ private fun GalleryBottomBar(
},
modifier = modifier,
floatingActionButton = {
AnimatedVisibility(
visible = eventSink != null,
enter = fadeIn(),
exit = fadeOut(),
) {
check(eventSink != null) { "Event sink cannot be null" }

FloatingActionButton(onClick = { eventSink(GalleryScreen.Event.Capture) }) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Add",
)
}
FloatingActionButton(onClick = { eventSink(GalleryScreen.Event.Capture) }) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Add",
)
}
},
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package io.ashdavies.gallery

import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.request.HttpRequestData
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.headersOf
import io.ktor.utils.io.ByteReadChannel

private val DefaultHeaders = headersOf(HttpHeaders.ContentType, "application/json")

private val HttpRequestData.path: String
get() = url.encodedPath.substring(1)

private val Headers.contentLength: String
get() = requireNotNull(get(HttpHeaders.ContentLength))

internal fun InMemoryHttpClientEngine(initialValue: List<String>): HttpClientEngine {
val values = initialValue.toMutableList()

return MockEngine { request ->
when {
request.method == HttpMethod.Get && request.path.isEmpty() -> {
val text = "[${values.joinToString(transform = { "\"$it\"" })}]"

respond(ByteReadChannel(text), headers = DefaultHeaders)
}

request.method == HttpMethod.Post && request.path.isNotEmpty() -> {
require(request.body.contentLength == request.headers.contentLength.toLong())
values += request.path

respond(ByteReadChannel.Empty, headers = DefaultHeaders)
}

request.method == HttpMethod.Put && request.path.isNotEmpty() -> {
require(request.body.contentLength == 0L)
values += request.path

respond(ByteReadChannel.Empty, headers = DefaultHeaders)
}

request.method == HttpMethod.Delete && request.path.isNotEmpty() -> {
require(request.body.contentLength == 0L)
values -= request.path

respond(ByteReadChannel.Empty, headers = DefaultHeaders)
}

else -> error("Unhandled request: $request")
}
}
}
Loading

0 comments on commit 340f969

Please sign in to comment.