diff --git a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/di/AppModule.kt b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/di/AppModule.kt index 66400ab484d..177775c677d 100644 --- a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/di/AppModule.kt +++ b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/di/AppModule.kt @@ -37,11 +37,13 @@ import de.chennemann.opencode.mobile.domain.session.SessionStreamCoordinator import de.chennemann.opencode.mobile.domain.session.StreamGateway import de.chennemann.opencode.mobile.domain.v2.DefaultSynchronizationService import de.chennemann.opencode.mobile.domain.v2.projects.ProjectRepository +import de.chennemann.opencode.mobile.domain.v2.projects.DefaultProjectService import de.chennemann.opencode.mobile.domain.v2.session.SessionRepository import de.chennemann.opencode.mobile.domain.v2.session.DefaultSessionService import de.chennemann.opencode.mobile.domain.v2.servers.DefaultServerService import de.chennemann.opencode.mobile.domain.v2.message.MessageRepository as MessageRepositoryV2 import de.chennemann.opencode.mobile.domain.v2.message.MessageService as MessageServiceV2 +import de.chennemann.opencode.mobile.domain.v2.projects.ProjectService as ProjectServiceV2 import de.chennemann.opencode.mobile.domain.v2.session.SessionService as SessionServiceV2 import de.chennemann.opencode.mobile.domain.v2.servers.ServerRepository as ServerRepositoryV2 import de.chennemann.opencode.mobile.domain.v2.servers.ServerService as ServerServiceV2 @@ -107,6 +109,7 @@ val appModule = module { single { SqlDelightMessageRepository(get(), get()) } single { DefaultSynchronizationService(get(), get(), get(), get()) } single { DefaultServerService(get(), get(), get()) } + single { DefaultProjectService(get()) } single { DefaultSessionService(get()) } single { DefaultMessageService(get()) } single { get() } @@ -129,6 +132,6 @@ val appModule = module { single { get() } viewModel { ConversationViewModel(get(), get()) } viewModel { (projectKey: String) -> SessionSelectionViewModel(projectKey, get(), get(), get()) } - viewModel { ManageViewModel(get(), get()) } + viewModel { ManageViewModel(get(), get(), get()) } viewModel { LogsViewModel(get(), get()) } } diff --git a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/domain/v2/projects/ProjectService.kt b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/domain/v2/projects/ProjectService.kt new file mode 100644 index 00000000000..4dc13ea63aa --- /dev/null +++ b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/domain/v2/projects/ProjectService.kt @@ -0,0 +1,27 @@ +package de.chennemann.opencode.mobile.domain.v2.projects + +import kotlinx.coroutines.flow.Flow + +interface ProjectService { + fun observeProjects(serverId: String? = null): Flow> + + suspend fun togglePinnedById(projectId: String): Boolean +} + +class DefaultProjectService( + private val projectRepository: ProjectRepository, +) : ProjectService { + override fun observeProjects(serverId: String?): Flow> { + return projectRepository.observeProjects(serverId) + } + + override suspend fun togglePinnedById(projectId: String): Boolean { + val id = projectId.trim() + if (id.isBlank()) return false + val project = projectRepository.selectProject(id) ?: return false + projectRepository.updateProject( + project.copy(pinned = !project.pinned) + ) + return true + } +} diff --git a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageContract.kt b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageContract.kt index 0227b384ce3..4f761dfc1f4 100644 --- a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageContract.kt +++ b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageContract.kt @@ -21,7 +21,7 @@ sealed interface ManageEvent { data class ProjectSelected(val worktree: String) : ManageEvent - data class ProjectFavoriteToggled(val worktree: String) : ManageEvent + data class ProjectFavoriteToggled(val projectId: String) : ManageEvent data class ProjectRemoved(val worktree: String) : ManageEvent diff --git a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageScreen.kt b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageScreen.kt index 210ecaaf0ba..9017da3ae4d 100644 --- a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageScreen.kt +++ b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageScreen.kt @@ -209,7 +209,7 @@ private fun ProjectListCard(state: ManageUiState, onEvent: (ManageEvent) -> Unit selected = workspaceId(state.selectedProject.orEmpty()) == workspaceId(project.worktree), compact = true, modifier = Modifier.weight(1f), - onFavoriteToggle = { onEvent(ManageEvent.ProjectFavoriteToggled(project.worktree)) }, + onFavoriteToggle = { onEvent(ManageEvent.ProjectFavoriteToggled(project.id)) }, onSelect = { onEvent(ManageEvent.ProjectSelected(project.worktree)) }, ) } @@ -272,7 +272,7 @@ private fun ProjectListCard(state: ManageUiState, onEvent: (ManageEvent) -> Unit selected = workspaceId(state.selectedProject.orEmpty()) == workspaceId(it.worktree), compact = false, modifier = Modifier.fillMaxWidth(), - onFavoriteToggle = { onEvent(ManageEvent.ProjectFavoriteToggled(it.worktree)) }, + onFavoriteToggle = { onEvent(ManageEvent.ProjectFavoriteToggled(it.id)) }, onSelect = { onEvent(ManageEvent.ProjectSelected(it.worktree)) }, ) } diff --git a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModel.kt b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModel.kt index d766170efb9..6ea5bce78da 100644 --- a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModel.kt +++ b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModel.kt @@ -6,18 +6,22 @@ import de.chennemann.opencode.mobile.di.DispatcherProvider import de.chennemann.opencode.mobile.domain.session.ProjectState import de.chennemann.opencode.mobile.domain.session.ServerState import de.chennemann.opencode.mobile.domain.session.SessionServiceApi +import de.chennemann.opencode.mobile.domain.v2.projects.LocalProjectInfo +import de.chennemann.opencode.mobile.domain.v2.projects.ProjectService as ProjectServiceV2 import de.chennemann.opencode.mobile.navigation.LogsRoute import de.chennemann.opencode.mobile.navigation.NavEvent import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch class ManageViewModel( private val service: SessionServiceApi, + private val projectService: ProjectServiceV2, private val dispatchers: DispatcherProvider, ) : ViewModel() { private val lane = dispatchers.default.limitedParallelism(1) @@ -25,15 +29,14 @@ class ManageViewModel( val nav = navFlow.asSharedFlow() - val state: StateFlow = service.state - .map { global -> - val projects = global.projects.map(::displayProject) + val state: StateFlow = combine(service.state, projectService.observeProjects()) { global, projects -> + val displayed = projects.map(::displayProject) ManageUiState( url = global.url, discovered = global.discovered, status = global.status, loadingProjects = global.loadingProjects, - projects = projects, + projects = displayed, selectedProject = global.selectedProject, message = global.message, ) @@ -74,7 +77,9 @@ class ManageViewModel( } is ManageEvent.ProjectFavoriteToggled -> { - service.toggleProjectFavorite(event.worktree) + viewModelScope.launch(lane) { + projectService.togglePinnedById(event.projectId) + } } is ManageEvent.ProjectRemoved -> { @@ -91,8 +96,14 @@ class ManageViewModel( } } - private fun displayProject(project: ProjectState): ProjectState { - return project.copy(name = folderName(project.worktree)) + private fun displayProject(project: LocalProjectInfo): ProjectState { + return ProjectState( + id = project.id, + worktree = project.path, + name = folderName(project.path), + sandboxes = emptyList(), + favorite = project.pinned, + ) } private fun folderName(path: String): String { diff --git a/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/domain/v2/projects/ProjectServiceTest.kt b/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/domain/v2/projects/ProjectServiceTest.kt new file mode 100644 index 00000000000..bf3278d683a --- /dev/null +++ b/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/domain/v2/projects/ProjectServiceTest.kt @@ -0,0 +1,86 @@ +package de.chennemann.opencode.mobile.domain.v2.projects + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ProjectServiceTest { + @Test + fun observeProjectsDelegatesToRepository() { + val repository = FakeProjectRepository() + val service = DefaultProjectService(repository) + + val observed = service.observeProjects("server-1") + + assertSame(repository.flow, observed) + assertEquals("server-1", repository.lastObservedServerId) + } + + @Test + fun togglePinnedByIdFlipsPinnedAndPersists() = runTest { + val repository = FakeProjectRepository( + initial = linkedMapOf( + "p1" to LocalProjectInfo( + id = "p1", + serverId = "server-1", + name = "Main", + path = "/repo/main", + pinned = false, + ) + ) + ) + val service = DefaultProjectService(repository) + + val toggled = service.togglePinnedById(" p1 ") + + assertTrue(toggled) + assertEquals(true, repository.projects["p1"]?.pinned) + assertEquals(listOf("p1"), repository.updateCalls.map { it.id }) + } + + @Test + fun togglePinnedByIdReturnsFalseForMissingOrBlankId() = runTest { + val repository = FakeProjectRepository() + val service = DefaultProjectService(repository) + + assertFalse(service.togglePinnedById(" ")) + assertFalse(service.togglePinnedById("missing")) + assertTrue(repository.updateCalls.isEmpty()) + } +} + +private class FakeProjectRepository( + initial: LinkedHashMap = linkedMapOf(), +) : ProjectRepository { + val projects = initial + val flow = flowOf(projects.values.toList()) + val updateCalls = mutableListOf() + var lastObservedServerId: String? = null + + override fun observeProjects(serverId: String?): Flow> { + lastObservedServerId = serverId + return flow + } + + override suspend fun selectProject(id: String): LocalProjectInfo? { + return projects[id] + } + + override suspend fun insertProject(project: LocalProjectInfo) { + projects[project.id] = project + } + + override suspend fun updateProject(project: LocalProjectInfo) { + updateCalls += project + projects[project.id] = project + } + + override suspend fun deleteProject(id: String) { + projects.remove(id) + } +} diff --git a/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModelTest.kt b/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModelTest.kt index f4028dbe456..59a3c90ac6f 100644 --- a/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModelTest.kt +++ b/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModelTest.kt @@ -6,6 +6,8 @@ import de.chennemann.opencode.mobile.domain.session.ServerState import de.chennemann.opencode.mobile.domain.session.SessionServiceApi import de.chennemann.opencode.mobile.domain.session.SessionState import de.chennemann.opencode.mobile.domain.session.SessionUiState +import de.chennemann.opencode.mobile.domain.v2.projects.LocalProjectInfo +import de.chennemann.opencode.mobile.domain.v2.projects.ProjectService as ProjectServiceV2 import de.chennemann.opencode.mobile.navigation.LogsRoute import de.chennemann.opencode.mobile.navigation.NavEvent import kotlinx.coroutines.CoroutineScope @@ -39,14 +41,25 @@ class ManageViewModelTest { Dispatchers.setMain(main) val worker = StandardTestDispatcher(testScheduler) val service = StubSessionService() - val viewModel = ManageViewModel(service, lanes(main, worker)) + val projectService = StubProjectService() + val viewModel = ManageViewModel(service, projectService, lanes(main, worker)) val collect = backgroundScope.launch(worker) { viewModel.state.collect {} } - service.state.value = state( - projects = listOf( - ProjectState(id = "p1", worktree = "/repo/main", name = "Main", favorite = true), - ProjectState(id = "p2", worktree = "/repo/other", name = "Other", favorite = false), + service.state.value = state(selectedProject = "/repo/main") + projectService.projects.value = listOf( + LocalProjectInfo( + id = "p1", + serverId = "server-1", + name = "Main", + path = "/repo/main", + pinned = true, + ), + LocalProjectInfo( + id = "p2", + serverId = "server-1", + name = "Other", + path = "/repo/other", + pinned = false, ), - selectedProject = "/repo/main", ) advanceUntilIdle() @@ -65,7 +78,8 @@ class ManageViewModelTest { Dispatchers.setMain(main) val worker = StandardTestDispatcher(testScheduler) val service = StubSessionService() - val viewModel = ManageViewModel(service, lanes(main, worker)) + val projectService = StubProjectService() + val viewModel = ManageViewModel(service, projectService, lanes(main, worker)) val collect = backgroundScope.launch(worker) { viewModel.state.collect {} } service.state.value = state( projects = listOf( @@ -88,7 +102,8 @@ class ManageViewModelTest { Dispatchers.setMain(main) val worker = StandardTestDispatcher(testScheduler) val service = StubSessionService() - val viewModel = ManageViewModel(service, lanes(main, worker)) + val projectService = StubProjectService() + val viewModel = ManageViewModel(service, projectService, lanes(main, worker)) viewModel.onEvent(ManageEvent.LoadProjectRequested(" /repo/alpha ")) advanceUntilIdle() @@ -101,7 +116,8 @@ class ManageViewModelTest { Dispatchers.setMain(main) val worker = StandardTestDispatcher(testScheduler) val service = StubSessionService() - val viewModel = ManageViewModel(service, lanes(main, worker)) + val projectService = StubProjectService() + val viewModel = ManageViewModel(service, projectService, lanes(main, worker)) viewModel.onEvent(ManageEvent.LoadProjectRequested(" ")) advanceUntilIdle() @@ -115,7 +131,8 @@ class ManageViewModelTest { Dispatchers.setMain(main) val worker = StandardTestDispatcher(testScheduler) val service = StubSessionService() - val viewModel = ManageViewModel(service, lanes(main, worker)) + val projectService = StubProjectService() + val viewModel = ManageViewModel(service, projectService, lanes(main, worker)) viewModel.onEvent(ManageEvent.Connect("http://127.0.0.1")) @@ -129,7 +146,8 @@ class ManageViewModelTest { Dispatchers.setMain(main) val worker = StandardTestDispatcher(testScheduler) val service = StubSessionService() - val viewModel = ManageViewModel(service, lanes(main, worker)) + val projectService = StubProjectService() + val viewModel = ManageViewModel(service, projectService, lanes(main, worker)) val nav = async { viewModel.nav.first() } advanceUntilIdle() @@ -145,7 +163,8 @@ class ManageViewModelTest { Dispatchers.setMain(main) val worker = StandardTestDispatcher(testScheduler) val service = StubSessionService() - val viewModel = ManageViewModel(service, lanes(main, worker)) + val projectService = StubProjectService() + val viewModel = ManageViewModel(service, projectService, lanes(main, worker)) val nav = async { viewModel.nav.first() } advanceUntilIdle() @@ -155,6 +174,22 @@ class ManageViewModelTest { assertEquals(NavEvent.NavigateBack, nav.await()) } + @Test + fun togglesFavoriteUsingProjectServiceById() = runTest(TestCoroutineScheduler()) { + val main = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(main) + val worker = StandardTestDispatcher(testScheduler) + val service = StubSessionService() + val projectService = StubProjectService() + val viewModel = ManageViewModel(service, projectService, lanes(main, worker)) + + viewModel.onEvent(ManageEvent.ProjectFavoriteToggled("project-42")) + advanceUntilIdle() + + assertEquals(listOf("project-42"), projectService.toggleRequests) + assertEquals(emptyList(), service.toggleFavoriteRequests) + } + private fun state( projects: List = emptyList(), selectedProject: String? = null, @@ -220,6 +255,7 @@ private class StubSessionService : SessionServiceApi { var startCalls = 0 var refreshCalls = 0 val selectRequests = mutableListOf() + val toggleFavoriteRequests = mutableListOf() val removeRequests = mutableListOf() val urls = mutableListOf() @@ -241,7 +277,9 @@ private class StubSessionService : SessionServiceApi { selectRequests += worktree } - override fun toggleProjectFavorite(worktree: String) = Unit + override fun toggleProjectFavorite(worktree: String) { + toggleFavoriteRequests += worktree + } override fun removeProject(worktree: String) { removeRequests += worktree @@ -271,3 +309,15 @@ private class StubSessionService : SessionServiceApi { return emptyList() } } + +private class StubProjectService : ProjectServiceV2 { + val projects = MutableStateFlow>(emptyList()) + val toggleRequests = mutableListOf() + + override fun observeProjects(serverId: String?) = projects + + override suspend fun togglePinnedById(projectId: String): Boolean { + toggleRequests += projectId + return true + } +}