Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

tech/redux pattern #234

Merged
merged 9 commits into from
Nov 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions core/src/main/kotlin/app/dapk/st/core/JobBag.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app.dapk.st.core

import kotlinx.coroutines.Job
import kotlin.reflect.KClass

class JobBag {

Expand All @@ -11,8 +12,17 @@ class JobBag {
jobs[key] = job
}

fun replace(key: KClass<*>, job: Job) {
jobs[key.java.canonicalName]?.cancel()
jobs[key.java.canonicalName] = job
}

fun cancel(key: String) {
jobs.remove(key)?.cancel()
}

fun cancel(key: KClass<*>) {
jobs.remove(key.java.canonicalName)?.cancel()
}

}
9 changes: 9 additions & 0 deletions core/src/testFixtures/kotlin/fake/FakeJobBag.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package fake

import app.dapk.st.core.JobBag
import io.mockk.mockk

class FakeJobBag {
val instance = mockk<JobBag>()
}

Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@ data class SpiderPage<T>(
)

@JvmInline
value class Route<S>(val value: String)
value class Route<out S>(val value: String)
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package app.dapk.st.core.page

import app.dapk.st.design.components.SpiderPage
import app.dapk.state.*
import kotlin.reflect.KClass

sealed interface PageAction<out P> : Action {
data class GoTo<P : Any>(val page: SpiderPage<P>) : PageAction<P>
}

sealed interface PageStateChange : Action {
data class ChangePage<P : Any>(val previous: SpiderPage<out P>, val newPage: SpiderPage<out P>) : PageAction<P>
data class UpdatePage<P : Any>(val pageContent: P) : PageAction<P>
}

data class PageContainer<P>(
val page: SpiderPage<out P>
)

interface PageReducerScope<P> {
fun <PC : Any> withPageContent(page: KClass<PC>, block: PageDispatchScope<PC>.() -> Unit)
fun rawPage(): SpiderPage<out P>
}

interface PageDispatchScope<PC> {
fun ReducerScope<*>.pageDispatch(action: PageAction<PC>)
fun getPageState(): PC?
}

fun <P : Any, S : Any> createPageReducer(
initialPage: SpiderPage<out P>,
factory: PageReducerScope<P>.() -> ReducerFactory<S>,
): ReducerFactory<Combined2<PageContainer<P>, S>> = shareState {
combineReducers(createPageReducer(initialPage), factory(pageReducerScope()))
}

private fun <P : Any, S : Any> SharedStateScope<Combined2<PageContainer<P>, S>>.pageReducerScope() = object : PageReducerScope<P> {
override fun <PC : Any> withPageContent(page: KClass<PC>, block: PageDispatchScope<PC>.() -> Unit) {
val currentPage = getSharedState().state1.page.state
if (currentPage::class == page) {
val pageDispatchScope = object : PageDispatchScope<PC> {
override fun ReducerScope<*>.pageDispatch(action: PageAction<PC>) {
val currentPageGuard = getSharedState().state1.page.state
if (currentPageGuard::class == page) {
dispatch(action)
}
}

override fun getPageState() = getSharedState().state1.page.state as? PC
}
block(pageDispatchScope)
}
}

override fun rawPage() = getSharedState().state1.page
}

@Suppress("UNCHECKED_CAST")
private fun <P : Any> createPageReducer(
initialPage: SpiderPage<out P>
): ReducerFactory<PageContainer<P>> {
return createReducer(
initialState = PageContainer(
page = initialPage
),

async(PageAction.GoTo::class) { action ->
val state = getState()
if (state.page.state::class != action.page.state::class) {
dispatch(PageStateChange.ChangePage(previous = state.page, newPage = action.page))
}
},

change(PageStateChange.ChangePage::class) { action, state ->
state.copy(page = action.newPage as SpiderPage<out P>)
},

change(PageStateChange.UpdatePage::class) { action, state ->
val isSamePage = state.page.state::class == action.pageContent::class
if (isSamePage) {
val updatedPageContent = (state.page as SpiderPage<Any>).copy(state = action.pageContent)
state.copy(page = updatedPageContent as SpiderPage<out P>)
} else {
state
}
},
)
}

inline fun <reified PC : Any> PageReducerScope<*>.withPageContext(crossinline block: PageDispatchScope<PC>.(PC) -> Unit) {
withPageContent(PC::class) { getPageState()?.let { block(it) } }
}

49 changes: 45 additions & 4 deletions domains/state/src/main/kotlin/app/dapk/state/State.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ fun interface Reducer<S> {

private fun <S> createScope(coroutineScope: CoroutineScope, store: Store<S>) = object : ReducerScope<S> {
override val coroutineScope = coroutineScope
override suspend fun dispatch(action: Action) = store.dispatch(action)
override fun dispatch(action: Action) = store.dispatch(action)
override fun getState(): S = store.getState()
}

Expand All @@ -52,7 +52,7 @@ interface Store<S> {

interface ReducerScope<S> {
val coroutineScope: CoroutineScope
suspend fun dispatch(action: Action)
fun dispatch(action: Action)
fun getState(): S
}

Expand All @@ -64,6 +64,45 @@ sealed interface ActionHandler<S> {
class Delegate<S>(override val key: KClass<Action>, val handler: ReducerScope<S>.(Action) -> ActionHandler<S>) : ActionHandler<S>
}

data class Combined2<S1, S2>(val state1: S1, val state2: S2)

fun interface SharedStateScope<C> {
fun getSharedState(): C
}

fun <S> shareState(block: SharedStateScope<S>.() -> ReducerFactory<S>): ReducerFactory<S> {
var internalScope: ReducerScope<S>? = null
val scope = SharedStateScope { internalScope!!.getState() }
val combinedFactory = block(scope)
return object : ReducerFactory<S> {
override fun create(scope: ReducerScope<S>) = combinedFactory.create(scope).also { internalScope = scope }
override fun initialState() = combinedFactory.initialState()
}
}

fun <S1, S2> combineReducers(r1: ReducerFactory<S1>, r2: ReducerFactory<S2>): ReducerFactory<Combined2<S1, S2>> {
return object : ReducerFactory<Combined2<S1, S2>> {
override fun create(scope: ReducerScope<Combined2<S1, S2>>): Reducer<Combined2<S1, S2>> {
val r1Scope = createReducerScope(scope) { scope.getState().state1 }
val r2Scope = createReducerScope(scope) { scope.getState().state2 }

val r1Reducer = r1.create(r1Scope)
val r2Reducer = r2.create(r2Scope)
return Reducer {
Combined2(r1Reducer.reduce(it), r2Reducer.reduce(it))
}
}

override fun initialState(): Combined2<S1, S2> = Combined2(r1.initialState(), r2.initialState())
}
}

private fun <S> createReducerScope(scope: ReducerScope<*>, state: () -> S) = object : ReducerScope<S> {
override val coroutineScope: CoroutineScope = scope.coroutineScope
override fun dispatch(action: Action) = scope.dispatch(action)
override fun getState() = state.invoke()
}

fun <S> createReducer(
initialState: S,
vararg reducers: (ReducerScope<S>) -> ActionHandler<S>,
Expand Down Expand Up @@ -132,9 +171,10 @@ fun <A : Action, S> async(klass: KClass<A>, block: suspend ReducerScope<S>.(A) -

fun <A : Action, S> multi(klass: KClass<A>, block: Multi<A, S>.(A) -> (ReducerScope<S>) -> ActionHandler<S>): (ReducerScope<S>) -> ActionHandler<S> {
val multiScope = object : Multi<A, S> {
override fun sideEffect(block: (A, S) -> Unit): (ReducerScope<S>) -> ActionHandler<S> = sideEffect(klass, block)
override fun sideEffect(block: suspend (S) -> Unit): (ReducerScope<S>) -> ActionHandler<S> = sideEffect(klass) { _, state -> block(state) }
override fun change(block: (A, S) -> S): (ReducerScope<S>) -> ActionHandler<S> = change(klass, block)
override fun async(block: suspend ReducerScope<S>.(A) -> Unit): (ReducerScope<S>) -> ActionHandler<S> = async(klass, block)
override fun nothing() = sideEffect { }
}

return {
Expand All @@ -145,7 +185,8 @@ fun <A : Action, S> multi(klass: KClass<A>, block: Multi<A, S>.(A) -> (ReducerSc
}

interface Multi<A : Action, S> {
fun sideEffect(block: (A, S) -> Unit): (ReducerScope<S>) -> ActionHandler<S>
fun sideEffect(block: suspend (S) -> Unit): (ReducerScope<S>) -> ActionHandler<S>
fun nothing(): (ReducerScope<S>) -> ActionHandler<S>
fun change(block: (A, S) -> S): (ReducerScope<S>) -> ActionHandler<S>
fun async(block: suspend ReducerScope<S>.(A) -> Unit): (ReducerScope<S>) -> ActionHandler<S>
}
Expand Down
18 changes: 17 additions & 1 deletion domains/state/src/testFixtures/kotlin/test/ReducerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class ReducerTestScope<S, E>(
private val actionCaptures = mutableListOf<Action>()
private val reducerScope = object : ReducerScope<S> {
override val coroutineScope = CoroutineScope(UnconfinedTestDispatcher())
override suspend fun dispatch(action: Action) {
override fun dispatch(action: Action) {
actionCaptures.add(action)
}

Expand Down Expand Up @@ -123,4 +123,20 @@ class ReducerTestScope<S, E>(
assertNoEvents()
assertNoDispatches()
}
}

fun <S, E> ReducerTestScope<S, E>.assertOnlyDispatches(vararg action: Action) {
this.assertOnlyDispatches(action.toList())
}

fun <S, E> ReducerTestScope<S, E>.assertDispatches(vararg action: Action) {
this.assertDispatches(action.toList())
}

fun <S, E> ReducerTestScope<S, E>.assertEvents(vararg event: E) {
this.assertEvents(event.toList())
}

fun <S, E> ReducerTestScope<S, E>.assertOnlyEvents(vararg event: E) {
this.assertOnlyEvents(event.toList())
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ internal fun directoryReducer(
}.launchIn(coroutineScope))
}

ComponentLifecycle.OnGone -> sideEffect { _, _ -> jobBag.cancel(KEY_SYNCING_JOB) }
ComponentLifecycle.OnGone -> sideEffect { jobBag.cancel(KEY_SYNCING_JOB) }
}
},

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package app.dapk.st.directory

import app.dapk.st.core.JobBag
import app.dapk.st.directory.state.*
import app.dapk.st.engine.UnreadCount
import fake.FakeChatEngine
import fake.FakeJobBag
import fixture.aRoomOverview
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
Expand Down Expand Up @@ -90,8 +90,3 @@ class DirectoryReducerTest {
internal class FakeShortcutHandler {
val instance = mockk<ShortcutHandler>()
}

class FakeJobBag {
val instance = mockk<JobBag>()
}

Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ import kotlinx.parcelize.Parcelize
class ImageGalleryActivity : DapkActivity() {

private val module by unsafeLazy { module<ImageGalleryModule>() }
private val viewModel by viewModel {
private val imageGalleryState by state {
val payload = intent.getParcelableExtra("key") as? ImageGalleryActivityPayload
module.imageGalleryViewModel(payload!!.roomName)
module.imageGalleryState(payload!!.roomName)
}

override fun onCreate(savedInstanceState: Bundle?) {
Expand All @@ -42,7 +42,7 @@ class ImageGalleryActivity : DapkActivity() {
setContent {
Surface {
PermissionGuard(permissionState) {
ImageGalleryScreen(viewModel, onTopLevelBack = { finish() }) { media ->
ImageGalleryScreen(imageGalleryState, onTopLevelBack = { finish() }) { media ->
setResult(RESULT_OK, Intent().setData(media.uri))
finish()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
package app.dapk.st.messenger.gallery

import android.content.ContentResolver
import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.ProvidableModule
import app.dapk.st.core.*
import app.dapk.st.messenger.gallery.state.ImageGalleryState
import app.dapk.st.messenger.gallery.state.imageGalleryReducer

class ImageGalleryModule(
private val contentResolver: ContentResolver,
private val dispatchers: CoroutineDispatchers,
) : ProvidableModule {

fun imageGalleryViewModel(roomName: String) = ImageGalleryViewModel(
FetchMediaFoldersUseCase(contentResolver, dispatchers),
FetchMediaUseCase(contentResolver, dispatchers),
roomName = roomName,
)
fun imageGalleryState(roomName: String): ImageGalleryState = createStateViewModel {
imageGalleryReducer(
roomName = roomName,
FetchMediaFoldersUseCase(contentResolver, dispatchers),
FetchMediaUseCase(contentResolver, dispatchers),
JobBag(),
)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -21,38 +21,45 @@ import androidx.compose.ui.unit.sp
import app.dapk.st.core.Lce
import app.dapk.st.core.LifecycleEffect
import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.core.page.PageAction
import app.dapk.st.design.components.GenericError
import app.dapk.st.design.components.Spider
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.messenger.gallery.state.ImageGalleryActions
import app.dapk.st.messenger.gallery.state.ImageGalleryPage
import app.dapk.st.messenger.gallery.state.ImageGalleryState
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest

@Composable
fun ImageGalleryScreen(viewModel: ImageGalleryViewModel, onTopLevelBack: () -> Unit, onImageSelected: (Media) -> Unit) {
fun ImageGalleryScreen(state: ImageGalleryState, onTopLevelBack: () -> Unit, onImageSelected: (Media) -> Unit) {
LifecycleEffect(onStart = {
viewModel.start()
state.dispatch(ImageGalleryActions.Visible)
})

val onNavigate: (SpiderPage<out ImageGalleryPage>?) -> Unit = {
when (it) {
null -> onTopLevelBack()
else -> viewModel.goTo(it)
else -> state.dispatch(PageAction.GoTo(it))
}
}

Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) {
Spider(currentPage = state.current.state1.page, onNavigate = onNavigate) {
item(ImageGalleryPage.Routes.folders) {
ImageGalleryFolders(it, onClick = { viewModel.selectFolder(it) }, onRetry = { viewModel.start() })
ImageGalleryFolders(
it,
onClick = { state.dispatch(ImageGalleryActions.SelectFolder(it)) },
onRetry = { state.dispatch(ImageGalleryActions.Visible) }
)
}
item(ImageGalleryPage.Routes.files) {
ImageGalleryMedia(it, onImageSelected, onRetry = { viewModel.selectFolder(it.folder) })
ImageGalleryMedia(it, onImageSelected, onRetry = { state.dispatch(ImageGalleryActions.SelectFolder(it.folder)) })
}
}

}

@Composable
fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Unit, onRetry: () -> Unit) {
private fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Unit, onRetry: () -> Unit) {
val screenWidth = LocalConfiguration.current.screenWidthDp

val gradient = Brush.verticalGradient(
Expand Down Expand Up @@ -108,7 +115,7 @@ fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Un
}

@Composable
fun ImageGalleryMedia(state: ImageGalleryPage.Files, onFileSelected: (Media) -> Unit, onRetry: () -> Unit) {
private fun ImageGalleryMedia(state: ImageGalleryPage.Files, onFileSelected: (Media) -> Unit, onRetry: () -> Unit) {
val screenWidth = LocalConfiguration.current.screenWidthDp

Column {
Expand Down
Loading