diff --git a/core/src/main/kotlin/app/dapk/st/core/JobBag.kt b/core/src/main/kotlin/app/dapk/st/core/JobBag.kt index 18066b4..515a0cc 100644 --- a/core/src/main/kotlin/app/dapk/st/core/JobBag.kt +++ b/core/src/main/kotlin/app/dapk/st/core/JobBag.kt @@ -10,6 +10,11 @@ class JobBag { jobs[key] = job } + fun replace(key: String, job: Job) { + jobs[key]?.cancel() + jobs[key] = job + } + fun cancel(key: String) { jobs.remove(key)?.cancel() } diff --git a/core/src/testFixtures/kotlin/fake/FakeJobBag.kt b/core/src/testFixtures/kotlin/fake/FakeJobBag.kt new file mode 100644 index 0000000..73004ab --- /dev/null +++ b/core/src/testFixtures/kotlin/fake/FakeJobBag.kt @@ -0,0 +1,9 @@ +package fake + +import app.dapk.st.core.JobBag +import io.mockk.mockk + +class FakeJobBag { + val instance = mockk() +} + diff --git a/domains/state/src/main/kotlin/app/dapk/state/State.kt b/domains/state/src/main/kotlin/app/dapk/state/State.kt index d65dfcf..3382fe4 100644 --- a/domains/state/src/main/kotlin/app/dapk/state/State.kt +++ b/domains/state/src/main/kotlin/app/dapk/state/State.kt @@ -64,6 +64,48 @@ sealed interface ActionHandler { class Delegate(override val key: KClass, val handler: ReducerScope.(Action) -> ActionHandler) : ActionHandler } +data class Combined2(val state1: S1, val state2: S2) + +fun interface SharedStateScope { + fun getSharedState(): C +} + +fun shareState(block: SharedStateScope.() -> ReducerFactory): ReducerFactory { + var internalScope: ReducerScope? = null + val scope = SharedStateScope { internalScope!!.getState() } + val combinedFactory = block(scope) + return object : ReducerFactory { + override fun create(scope: ReducerScope) = combinedFactory.create(scope).also { internalScope = scope } + override fun initialState() = combinedFactory.initialState() + } +} + +fun combineReducers(r1: ReducerFactory, r2: ReducerFactory): ReducerFactory> { + return object : ReducerFactory> { + override fun create(scope: ReducerScope>): Reducer> { + val r1Scope = object : ReducerScope { + 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 { + 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 = Combined2(r1.initialState(), r2.initialState()) + } +} + fun createReducer( initialState: S, vararg reducers: (ReducerScope) -> ActionHandler, diff --git a/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryReducerTest.kt b/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryReducerTest.kt index ce6fe82..6e95300 100644 --- a/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryReducerTest.kt +++ b/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryReducerTest.kt @@ -1,10 +1,10 @@ package app.dapk.st.directory -import app.dapk.st.core.JobBag import app.dapk.st.directory.state.* import app.dapk.st.engine.DirectoryItem import app.dapk.st.engine.UnreadCount import fake.FakeChatEngine +import fake.FakeJobBag import fixture.aRoomOverview import io.mockk.mockk import kotlinx.coroutines.flow.flowOf @@ -91,8 +91,3 @@ class DirectoryReducerTest { internal class FakeShortcutHandler { val instance = mockk() } - -class FakeJobBag { - val instance = mockk() -} - diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryActivity.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryActivity.kt index 1b4a23e..599d104 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryActivity.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryActivity.kt @@ -23,9 +23,9 @@ import kotlinx.parcelize.Parcelize class ImageGalleryActivity : DapkActivity() { private val module by unsafeLazy { module() } - 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?) { @@ -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() } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryModule.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryModule.kt index 0e92bdb..b74d9fa 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryModule.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryModule.kt @@ -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(), + ) + } } \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryScreen.kt index daae82d..b86edba 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryScreen.kt @@ -24,35 +24,41 @@ import app.dapk.st.core.components.CenteredLoading 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?) -> 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( @@ -108,7 +114,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 { diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryViewModel.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryViewModel.kt deleted file mode 100644 index 0620315..0000000 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryViewModel.kt +++ /dev/null @@ -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( - 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 { copy(content = Lce.Content(folders)) } - } - - } - - fun goTo(page: SpiderPage) { - 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 { - copy(content = Lce.Content(media)) - } - } - } - - @Suppress("UNCHECKED_CAST") - private inline fun updatePageState(crossinline block: S.() -> S) { - val page = state.page - val currentState = page.state - require(currentState is S) - updateState { copy(page = (page as SpiderPage).copy(state = block(page.state))) } - } - -} - -data class ImageGalleryState( - val page: SpiderPage, -) - - -sealed interface ImageGalleryPage { - data class Folders(val content: Lce>) : ImageGalleryPage - data class Files(val content: Lce>, val folder: Folder) : ImageGalleryPage - - object Routes { - val folders = Route("Folders") - val files = Route("Files") - } -} - -sealed interface ImageGalleryEvent diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/PageReducer.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/PageReducer.kt new file mode 100644 index 0000000..d2e9dd0 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/PageReducer.kt @@ -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

createPageReducer( + initialPage: SpiderPage +): ReducerFactory> { + return createReducer( + initialState = PageContainer( + page = initialPage + ), + + change(PageAction.GoTo::class) { action, state -> + state.copy(page = action.page as SpiderPage

) + }, + + change(PageAction.UpdatePage::class) { action, state -> + val isSamePage = state.page.state::class == action.pageContent::class + if (isSamePage) { + val updatedPageContent = (state.page as SpiderPage).copy(state = action.pageContent) + state.copy(page = updatedPageContent as SpiderPage) + } else { + state + } + }, + ) +} + +sealed interface PageAction : Action { + data class GoTo

(val page: SpiderPage

) : PageAction + data class UpdatePage

(val pageContent: P) : PageAction +} + +data class PageContainer

( + val page: SpiderPage +) diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryActions.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryActions.kt new file mode 100644 index 0000000..bdff8ce --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryActions.kt @@ -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 +} diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryReducer.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryReducer.kt new file mode 100644 index 0000000..f66b2e9 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryReducer.kt @@ -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> = createPageReducer( + initialPage = SpiderPage( + route = ImageGalleryPage.Routes.folders, + label = "Send to $roomName", + parent = null, + state = ImageGalleryPage.Folders(Lce.Loading()) + ) +) + +private fun SharedStateScope, 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)))) + }) + }, +) diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryState.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryState.kt new file mode 100644 index 0000000..b17ffcb --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryState.kt @@ -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, Unit>, Unit> + +sealed interface ImageGalleryPage { + data class Folders(val content: Lce>) : ImageGalleryPage + data class Files(val content: Lce>, val folder: Folder) : ImageGalleryPage + + object Routes { + val folders = Route("Folders") + val files = Route("Files") + } +} diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerReducerTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerReducerTest.kt index d061d32..61d279a 100644 --- a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerReducerTest.kt +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerReducerTest.kt @@ -12,6 +12,7 @@ import app.dapk.st.matrix.common.asString import app.dapk.st.messenger.state.* import app.dapk.st.navigator.MessageAttachment import fake.FakeChatEngine +import fake.FakeJobBag import fake.FakeMessageOptionsStore import fixture.* import io.mockk.every @@ -351,10 +352,6 @@ class FakeCopyToClipboard { val instance = mockk() } -class FakeJobBag { - val instance = mockk() -} - class FakeDeviceMeta { val instance = mockk()