port profile and page to reducer pattern

This commit is contained in:
Adam Brown 2022-11-01 17:36:22 +00:00
parent 72fa795d38
commit 70bc1be72d
13 changed files with 216 additions and 118 deletions

View File

@ -10,6 +10,11 @@ class JobBag {
jobs[key] = job jobs[key] = job
} }
fun replace(key: String, job: Job) {
jobs[key]?.cancel()
jobs[key] = job
}
fun cancel(key: String) { fun cancel(key: String) {
jobs.remove(key)?.cancel() jobs.remove(key)?.cancel()
} }

View File

@ -0,0 +1,9 @@
package fake
import app.dapk.st.core.JobBag
import io.mockk.mockk
class FakeJobBag {
val instance = mockk<JobBag>()
}

View File

@ -64,6 +64,48 @@ sealed interface ActionHandler<S> {
class Delegate<S>(override val key: KClass<Action>, val handler: ReducerScope<S>.(Action) -> ActionHandler<S>) : 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 = object : ReducerScope<S1> {
override val coroutineScope: CoroutineScope = scope.coroutineScope
override suspend fun dispatch(action: Action) = scope.dispatch(action)
override fun getState() = scope.getState().state1
}
val r2Scope = object : ReducerScope<S2> {
override val coroutineScope: CoroutineScope = scope.coroutineScope
override suspend fun dispatch(action: Action) = scope.dispatch(action)
override fun getState() = 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())
}
}
fun <S> createReducer( fun <S> createReducer(
initialState: S, initialState: S,
vararg reducers: (ReducerScope<S>) -> ActionHandler<S>, vararg reducers: (ReducerScope<S>) -> ActionHandler<S>,

View File

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

View File

@ -23,9 +23,9 @@ import kotlinx.parcelize.Parcelize
class ImageGalleryActivity : DapkActivity() { class ImageGalleryActivity : DapkActivity() {
private val module by unsafeLazy { module<ImageGalleryModule>() } private val module by unsafeLazy { module<ImageGalleryModule>() }
private val viewModel by viewModel { private val imageGalleryState by state {
val payload = intent.getParcelableExtra("key") as? ImageGalleryActivityPayload val payload = intent.getParcelableExtra("key") as? ImageGalleryActivityPayload
module.imageGalleryViewModel(payload!!.roomName) module.imageGalleryState(payload!!.roomName)
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -42,7 +42,7 @@ class ImageGalleryActivity : DapkActivity() {
setContent { setContent {
Surface { Surface {
PermissionGuard(permissionState) { PermissionGuard(permissionState) {
ImageGalleryScreen(viewModel, onTopLevelBack = { finish() }) { media -> ImageGalleryScreen(imageGalleryState, onTopLevelBack = { finish() }) { media ->
setResult(RESULT_OK, Intent().setData(media.uri)) setResult(RESULT_OK, Intent().setData(media.uri))
finish() finish()
} }

View File

@ -1,18 +1,22 @@
package app.dapk.st.messenger.gallery package app.dapk.st.messenger.gallery
import android.content.ContentResolver import android.content.ContentResolver
import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.*
import app.dapk.st.core.ProvidableModule import app.dapk.st.messenger.gallery.state.ImageGalleryState
import app.dapk.st.messenger.gallery.state.imageGalleryReducer
class ImageGalleryModule( class ImageGalleryModule(
private val contentResolver: ContentResolver, private val contentResolver: ContentResolver,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
) : ProvidableModule { ) : ProvidableModule {
fun imageGalleryViewModel(roomName: String) = ImageGalleryViewModel( fun imageGalleryState(roomName: String): ImageGalleryState = createStateViewModel {
FetchMediaFoldersUseCase(contentResolver, dispatchers), imageGalleryReducer(
FetchMediaUseCase(contentResolver, dispatchers), roomName = roomName,
roomName = roomName, FetchMediaFoldersUseCase(contentResolver, dispatchers),
) FetchMediaUseCase(contentResolver, dispatchers),
JobBag(),
)
}
} }

View File

@ -24,35 +24,41 @@ import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.design.components.GenericError import app.dapk.st.design.components.GenericError
import app.dapk.st.design.components.Spider import app.dapk.st.design.components.Spider
import app.dapk.st.design.components.SpiderPage 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.compose.rememberAsyncImagePainter
import coil.request.ImageRequest import coil.request.ImageRequest
@Composable @Composable
fun ImageGalleryScreen(viewModel: ImageGalleryViewModel, onTopLevelBack: () -> Unit, onImageSelected: (Media) -> Unit) { fun ImageGalleryScreen(state: ImageGalleryState, onTopLevelBack: () -> Unit, onImageSelected: (Media) -> Unit) {
LifecycleEffect(onStart = { LifecycleEffect(onStart = {
viewModel.start() state.dispatch(ImageGalleryActions.Visible)
}) })
val onNavigate: (SpiderPage<out ImageGalleryPage>?) -> Unit = { val onNavigate: (SpiderPage<out ImageGalleryPage>?) -> Unit = {
when (it) { when (it) {
null -> onTopLevelBack() 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) { 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) { item(ImageGalleryPage.Routes.files) {
ImageGalleryMedia(it, onImageSelected, onRetry = { viewModel.selectFolder(it.folder) }) ImageGalleryMedia(it, onImageSelected, onRetry = { state.dispatch(ImageGalleryActions.SelectFolder(it.folder)) })
} }
} }
} }
@Composable @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 screenWidth = LocalConfiguration.current.screenWidthDp
val gradient = Brush.verticalGradient( val gradient = Brush.verticalGradient(
@ -108,7 +114,7 @@ fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Un
} }
@Composable @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 val screenWidth = LocalConfiguration.current.screenWidthDp
Column { Column {

View File

@ -1,89 +0,0 @@
package app.dapk.st.messenger.gallery
import androidx.lifecycle.viewModelScope
import app.dapk.st.core.Lce
import app.dapk.st.design.components.Route
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.viewmodel.DapkViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class ImageGalleryViewModel(
private val foldersUseCase: FetchMediaFoldersUseCase,
private val fetchMediaUseCase: FetchMediaUseCase,
roomName: String,
) : DapkViewModel<ImageGalleryState, ImageGalleryEvent>(
initialState = ImageGalleryState(
page = SpiderPage(
route = ImageGalleryPage.Routes.folders,
label = "Send to $roomName",
parent = null,
state = ImageGalleryPage.Folders(Lce.Loading())
)
)
) {
private var currentPageJob: Job? = null
fun start() {
currentPageJob?.cancel()
currentPageJob = viewModelScope.launch {
val folders = foldersUseCase.fetchFolders()
updatePageState<ImageGalleryPage.Folders> { copy(content = Lce.Content(folders)) }
}
}
fun goTo(page: SpiderPage<out ImageGalleryPage>) {
currentPageJob?.cancel()
updateState { copy(page = page) }
}
fun selectFolder(folder: Folder) {
currentPageJob?.cancel()
updateState {
copy(
page = SpiderPage(
route = ImageGalleryPage.Routes.files,
label = page.label,
parent = ImageGalleryPage.Routes.folders,
state = ImageGalleryPage.Files(Lce.Loading(), folder)
)
)
}
currentPageJob = viewModelScope.launch {
val media = fetchMediaUseCase.getMediaInBucket(folder.bucketId)
updatePageState<ImageGalleryPage.Files> {
copy(content = Lce.Content(media))
}
}
}
@Suppress("UNCHECKED_CAST")
private inline fun <reified S : ImageGalleryPage> updatePageState(crossinline block: S.() -> S) {
val page = state.page
val currentState = page.state
require(currentState is S)
updateState { copy(page = (page as SpiderPage<S>).copy(state = block(page.state))) }
}
}
data class ImageGalleryState(
val page: SpiderPage<out ImageGalleryPage>,
)
sealed interface ImageGalleryPage {
data class Folders(val content: Lce<List<Folder>>) : ImageGalleryPage
data class Files(val content: Lce<List<Media>>, val folder: Folder) : ImageGalleryPage
object Routes {
val folders = Route<Folders>("Folders")
val files = Route<Files>("Files")
}
}
sealed interface ImageGalleryEvent

View File

@ -0,0 +1,40 @@
package app.dapk.st.messenger.gallery
import app.dapk.st.design.components.SpiderPage
import app.dapk.state.Action
import app.dapk.state.ReducerFactory
import app.dapk.state.change
import app.dapk.state.createReducer
fun <P : Any> createPageReducer(
initialPage: SpiderPage<out P>
): ReducerFactory<PageContainer<P>> {
return createReducer(
initialState = PageContainer(
page = initialPage
),
change(PageAction.GoTo::class) { action, state ->
state.copy(page = action.page as SpiderPage<P>)
},
change(PageAction.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
}
},
)
}
sealed interface PageAction : Action {
data class GoTo<P : Any>(val page: SpiderPage<P>) : PageAction
data class UpdatePage<P : Any>(val pageContent: P) : PageAction
}
data class PageContainer<P>(
val page: SpiderPage<out P>
)

View File

@ -0,0 +1,9 @@
package app.dapk.st.messenger.gallery.state
import app.dapk.st.messenger.gallery.Folder
import app.dapk.state.Action
sealed interface ImageGalleryActions : Action {
object Visible : ImageGalleryActions
data class SelectFolder(val folder: Folder) : ImageGalleryActions
}

View File

@ -0,0 +1,59 @@
package app.dapk.st.messenger.gallery.state
import app.dapk.st.core.JobBag
import app.dapk.st.core.Lce
import app.dapk.st.design.components.SpiderPage
import app.dapk.st.messenger.gallery.*
import app.dapk.state.*
import kotlinx.coroutines.launch
fun imageGalleryReducer(
roomName: String,
foldersUseCase: FetchMediaFoldersUseCase,
fetchMediaUseCase: FetchMediaUseCase,
jobBag: JobBag,
) = shareState {
combineReducers(
createPageReducer(roomName),
createImageGalleryPageReducer(jobBag, foldersUseCase, fetchMediaUseCase),
)
}
private fun createPageReducer(roomName: String): ReducerFactory<PageContainer<ImageGalleryPage>> = createPageReducer(
initialPage = SpiderPage(
route = ImageGalleryPage.Routes.folders,
label = "Send to $roomName",
parent = null,
state = ImageGalleryPage.Folders(Lce.Loading())
)
)
private fun SharedStateScope<Combined2<PageContainer<ImageGalleryPage>, Unit>>.createImageGalleryPageReducer(
jobBag: JobBag,
foldersUseCase: FetchMediaFoldersUseCase,
fetchMediaUseCase: FetchMediaUseCase
) = createReducer(
initialState = Unit,
async(ImageGalleryActions.Visible::class) {
jobBag.replace("page", coroutineScope.launch {
val folders = foldersUseCase.fetchFolders()
dispatch(PageAction.UpdatePage(ImageGalleryPage.Folders(Lce.Content(folders))))
})
},
async(ImageGalleryActions.SelectFolder::class) { action ->
val page = SpiderPage(
route = ImageGalleryPage.Routes.files,
label = getSharedState().state1.page.label,
parent = ImageGalleryPage.Routes.folders,
state = ImageGalleryPage.Files(Lce.Loading(), action.folder)
)
dispatch(PageAction.GoTo(page))
jobBag.replace("page", coroutineScope.launch {
val media = fetchMediaUseCase.getMediaInBucket(action.folder.bucketId)
dispatch(PageAction.UpdatePage(page.state.copy(content = Lce.Content(media))))
})
},
)

View File

@ -0,0 +1,21 @@
package app.dapk.st.messenger.gallery.state
import app.dapk.st.core.Lce
import app.dapk.st.core.State
import app.dapk.st.design.components.Route
import app.dapk.st.messenger.gallery.Folder
import app.dapk.st.messenger.gallery.Media
import app.dapk.st.messenger.gallery.PageContainer
import app.dapk.state.Combined2
typealias ImageGalleryState = State<Combined2<PageContainer<ImageGalleryPage>, Unit>, Unit>
sealed interface ImageGalleryPage {
data class Folders(val content: Lce<List<Folder>>) : ImageGalleryPage
data class Files(val content: Lce<List<Media>>, val folder: Folder) : ImageGalleryPage
object Routes {
val folders = Route<Folders>("Folders")
val files = Route<Files>("Files")
}
}

View File

@ -12,6 +12,7 @@ import app.dapk.st.matrix.common.asString
import app.dapk.st.messenger.state.* import app.dapk.st.messenger.state.*
import app.dapk.st.navigator.MessageAttachment import app.dapk.st.navigator.MessageAttachment
import fake.FakeChatEngine import fake.FakeChatEngine
import fake.FakeJobBag
import fake.FakeMessageOptionsStore import fake.FakeMessageOptionsStore
import fixture.* import fixture.*
import io.mockk.every import io.mockk.every
@ -351,10 +352,6 @@ class FakeCopyToClipboard {
val instance = mockk<CopyToClipboard>() val instance = mockk<CopyToClipboard>()
} }
class FakeJobBag {
val instance = mockk<JobBag>()
}
class FakeDeviceMeta { class FakeDeviceMeta {
val instance = mockk<DeviceMeta>() val instance = mockk<DeviceMeta>()