From 2dd4adf4e7970600203b4b758726a411577c3f24 Mon Sep 17 00:00:00 2001 From: tomastiminskas Date: Thu, 30 Jul 2026 14:23:36 +0000 Subject: [PATCH] Generated with Hive: Add Workspaces tab to Dashboard with list UI and view-state machine wiring --- .../sphinx/dashboard/ui/DashboardFragment.kt | 38 ++++++-- .../dashboard/ui/DashboardFragmentsAdapter.kt | 8 +- .../sphinx/dashboard/ui/DashboardViewModel.kt | 6 ++ .../sphinx/dashboard/ui/WorkspacesFragment.kt | 84 +++++++++++++++++ .../dashboard/ui/WorkspacesViewModel.kt | 53 +++++++++++ .../dashboard/ui/adapter/WorkspaceAdapter.kt | 90 +++++++++++++++++++ .../ui/viewstates/DashboardTabsViewState.kt | 1 + .../main/res/drawable/bg_workspace_logo.xml | 6 ++ .../res/drawable/ic_workspace_placeholder.xml | 36 ++++++++ .../main/res/layout/fragment_workspaces.xml | 33 +++++++ .../src/main/res/layout/item_workspace.xml | 86 ++++++++++++++++++ .../dashboard/src/main/res/values/strings.xml | 1 + 12 files changed, 433 insertions(+), 9 deletions(-) create mode 100644 sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/WorkspacesFragment.kt create mode 100644 sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/WorkspacesViewModel.kt create mode 100644 sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/adapter/WorkspaceAdapter.kt create mode 100644 sphinx/screens/dashboard/dashboard/src/main/res/drawable/bg_workspace_logo.xml create mode 100644 sphinx/screens/dashboard/dashboard/src/main/res/drawable/ic_workspace_placeholder.xml create mode 100644 sphinx/screens/dashboard/dashboard/src/main/res/layout/fragment_workspaces.xml create mode 100644 sphinx/screens/dashboard/dashboard/src/main/res/layout/item_workspace.xml diff --git a/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/DashboardFragment.kt b/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/DashboardFragment.kt index 82476d7b..e2c80fb5 100644 --- a/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/DashboardFragment.kt +++ b/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/DashboardFragment.kt @@ -204,8 +204,6 @@ internal class DashboardFragment : MotionLayoutFragment< tab.text = dashboardFragmentsAdapter.getPageTitle(position) }.attach() - viewPagerDashboardTabs.offscreenPageLimit = 3 - viewPagerDashboardTabs.post { viewPagerDashboardTabs.registerOnPageChangeCallback(object: ViewPager2.OnPageChangeCallback() { @@ -216,11 +214,20 @@ internal class DashboardFragment : MotionLayoutFragment< ) { } override fun onPageSelected(position: Int) { - viewModel.updateTabsState( - feedActive = position == DashboardFragmentsAdapter.FEED_TAB_POSITION, - friendsActive = position == DashboardFragmentsAdapter.FRIENDS_TAB_POSITION, - tribesActive = position == DashboardFragmentsAdapter.TRIBES_TAB_POSITION, - ) + when (position) { + DashboardFragmentsAdapter.FEED_TAB_POSITION -> viewModel.updateTabsState( + feedActive = true, friendsActive = false, tribesActive = false, workspacesActive = false + ) + DashboardFragmentsAdapter.FRIENDS_TAB_POSITION -> viewModel.updateTabsState( + feedActive = false, friendsActive = true, tribesActive = false, workspacesActive = false + ) + DashboardFragmentsAdapter.TRIBES_TAB_POSITION -> viewModel.updateTabsState( + feedActive = false, friendsActive = false, tribesActive = true, workspacesActive = false + ) + DashboardFragmentsAdapter.WORKSPACES_TAB_POSITION -> viewModel.updateTabsState( + feedActive = false, friendsActive = false, tribesActive = false, workspacesActive = true + ) + } } override fun onPageScrollStateChanged(state: Int) { } @@ -248,6 +255,15 @@ internal class DashboardFragment : MotionLayoutFragment< val tribesTitle = DashboardFragmentsAdapter.TAB_TITLES[DashboardFragmentsAdapter.TRIBES_TAB_POSITION] tribesTab?.findViewById(R.id.text_view_tab_title)?.text = getString(tribesTitle) + val workspacesTab: View = LayoutInflater.from(this@DashboardFragment.context) + .inflate(R.layout.layout_dashboard_custom_tab, tabs, false) + tabs.getTabAt(DashboardFragmentsAdapter.WORKSPACES_TAB_POSITION)?.customView = workspacesTab + + val workspacesTitle = DashboardFragmentsAdapter.TAB_TITLES[DashboardFragmentsAdapter.WORKSPACES_TAB_POSITION] + workspacesTab?.findViewById(R.id.text_view_tab_title)?.text = getString(workspacesTitle) + + viewPagerDashboardTabs.offscreenPageLimit = 4 + if (viewModel.getCurrentPagePosition() != DashboardFragmentsAdapter.FIRST_INIT) { viewPagerDashboardTabs.currentItem = viewModel.getCurrentPagePosition() } else @@ -731,6 +747,7 @@ internal class DashboardFragment : MotionLayoutFragment< val feedTab = tabs.getTabAt(DashboardFragmentsAdapter.FEED_TAB_POSITION)?.customView val friendsTab = tabs.getTabAt(DashboardFragmentsAdapter.FRIENDS_TAB_POSITION)?.customView val tribesTab = tabs.getTabAt(DashboardFragmentsAdapter.TRIBES_TAB_POSITION)?.customView + val workspacesTab = tabs.getTabAt(DashboardFragmentsAdapter.WORKSPACES_TAB_POSITION)?.customView feedTab?.findViewById(R.id.text_view_tab_title)?.setTextColor( ContextCompat.getColor( @@ -760,6 +777,13 @@ internal class DashboardFragment : MotionLayoutFragment< tribesTab?.findViewById(R.id.view_unseen_messages_dot)?.goneIfFalse( viewState.tribesBadgeVisible ) + + workspacesTab?.findViewById(R.id.text_view_tab_title)?.setTextColor( + ContextCompat.getColor( + binding.root.context, + if (viewState.workspacesActive) R_common.color.text else R_common.color.secondaryText + ) + ) } } } diff --git a/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/DashboardFragmentsAdapter.kt b/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/DashboardFragmentsAdapter.kt index 0b1f8410..1262cd05 100644 --- a/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/DashboardFragmentsAdapter.kt +++ b/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/DashboardFragmentsAdapter.kt @@ -1,7 +1,6 @@ package chat.sphinx.dashboard.ui import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentPagerAdapter import androidx.viewpager2.adapter.FragmentStateAdapter import chat.sphinx.dashboard.R import chat.sphinx.dashboard.ui.feed.FeedFragment @@ -19,12 +18,14 @@ class DashboardFragmentsAdapter( const val FEED_TAB_POSITION = 0 const val FRIENDS_TAB_POSITION = 1 const val TRIBES_TAB_POSITION = 2 - const val FIRST_INIT = 3 + const val WORKSPACES_TAB_POSITION = 3 + const val FIRST_INIT = 4 val TAB_TITLES = arrayOf( R.string.dashboard_feed_tab_name, R.string.dashboard_friends_tab_name, R.string.dashboard_tribes_tab_name, + R.string.dashboard_workspaces_tab_name, ) } @@ -43,6 +44,9 @@ class DashboardFragmentsAdapter( chatListType = ChatType.Tribe ) } + WORKSPACES_TAB_POSITION -> { + WorkspacesFragment() + } else -> { ChatListFragment.newInstance() } diff --git a/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/DashboardViewModel.kt b/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/DashboardViewModel.kt index 6573314d..97d035df 100644 --- a/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/DashboardViewModel.kt +++ b/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/DashboardViewModel.kt @@ -1341,6 +1341,7 @@ internal class DashboardViewModel @Inject constructor( feedActive: Boolean? = null, friendsActive: Boolean? = null, tribesActive: Boolean? = null, + workspacesActive: Boolean? = null, friendsBadgeVisible: Boolean? = null, tribesBadgeVisible: Boolean? = null ) { @@ -1353,6 +1354,7 @@ internal class DashboardViewModel @Inject constructor( feedActive = feedActive ?: currentState.feedActive, friendsActive = friendsActive ?: currentState.friendsActive, tribesActive = tribesActive ?: currentState.tribesActive, + workspacesActive = workspacesActive ?: currentState.workspacesActive, friendsBadgeVisible = friendsBadgeVisible ?: currentState.friendsBadgeVisible, tribesBadgeVisible = tribesBadgeVisible ?: currentState.tribesBadgeVisible @@ -1362,6 +1364,7 @@ internal class DashboardViewModel @Inject constructor( feedActive = feedActive ?: false, friendsActive = friendsActive ?: true, tribesActive = tribesActive ?: false, + workspacesActive = workspacesActive ?: false, friendsBadgeVisible = friendsBadgeVisible ?: false, tribesBadgeVisible = tribesBadgeVisible ?: false ) @@ -1383,6 +1386,9 @@ internal class DashboardViewModel @Inject constructor( currentState.tribesActive -> { DashboardFragmentsAdapter.TRIBES_TAB_POSITION } + currentState.workspacesActive -> { + DashboardFragmentsAdapter.WORKSPACES_TAB_POSITION + } else -> DashboardFragmentsAdapter.FRIENDS_TAB_POSITION } } diff --git a/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/WorkspacesFragment.kt b/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/WorkspacesFragment.kt new file mode 100644 index 00000000..114d7248 --- /dev/null +++ b/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/WorkspacesFragment.kt @@ -0,0 +1,84 @@ +package chat.sphinx.dashboard.ui + +import android.content.Context +import android.os.Bundle +import android.view.View +import android.widget.ImageView +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import by.kirich1409.viewbindingdelegate.viewBinding +import chat.sphinx.concept_image_loader.ImageLoader +import chat.sphinx.dashboard.R +import chat.sphinx.dashboard.databinding.FragmentWorkspacesBinding +import chat.sphinx.dashboard.ui.adapter.WorkspaceAdapter +import dagger.hilt.android.AndroidEntryPoint +import io.matthewnelson.android_feature_screens.ui.sideeffect.SideEffectFragment +import io.matthewnelson.android_feature_screens.util.gone +import io.matthewnelson.android_feature_screens.util.visible +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +internal class WorkspacesFragment : SideEffectFragment< + Context, + ChatListSideEffect, + WorkspacesViewState, + WorkspacesViewModel, + FragmentWorkspacesBinding + >(R.layout.fragment_workspaces) { + + @Inject + @Suppress("ProtectedInFinal") + protected lateinit var imageLoader: ImageLoader + + override val viewModel: WorkspacesViewModel by viewModels() + override val binding: FragmentWorkspacesBinding by viewBinding(FragmentWorkspacesBinding::bind) + + private var workspaceAdapter: WorkspaceAdapter? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupRecyclerView() + subscribeToWorkspaces() + viewModel.loadWorkspaces() + } + + private fun setupRecyclerView() { + workspaceAdapter = WorkspaceAdapter( + imageLoader = imageLoader, + onStopSupervisor = onStopSupervisor, + ) + + binding.recyclerViewWorkspaces.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = workspaceAdapter + setHasFixedSize(false) + } + } + + private fun subscribeToWorkspaces() { + onStopSupervisor.scope.launch(viewModel.mainImmediate) { + viewModel.loading.collect { isLoading -> + if (isLoading) { + binding.progressBarWorkspaces.visible + } else { + binding.progressBarWorkspaces.gone + } + } + } + + onStopSupervisor.scope.launch(viewModel.mainImmediate) { + viewModel.workspaces.collect { workspaces -> + workspaceAdapter?.submitList(workspaces) + } + } + } + + override suspend fun onViewStateFlowCollect(viewState: WorkspacesViewState) { + // No motion states needed + } + + override suspend fun onSideEffectCollect(sideEffect: ChatListSideEffect) { + sideEffect.execute(binding.root.context) + } +} diff --git a/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/WorkspacesViewModel.kt b/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/WorkspacesViewModel.kt new file mode 100644 index 00000000..e80524b6 --- /dev/null +++ b/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/WorkspacesViewModel.kt @@ -0,0 +1,53 @@ +package chat.sphinx.dashboard.ui + +import androidx.lifecycle.viewModelScope +import chat.sphinx.concept_repository_dashboard.model.Workspace +import chat.sphinx.concept_repository_dashboard_android.RepositoryDashboardAndroid +import dagger.hilt.android.lifecycle.HiltViewModel +import io.matthewnelson.android_feature_viewmodel.SideEffectViewModel +import io.matthewnelson.concept_coroutines.CoroutineDispatchers +import io.matthewnelson.concept_views.viewstate.ViewState +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal sealed class WorkspacesViewState : ViewState() { + object Idle : WorkspacesViewState() +} + +@HiltViewModel +internal class WorkspacesViewModel @Inject constructor( + dispatchers: CoroutineDispatchers, + private val repositoryDashboard: RepositoryDashboardAndroid, +) : SideEffectViewModel< + android.content.Context, + ChatListSideEffect, + WorkspacesViewState + >(dispatchers, WorkspacesViewState.Idle) { + + private val _workspaces = MutableStateFlow>(emptyList()) + val workspaces: StateFlow> = _workspaces.asStateFlow() + + private val _loading = MutableStateFlow(false) + val loading: StateFlow = _loading.asStateFlow() + + private var fetchJob: Job? = null + + fun loadWorkspaces() { + fetchJob?.cancel() + fetchJob = viewModelScope.launch { + _loading.value = true + try { + _workspaces.value = repositoryDashboard.fetchWorkspaces() + } catch (e: Exception) { + // fetchWorkspaces() is expected to return emptyList() on failure, + // but guard here so _loading is always reset even on unexpected throws + } finally { + _loading.value = false + } + } + } +} diff --git a/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/adapter/WorkspaceAdapter.kt b/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/adapter/WorkspaceAdapter.kt new file mode 100644 index 00000000..77a33fd7 --- /dev/null +++ b/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/adapter/WorkspaceAdapter.kt @@ -0,0 +1,90 @@ +package chat.sphinx.dashboard.ui.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import chat.sphinx.concept_image_loader.Disposable +import chat.sphinx.concept_image_loader.ImageLoader +import chat.sphinx.concept_image_loader.ImageLoaderOptions +import chat.sphinx.concept_repository_dashboard.model.Workspace +import chat.sphinx.dashboard.R +import chat.sphinx.dashboard.databinding.ItemWorkspaceBinding +import io.matthewnelson.android_feature_viewmodel.util.OnStopSupervisor +import kotlinx.coroutines.launch + +internal class WorkspaceAdapter( + private val imageLoader: ImageLoader, + private val onStopSupervisor: OnStopSupervisor, +) : ListAdapter(DIFF_CALLBACK) { + + companion object { + private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Workspace, newItem: Workspace): Boolean = + oldItem.id == newItem.id + + override fun areContentsTheSame(oldItem: Workspace, newItem: Workspace): Boolean = + oldItem == newItem + } + } + + private val imageLoaderOptions: ImageLoaderOptions = ImageLoaderOptions.Builder() + .placeholderResId(R.drawable.ic_workspace_placeholder) + .build() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WorkspaceViewHolder { + val binding = ItemWorkspaceBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return WorkspaceViewHolder(binding) + } + + override fun onBindViewHolder(holder: WorkspaceViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onViewRecycled(holder: WorkspaceViewHolder) { + super.onViewRecycled(holder) + holder.onRecycled() + } + + inner class WorkspaceViewHolder( + private val binding: ItemWorkspaceBinding + ) : RecyclerView.ViewHolder(binding.root) { + + var disposable: Disposable? = null + + fun bind(workspace: Workspace) { + binding.apply { + textViewWorkspaceName.text = workspace.name + textViewWorkspaceRole.text = workspace.userRole ?: "Member" + textViewWorkspaceMembers.text = "Members: ${workspace.memberCount}" + + disposable?.dispose() + disposable = null + + val logoUrl = workspace.logoUrl + if (logoUrl != null) { + onStopSupervisor.scope.launch { + imageLoader.load( + imageViewWorkspaceLogo, + logoUrl, + imageLoaderOptions + ).also { disposable = it } + } + } else { + imageViewWorkspaceLogo.setImageResource(R.drawable.ic_workspace_placeholder) + } + } + } + + fun onRecycled() { + disposable?.dispose() + disposable = null + } + } +} diff --git a/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/viewstates/DashboardTabsViewState.kt b/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/viewstates/DashboardTabsViewState.kt index b3988bfa..8e301af5 100644 --- a/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/viewstates/DashboardTabsViewState.kt +++ b/sphinx/screens/dashboard/dashboard/src/main/java/chat/sphinx/dashboard/ui/viewstates/DashboardTabsViewState.kt @@ -10,6 +10,7 @@ sealed class DashboardTabsViewState: ViewState() { val feedActive: Boolean, val friendsActive: Boolean, val tribesActive: Boolean, + val workspacesActive: Boolean = false, val friendsBadgeVisible: Boolean, val tribesBadgeVisible: Boolean, ) : DashboardTabsViewState() diff --git a/sphinx/screens/dashboard/dashboard/src/main/res/drawable/bg_workspace_logo.xml b/sphinx/screens/dashboard/dashboard/src/main/res/drawable/bg_workspace_logo.xml new file mode 100644 index 00000000..c7548d43 --- /dev/null +++ b/sphinx/screens/dashboard/dashboard/src/main/res/drawable/bg_workspace_logo.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/sphinx/screens/dashboard/dashboard/src/main/res/drawable/ic_workspace_placeholder.xml b/sphinx/screens/dashboard/dashboard/src/main/res/drawable/ic_workspace_placeholder.xml new file mode 100644 index 00000000..56f3193c --- /dev/null +++ b/sphinx/screens/dashboard/dashboard/src/main/res/drawable/ic_workspace_placeholder.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + diff --git a/sphinx/screens/dashboard/dashboard/src/main/res/layout/fragment_workspaces.xml b/sphinx/screens/dashboard/dashboard/src/main/res/layout/fragment_workspaces.xml new file mode 100644 index 00000000..2f3d2b26 --- /dev/null +++ b/sphinx/screens/dashboard/dashboard/src/main/res/layout/fragment_workspaces.xml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/sphinx/screens/dashboard/dashboard/src/main/res/layout/item_workspace.xml b/sphinx/screens/dashboard/dashboard/src/main/res/layout/item_workspace.xml new file mode 100644 index 00000000..71ae1204 --- /dev/null +++ b/sphinx/screens/dashboard/dashboard/src/main/res/layout/item_workspace.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + diff --git a/sphinx/screens/dashboard/dashboard/src/main/res/values/strings.xml b/sphinx/screens/dashboard/dashboard/src/main/res/values/strings.xml index ab99e4db..9cae1809 100644 --- a/sphinx/screens/dashboard/dashboard/src/main/res/values/strings.xml +++ b/sphinx/screens/dashboard/dashboard/src/main/res/values/strings.xml @@ -120,6 +120,7 @@ Feed Friends Tribes + Workspaces All Listen