From 59f8e2b1155fffe10fd7ba78346a84e521706537 Mon Sep 17 00:00:00 2001 From: Xinto Date: Fri, 14 Apr 2023 14:21:14 +0400 Subject: [PATCH 1/5] initial refactor commit --- .../java/com/xinto/opencord/di/StoreModule.kt | 1 + .../com/xinto/opencord/di/ViewModelModule.kt | 16 +- .../opencord/store/PersistentDataStore.kt | 95 +++++++ .../opencord/ui/screens/home/HomeScreen.kt | 53 ++-- .../home/panels/channel/ChannelsList.kt | 63 ----- .../home/panels/channel/HomeChannelsPanel.kt | 86 ++++++ .../panels/channel/HomeChannelsPanelState.kt | 8 + .../channel/HomeChannelsPanelViewModel.kt} | 224 ++++++---------- .../panels/channel/model/CategoryItemData.kt | 30 +++ .../panels/channel/model/ChannelItemData.kt | 45 ++++ .../channel/{ => state}/ChannelsListLoaded.kt | 9 +- .../{ => state}/ChannelsListLoading.kt | 0 .../{ => state}/ChannelsListUnselected.kt | 0 .../panels/chat/{Chat.kt => HomeChatPanel.kt} | 57 ++-- .../home/panels/chat/HomeChatPanelState.kt | 8 + .../panels/chat/HomeChatPanelViewModel.kt | 205 +++++++++++++++ .../component}/ChatInput.kt | 4 +- .../home/panels/chat/model/MessageItem.kt | 34 +++ .../home/panels/chat/model/ReactionState.kt | 18 ++ .../home/panels/chat/{ => state}/ChatError.kt | 2 +- .../panels/chat/{ => state}/ChatLoaded.kt | 15 +- .../panels/chat/{ => state}/ChatLoading.kt | 0 .../panels/chat/{ => state}/ChatUnselected.kt | 0 .../home/panels/currentuser/CurrentUser.kt | 54 ---- .../screens/home/panels/guild/GuildsList.kt | 38 ++- .../home/panels/guild/GuildsViewModel.kt | 93 +++++++ .../home/panels/guild/HomeGuildPanelState.kt | 7 + .../home/panels/guild/model/GuildItem.kt | 35 +++ .../guild/{ => state}/GuildsListLoaded.kt | 2 +- .../guild/{ => state}/GuildsListLoading.kt | 0 .../messagemenu/HomeMessageMenuPanelState.kt | 7 + .../home/panels/messagemenu/MessageMenu.kt | 10 +- .../messagemenu}/MessageMenuViewModel.kt | 15 +- .../{ => state}/MessageMenuLoaded.kt | 5 +- .../{ => state}/MessageMenuLoading.kt | 0 .../screens/home/panels/user/CurrentUser.kt | 76 ++++++ .../home/panels/user/HomeUserPanelState.kt | 7 + .../panels/user/HomeUserPanelViewModel.kt} | 13 +- .../component}/CurrentUserSheet.kt | 6 +- .../layout}/CurrentUserContent.kt | 4 +- .../state}/CurrentUserLoaded.kt | 5 +- .../state}/CurrentUserLoading.kt | 5 +- .../opencord/ui/screens/login/LoginScreen.kt | 1 - .../login}/LoginViewModel.kt | 2 +- .../ui/screens/mentions/MentionsScreen.kt | 1 - .../mentions}/MentionsViewModel.kt | 4 +- .../opencord/ui/screens/pins/PinsScreen.kt | 42 ++- .../ui/screens/pins/PinsScreenState.kt | 7 + .../pins/PinsScreenViewModel.kt} | 17 +- .../pins/{ => state}/PinsScreenError.kt | 2 +- .../pins/{ => state}/PinsScreenLoaded.kt | 2 +- .../pins/{ => state}/PinsScreenLoading.kt | 2 +- .../{base => }/BasePersistenceViewModel.kt | 2 +- .../ui/viewmodel/ChatInputViewModel.kt | 1 - .../opencord/ui/viewmodel/ChatViewModel.kt | 244 ------------------ .../opencord/ui/viewmodel/GuildsViewModel.kt | 113 -------- gradle.properties | 3 + gradle/libs.versions.toml | 5 +- 58 files changed, 1038 insertions(+), 765 deletions(-) create mode 100644 app/src/main/java/com/xinto/opencord/store/PersistentDataStore.kt delete mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/ChannelsList.kt create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/HomeChannelsPanel.kt create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/HomeChannelsPanelState.kt rename app/src/main/java/com/xinto/opencord/ui/{viewmodel/ChannelsViewModel.kt => screens/home/panels/channel/HomeChannelsPanelViewModel.kt} (60%) create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/model/CategoryItemData.kt create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/model/ChannelItemData.kt rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/{ => state}/ChannelsListLoaded.kt (96%) rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/{ => state}/ChannelsListLoading.kt (100%) rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/{ => state}/ChannelsListUnselected.kt (100%) rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/{Chat.kt => HomeChatPanel.kt} (71%) create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelState.kt create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelViewModel.kt rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/{chatinput => chat/component}/ChatInput.kt (97%) create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/model/MessageItem.kt create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/model/ReactionState.kt rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/{ => state}/ChatError.kt (95%) rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/{ => state}/ChatLoaded.kt (96%) rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/{ => state}/ChatLoading.kt (100%) rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/{ => state}/ChatUnselected.kt (100%) delete mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/currentuser/CurrentUser.kt create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/GuildsViewModel.kt create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/HomeGuildPanelState.kt create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/model/GuildItem.kt rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/{ => state}/GuildsListLoaded.kt (97%) rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/{ => state}/GuildsListLoading.kt (100%) create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/HomeMessageMenuPanelState.kt rename app/src/main/java/com/xinto/opencord/ui/{viewmodel => screens/home/panels/messagemenu}/MessageMenuViewModel.kt (91%) rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/{ => state}/MessageMenuLoaded.kt (96%) rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/{ => state}/MessageMenuLoading.kt (100%) create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/CurrentUser.kt create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/HomeUserPanelState.kt rename app/src/main/java/com/xinto/opencord/ui/{viewmodel/CurrentUserViewModel.kt => screens/home/panels/user/HomeUserPanelViewModel.kt} (94%) rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/{currentuser/sheet => user/component}/CurrentUserSheet.kt (95%) rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/{currentuser => user/layout}/CurrentUserContent.kt (96%) rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/{currentuser => user/state}/CurrentUserLoaded.kt (93%) rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/{currentuser => user/state}/CurrentUserLoading.kt (93%) rename app/src/main/java/com/xinto/opencord/ui/{viewmodel => screens/login}/LoginViewModel.kt (99%) rename app/src/main/java/com/xinto/opencord/ui/{viewmodel => screens/mentions}/MentionsViewModel.kt (97%) create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreenState.kt rename app/src/main/java/com/xinto/opencord/ui/{viewmodel/ChannelPinsViewModel.kt => screens/pins/PinsScreenViewModel.kt} (83%) rename app/src/main/java/com/xinto/opencord/ui/screens/pins/{ => state}/PinsScreenError.kt (96%) rename app/src/main/java/com/xinto/opencord/ui/screens/pins/{ => state}/PinsScreenLoaded.kt (99%) rename app/src/main/java/com/xinto/opencord/ui/screens/pins/{ => state}/PinsScreenLoading.kt (89%) rename app/src/main/java/com/xinto/opencord/ui/viewmodel/{base => }/BasePersistenceViewModel.kt (95%) delete mode 100644 app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatViewModel.kt delete mode 100644 app/src/main/java/com/xinto/opencord/ui/viewmodel/GuildsViewModel.kt diff --git a/app/src/main/java/com/xinto/opencord/di/StoreModule.kt b/app/src/main/java/com/xinto/opencord/di/StoreModule.kt index a8c434cb..edfa8772 100644 --- a/app/src/main/java/com/xinto/opencord/di/StoreModule.kt +++ b/app/src/main/java/com/xinto/opencord/di/StoreModule.kt @@ -15,4 +15,5 @@ val storeModule = module { singleOf(::SessionStoreImpl) bind SessionStore::class singleOf(::UnreadStoreImpl) bind UnreadStore::class singleOf(::ReactionStoreImpl) bind ReactionStore::class + singleOf(::PersistentDataStoreImpl) bind PersistentDataStore::class } diff --git a/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt b/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt index 644f6561..4548a1c6 100644 --- a/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt +++ b/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt @@ -1,16 +1,24 @@ package com.xinto.opencord.di +import com.xinto.opencord.ui.screens.home.panels.channel.HomeChannelsPanelViewModel +import com.xinto.opencord.ui.screens.home.panels.chat.HomeChatPanelViewModel +import com.xinto.opencord.ui.screens.home.panels.guild.GuildsViewModel +import com.xinto.opencord.ui.screens.home.panels.messagemenu.MessageMenuViewModel +import com.xinto.opencord.ui.screens.home.panels.user.HomeUserPanelViewModel +import com.xinto.opencord.ui.screens.login.LoginViewModel +import com.xinto.opencord.ui.screens.mentions.MentionsViewModel +import com.xinto.opencord.ui.screens.pins.PinsScreenViewModel import com.xinto.opencord.ui.viewmodel.* import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.dsl.module val viewModelModule = module { viewModelOf(::LoginViewModel) - viewModelOf(::ChatViewModel) + viewModelOf(::HomeChatPanelViewModel) viewModelOf(::GuildsViewModel) - viewModelOf(::ChannelsViewModel) - viewModelOf(::ChannelPinsViewModel) - viewModelOf(::CurrentUserViewModel) + viewModelOf(::HomeChannelsPanelViewModel) + viewModelOf(::PinsScreenViewModel) + viewModelOf(::HomeUserPanelViewModel) viewModelOf(::MessageMenuViewModel) viewModelOf(::MentionsViewModel) viewModelOf(::ChatInputViewModel) diff --git a/app/src/main/java/com/xinto/opencord/store/PersistentDataStore.kt b/app/src/main/java/com/xinto/opencord/store/PersistentDataStore.kt new file mode 100644 index 00000000..192c7c8a --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/store/PersistentDataStore.kt @@ -0,0 +1,95 @@ +package com.xinto.opencord.store + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface PersistentDataStore { + + fun observeCurrentGuild(): Flow + fun observeCurrentChannel(): Flow + fun observeCollapsedCategories(): Flow> + + suspend fun updateCurrentGuild(guildId: Long) + suspend fun updateCurrentChannel(channelId: Long) + suspend fun toggleCategory(categoryId: Long) + suspend fun collapseCategory(categoryId: Long) + suspend fun expandCategory(categoryId: Long) + +} + +class PersistentDataStoreImpl( + private val context: Context +) : PersistentDataStore { + + private val Context.dataStore by preferencesDataStore("persistent") + + override fun observeCurrentGuild(): Flow { + return context.dataStore.data.map { preferences -> + preferences[CURRENT_GUILD_KEY] ?: 0L + } + } + + override fun observeCurrentChannel(): Flow { + return context.dataStore.data.map { preferences -> + preferences[CURRENT_CHANNEL_KEY] ?: 0L + } + } + + override fun observeCollapsedCategories(): Flow> { + return context.dataStore.data.map { preferences -> + (preferences[COLLAPSED_CATEGORIES_KEY] ?: emptySet()).mapNotNull { + it.toLongOrNull() + } + } + } + + override suspend fun updateCurrentGuild(guildId: Long) { + context.dataStore.edit { preferences -> + preferences[CURRENT_GUILD_KEY] = guildId + } + } + + override suspend fun updateCurrentChannel(channelId: Long) { + context.dataStore.edit { preferences -> + preferences[CURRENT_CHANNEL_KEY] = channelId + } + } + + override suspend fun toggleCategory(categoryId: Long) { + context.dataStore.edit { preferences -> + val current = preferences[COLLAPSED_CATEGORIES_KEY] ?: emptySet() + val stringCategoryId = categoryId.toString() + if (current.contains(stringCategoryId)) { + preferences[COLLAPSED_CATEGORIES_KEY] = current - stringCategoryId + } else { + preferences[COLLAPSED_CATEGORIES_KEY] = current + stringCategoryId + } + } + } + + override suspend fun expandCategory(categoryId: Long) { + context.dataStore.edit { preferences -> + val current = preferences[COLLAPSED_CATEGORIES_KEY] ?: emptySet() + preferences[COLLAPSED_CATEGORIES_KEY] = current - categoryId.toString() + } + } + + override suspend fun collapseCategory(categoryId: Long) { + context.dataStore.edit { preferences -> + val current = preferences[COLLAPSED_CATEGORIES_KEY] ?: emptySet() + preferences[COLLAPSED_CATEGORIES_KEY] = current + categoryId.toString() + } + } + + private companion object { + val CURRENT_GUILD_KEY = longPreferencesKey("CURRENT_GULD") + val CURRENT_CHANNEL_KEY = longPreferencesKey("CURRENT_CHANNEL") + val COLLAPSED_CATEGORIES_KEY = stringSetPreferencesKey("COLLAPSED_CATEGORIES") + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/HomeScreen.kt index 1d766bec..f3eb5b7d 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/HomeScreen.kt @@ -4,8 +4,17 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -17,20 +26,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.xinto.opencord.ui.navigation.PinsScreenData import com.xinto.opencord.ui.screens.home.panels.HomeNavButtons -import com.xinto.opencord.ui.screens.home.panels.channel.ChannelsList -import com.xinto.opencord.ui.screens.home.panels.chat.Chat -import com.xinto.opencord.ui.screens.home.panels.currentuser.CurrentUser -import com.xinto.opencord.ui.screens.home.panels.guild.GuildsList +import com.xinto.opencord.ui.screens.home.panels.channel.HomeChannelsPanel +import com.xinto.opencord.ui.screens.home.panels.chat.HomeChatPanel +import com.xinto.opencord.ui.screens.home.panels.guild.HomeGuildsPanel import com.xinto.opencord.ui.screens.home.panels.member.MembersList +import com.xinto.opencord.ui.screens.home.panels.user.HomeUserPanel import com.xinto.opencord.ui.util.animateCornerBasedShapeAsState -import com.xinto.opencord.ui.viewmodel.ChannelsViewModel -import com.xinto.opencord.ui.viewmodel.ChatViewModel -import com.xinto.opencord.ui.viewmodel.CurrentUserViewModel -import com.xinto.opencord.ui.viewmodel.GuildsViewModel import io.github.materiiapps.panels.SwipePanels import io.github.materiiapps.panels.SwipePanelsValue import io.github.materiiapps.panels.rememberSwipePanelsState -import org.koin.androidx.compose.getViewModel @Composable fun HomeScreen( @@ -41,12 +45,6 @@ fun HomeScreen( onFriendsClick: () -> Unit, modifier: Modifier = Modifier, ) { - val currentUserViewModel: CurrentUserViewModel = getViewModel() - val chatViewModel: ChatViewModel = getViewModel() - val guildsViewModel: GuildsViewModel = getViewModel() - val channelsViewModel: ChannelsViewModel = getViewModel() - - val channelsListState = rememberLazyListState() val panelState = rememberSwipePanelsState() BackHandler(enabled = panelState.currentValue != SwipePanelsValue.Center) { @@ -71,28 +69,21 @@ fun HomeScreen( Row( modifier = Modifier.weight(1f), ) { - GuildsList( + HomeGuildsPanel( modifier = Modifier .fillMaxHeight() .width(72.dp), - onGuildSelect = channelsViewModel::load, - viewModel = guildsViewModel, ) - ChannelsList( + HomeChannelsPanel( modifier = Modifier .fillMaxHeight() .weight(1f), - onChannelSelect = chatViewModel::load, - viewModel = channelsViewModel, - lazyListState = channelsListState, ) } - CurrentUser( + HomeUserPanel( modifier = Modifier .fillMaxWidth() .padding(start = 6.dp), - viewModel = currentUserViewModel, - onSettingsClick = onSettingsClick, ) } @@ -119,13 +110,7 @@ fun HomeScreen( }, ) - Chat( - onChannelsButtonClick = panelState::openStart, - onMembersButtonClick = panelState::openEnd, - onPinsButtonClick = { - onPinsClick(PinsScreenData(channelsViewModel.selectedChannelId)) - }, - viewModel = chatViewModel, + HomeChatPanel( modifier = Modifier .fillMaxSize() .clip(centerPanelShape), diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/ChannelsList.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/ChannelsList.kt deleted file mode 100644 index 636bd2cd..00000000 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/ChannelsList.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.xinto.opencord.ui.screens.home.panels.channel - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.LocalAbsoluteTonalElevation -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.xinto.opencord.ui.viewmodel.ChannelsViewModel - -@Composable -fun ChannelsList( - onChannelSelect: () -> Unit, - viewModel: ChannelsViewModel, - modifier: Modifier = Modifier, - lazyListState: LazyListState = rememberLazyListState(), -) { - CompositionLocalProvider(LocalAbsoluteTonalElevation provides 1.dp) { - Surface( - modifier = modifier, - shape = MaterialTheme.shapes.large, - ) { - when (viewModel.state) { - is ChannelsViewModel.State.Unselected -> { - ChannelsListUnselected( - modifier = Modifier.fillMaxSize(), - ) - } - is ChannelsViewModel.State.Loading -> { - ChannelsListLoading( - modifier = Modifier.fillMaxSize(), - ) - } - is ChannelsViewModel.State.Loaded -> { - ChannelsListLoaded( - modifier = Modifier.fillMaxSize(), - onChannelSelect = { - viewModel.selectChannel(it) - onChannelSelect() - }, - onCategoryClick = { - viewModel.toggleCategory(it) - }, - bannerUrl = viewModel.guildBannerUrl, - boostLevel = viewModel.guildBoostLevel, - guildName = viewModel.guildName, - selectedChannelId = viewModel.selectedChannelId, - categoryChannels = viewModel.categoryChannels, - noCategoryChannels = viewModel.noCategoryChannels, - lazyListState = lazyListState, - ) - } - is ChannelsViewModel.State.Error -> { - - } - } - } - } -} diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/HomeChannelsPanel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/HomeChannelsPanel.kt new file mode 100644 index 00000000..ff594744 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/HomeChannelsPanel.kt @@ -0,0 +1,86 @@ +package com.xinto.opencord.ui.screens.home.panels.channel + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.LocalAbsoluteTonalElevation +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.xinto.opencord.ui.screens.home.panels.channel.model.CategoryItemData +import com.xinto.opencord.ui.screens.home.panels.channel.model.ChannelItemData +import org.koin.androidx.compose.koinViewModel + +@Composable +fun HomeChannelsPanel(modifier: Modifier = Modifier) { + val viewModel: HomeChannelsPanelViewModel = koinViewModel() + + HomeChannelsPanel( + state = viewModel.state, + onChannelSelect = viewModel::selectChannel, + onCategoryClick = viewModel::toggleCategory, + guildBannerUrl = viewModel.guildBannerUrl, + guildBoostLevel = viewModel.guildBoostLevel, + guildName = viewModel.guildName, + selectedChannelId = viewModel.selectedChannelId, + categoryChannels = viewModel.categoryChannels, + noCategoryChannels = viewModel.noCategoryChannels, + modifier = modifier, + ) +} + +@Composable +fun HomeChannelsPanel( + state: HomeChannelsPanelState, + onChannelSelect: (Long) -> Unit, + onCategoryClick: (Long) -> Unit, + guildBannerUrl: String?, + guildBoostLevel: Int, + guildName: String, + selectedChannelId: Long, + categoryChannels: SnapshotStateMap, + noCategoryChannels: SnapshotStateMap, + modifier: Modifier = Modifier, + lazyListState: LazyListState = rememberLazyListState(), +) { + CompositionLocalProvider(LocalAbsoluteTonalElevation provides 1.dp) { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.large, + ) { + when (state) { + is HomeChannelsPanelState.Unselected -> { + ChannelsListUnselected( + modifier = Modifier.fillMaxSize(), + ) + } + is HomeChannelsPanelState.Loading -> { + ChannelsListLoading( + modifier = Modifier.fillMaxSize(), + ) + } + is HomeChannelsPanelState.Loaded -> { + ChannelsListLoaded( + modifier = Modifier.fillMaxSize(), + onChannelSelect = onChannelSelect, + onCategoryClick = onCategoryClick, + bannerUrl = guildBannerUrl, + boostLevel = guildBoostLevel, + guildName = guildName, + selectedChannelId = selectedChannelId, + categoryChannels = categoryChannels, + noCategoryChannels = noCategoryChannels, + lazyListState = lazyListState, + ) + } + is HomeChannelsPanelState.Error -> { + + } + } + } + } +} diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/HomeChannelsPanelState.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/HomeChannelsPanelState.kt new file mode 100644 index 00000000..e085ad28 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/HomeChannelsPanelState.kt @@ -0,0 +1,8 @@ +package com.xinto.opencord.ui.screens.home.panels.channel + +sealed interface HomeChannelsPanelState { + object Unselected : HomeChannelsPanelState + object Loading : HomeChannelsPanelState + object Loaded : HomeChannelsPanelState + object Error : HomeChannelsPanelState +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChannelsViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/HomeChannelsPanelViewModel.kt similarity index 60% rename from app/src/main/java/com/xinto/opencord/ui/viewmodel/ChannelsViewModel.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/HomeChannelsPanelViewModel.kt index 72acf02d..45facd58 100644 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChannelsViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/HomeChannelsPanelViewModel.kt @@ -1,90 +1,30 @@ -package com.xinto.opencord.ui.viewmodel +package com.xinto.opencord.ui.screens.home.panels.channel import androidx.compose.runtime.* +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.xinto.opencord.domain.channel.DomainCategoryChannel import com.xinto.opencord.domain.channel.DomainChannel -import com.xinto.opencord.domain.channel.DomainUnreadState -import com.xinto.opencord.manager.PersistentDataManager import com.xinto.opencord.store.* -import com.xinto.opencord.ui.viewmodel.base.BasePersistenceViewModel +import com.xinto.opencord.ui.screens.home.panels.channel.model.CategoryItemData +import com.xinto.opencord.ui.screens.home.panels.channel.model.ChannelItemData import com.xinto.opencord.util.collectIn import kotlinx.coroutines.* +import kotlinx.coroutines.flow.flattenMerge +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.mapNotNull - +@OptIn(FlowPreview::class) @Stable -class ChannelsViewModel( - persistentDataManager: PersistentDataManager, +class HomeChannelsPanelViewModel( private val channelStore: ChannelStore, private val guildStore: GuildStore, private val lastMessageStore: LastMessageStore, private val unreadStore: UnreadStore, -) : BasePersistenceViewModel(persistentDataManager) { - sealed interface State { - object Unselected : State - object Loading : State - object Loaded : State - object Error : State - } - - @Stable - class ChannelItemData( - channel: DomainChannel, - mentionCount: Int, - var unreadListenerJob: Job? = null, - var lastMessageListenerJob: Job? = null, - var mentionCountListenerJob: Job? = null, - private var lastUnreadMessageId: Long? = null, - private var lastChannelMessageId: Long? = null, - ) { - private val _isUnread: Boolean - get() = (lastChannelMessageId ?: 0) > (lastUnreadMessageId ?: 0) - - var channel by mutableStateOf(channel) - var mentionCount by mutableStateOf(mentionCount) - var isUnread by mutableStateOf(_isUnread) - private set - - fun updateUnreadState(unreadState: DomainUnreadState?) { - lastUnreadMessageId = unreadState?.lastMessageId - mentionCount = unreadState?.mentionCount ?: 0 - isUnread = _isUnread - } - - fun updateLastMessageId(lastMessageId: Long?) { - this.lastChannelMessageId = lastMessageId - isUnread = _isUnread - } - - fun cancelJobs() { - unreadListenerJob?.cancel() - lastMessageListenerJob?.cancel() - mentionCountListenerJob?.cancel() - } - } - - @Stable - class CategoryItemData( - channel: DomainCategoryChannel, - collapsed: Boolean, - subChannels: List?, - ) { - var channel by mutableStateOf(channel) - var collapsed by mutableStateOf(collapsed) - var channels = mutableStateMapOf() - - val channelsSorted by derivedStateOf { - channels.values.sortedWith { a, b -> a.channel compareTo b.channel } - } - - init { - if (subChannels != null) { - channels.putAll(subChannels.associateBy { it.channel.id }) - } - } - } + private val persistentDataStore: PersistentDataStore +) : ViewModel() { - var state by mutableStateOf(State.Unselected) + var state by mutableStateOf(HomeChannelsPanelState.Unselected) private set var selectedChannelId by mutableStateOf(0L) @@ -103,57 +43,75 @@ class ChannelsViewModel( // Dual reference to all channel items for events updating state private val allChannelItems = mutableMapOf() - fun load() { - if (persistentGuildId <= 0L) return + fun selectChannel(channelId: Long) { + viewModelScope.launch { + persistentDataStore.updateCurrentChannel(channelId) + } + } - viewModelScope.coroutineContext.cancelChildren() + fun toggleCategory(categoryId: Long) { viewModelScope.launch { - state = State.Loading - withContext(Dispatchers.IO) { - try { - val guild = guildStore.fetchGuild(persistentGuildId) - ?: return@withContext - - withContext(Dispatchers.Main) { - guildName = guild.name - guildBannerUrl = guild.bannerUrl - guildBoostLevel = guild.premiumTier - } + persistentDataStore.toggleCategory(categoryId) + } + categoryChannels[categoryId]?.apply { collapsed = !collapsed } + } - replaceChannels(channelStore.fetchChannels(persistentGuildId)) - } catch (e: Exception) { - e.printStackTrace() - withContext(Dispatchers.Main) { - state = State.Error - } + init { + persistentDataStore.observeCurrentGuild() + .collectIn(viewModelScope) { guildId -> + if (guildId == 0L) { + state = HomeChannelsPanelState.Unselected + return@collectIn } + + state = HomeChannelsPanelState.Loading + + val guild = guildStore.fetchGuild(guildId) + if (guild == null) { + state = HomeChannelsPanelState.Error + return@collectIn + } + + guildName = guild.name + guildBannerUrl = guild.bannerUrl + guildBoostLevel = guild.premiumTier + + replaceChannels(channelStore.fetchChannels(guildId)) } - } - guildStore.observeGuild(persistentGuildId).collectIn(viewModelScope) { event -> - event.fold( - onAdd = { - guildName = it.name - guildBannerUrl = it.bannerUrl - guildBoostLevel = it.premiumTier - }, - onUpdate = { - guildName = it.name - guildBannerUrl = it.bannerUrl - guildBoostLevel = it.premiumTier - }, - onDelete = { - state = State.Unselected - }, - ) - } + persistentDataStore.observeCurrentGuild() + .mapNotNull { guildId -> + if (guildId == 0L) null else guildStore.observeGuild(guildId) + }.flattenMerge().collectIn(viewModelScope) { event -> + event.fold( + onAdd = { + guildName = it.name + guildBannerUrl = it.bannerUrl + guildBoostLevel = it.premiumTier + }, + onUpdate = { + guildName = it.name + guildBannerUrl = it.bannerUrl + guildBoostLevel = it.premiumTier + }, + onDelete = { + state = HomeChannelsPanelState.Unselected + }, + ) + } - channelStore.observeChannelsReplace(persistentGuildId).collectIn(viewModelScope) { channels -> - replaceChannels(channels) - } + persistentDataStore.observeCurrentGuild() + .mapNotNull { guildId -> + if (guildId == 0L) null else channelStore.observeChannelsReplace(guildId) + }.flattenMerge().collectIn(viewModelScope) { channels -> + replaceChannels(channels) + } - channelStore.observeChannels(persistentGuildId).collectIn(viewModelScope) { event -> - state = State.Loaded + persistentDataStore.observeCurrentGuild() + .mapNotNull { guildId -> + if (guildId == 0L) null else channelStore.observeChannels(guildId) + }.flattenMerge().collectIn(viewModelScope) { event -> + state = HomeChannelsPanelState.Loaded event.fold( onAdd = { channel -> if (channel is DomainCategoryChannel) { @@ -205,8 +163,16 @@ class ChannelsViewModel( }, ) } + + persistentDataStore.observeCollapsedCategories() + .collectIn(viewModelScope) { collapsedCategories -> + collapsedCategories.forEach { + categoryChannels[it] + } + } } + private suspend fun makeAliveChannelItem(channel: DomainChannel): ChannelItemData { if (channel is DomainCategoryChannel) { error("cannot make channel item from category channel") @@ -256,6 +222,7 @@ class ChannelsViewModel( } private suspend fun replaceChannels(channels: List) { + val collapsedCategories = persistentDataStore.observeCollapsedCategories().last() val (categoryItems, channelItems) = channels .partition { it is DomainCategoryChannel } .let { (newCategories, newChannels) -> @@ -266,7 +233,7 @@ class ChannelsViewModel( val categoryItems = newCategories.associate { category -> category.id to CategoryItemData( channel = category as DomainCategoryChannel, - collapsed = persistentCollapsedCategories.contains(category.id), + collapsed = collapsedCategories.contains(category.id), subChannels = channelItems.values.filter { if (it.channel is DomainCategoryChannel) false @@ -294,32 +261,9 @@ class ChannelsViewModel( noCategoryChannels.clear() noCategoryChannels.putAll(noCategoryItems) - state = State.Loaded + state = HomeChannelsPanelState.Loaded } } } - fun selectChannel(channelId: Long) { - selectedChannelId = channelId - persistentChannelId = channelId - } - - fun toggleCategory(categoryId: Long) { - if (persistentCollapsedCategories.contains(categoryId)) - removePersistentCollapseCategory(categoryId) - else { - addPersistentCollapseCategory(categoryId) - } - - categoryChannels[categoryId]?.apply { collapsed = !collapsed } - } - - init { - if (persistentGuildId != 0L) { - load() - } - if (persistentChannelId != 0L) { - selectedChannelId = persistentChannelId - } - } } diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/model/CategoryItemData.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/model/CategoryItemData.kt new file mode 100644 index 00000000..024ce64a --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/model/CategoryItemData.kt @@ -0,0 +1,30 @@ +package com.xinto.opencord.ui.screens.home.panels.channel.model + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.xinto.opencord.domain.channel.DomainCategoryChannel + +@Stable +class CategoryItemData( + channel: DomainCategoryChannel, + collapsed: Boolean, + subChannels: List?, +) { + var channel by mutableStateOf(channel) + var collapsed by mutableStateOf(collapsed) + var channels = mutableStateMapOf() + + val channelsSorted by derivedStateOf { + channels.values.sortedWith { a, b -> a.channel compareTo b.channel } + } + + init { + if (subChannels != null) { + channels.putAll(subChannels.associateBy { it.channel.id }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/model/ChannelItemData.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/model/ChannelItemData.kt new file mode 100644 index 00000000..510e4bb7 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/model/ChannelItemData.kt @@ -0,0 +1,45 @@ +package com.xinto.opencord.ui.screens.home.panels.channel.model + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.xinto.opencord.domain.channel.DomainChannel +import com.xinto.opencord.domain.channel.DomainUnreadState +import kotlinx.coroutines.Job + +@Stable +class ChannelItemData( + channel: DomainChannel, + mentionCount: Int, + var unreadListenerJob: Job? = null, + var lastMessageListenerJob: Job? = null, + var mentionCountListenerJob: Job? = null, + private var lastUnreadMessageId: Long? = null, + private var lastChannelMessageId: Long? = null, +) { + private val _isUnread: Boolean + get() = (lastChannelMessageId ?: 0) > (lastUnreadMessageId ?: 0) + + var channel by mutableStateOf(channel) + var mentionCount by mutableStateOf(mentionCount) + var isUnread by mutableStateOf(_isUnread) + private set + + fun updateUnreadState(unreadState: DomainUnreadState?) { + lastUnreadMessageId = unreadState?.lastMessageId + mentionCount = unreadState?.mentionCount ?: 0 + isUnread = _isUnread + } + + fun updateLastMessageId(lastMessageId: Long?) { + this.lastChannelMessageId = lastMessageId + isUnread = _isUnread + } + + fun cancelJobs() { + unreadListenerJob?.cancel() + lastMessageListenerJob?.cancel() + mentionCountListenerJob?.cancel() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/ChannelsListLoaded.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/state/ChannelsListLoaded.kt similarity index 96% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/ChannelsListLoaded.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/state/ChannelsListLoaded.kt index 96fbac0d..8f0636a0 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/ChannelsListLoaded.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/state/ChannelsListLoaded.kt @@ -36,9 +36,10 @@ import com.xinto.opencord.domain.channel.DomainVoiceChannel import com.xinto.opencord.ui.components.OCImage import com.xinto.opencord.ui.components.channel.list.ChannelListCategoryItem import com.xinto.opencord.ui.components.channel.list.ChannelListRegularItem +import com.xinto.opencord.ui.screens.home.panels.channel.model.CategoryItemData +import com.xinto.opencord.ui.screens.home.panels.channel.model.ChannelItemData import com.xinto.opencord.ui.util.ContentAlpha import com.xinto.opencord.ui.util.ProvideContentAlpha -import com.xinto.opencord.ui.viewmodel.ChannelsViewModel @Composable fun ChannelsListLoaded( @@ -48,8 +49,8 @@ fun ChannelsListLoaded( bannerUrl: String?, boostLevel: Int, guildName: String, - categoryChannels: SnapshotStateMap, - noCategoryChannels: SnapshotStateMap, + categoryChannels: SnapshotStateMap, + noCategoryChannels: SnapshotStateMap, modifier: Modifier = Modifier, lazyListState: LazyListState = rememberLazyListState(), ) { @@ -199,7 +200,7 @@ fun ChannelsListLoaded( @Composable fun ChannelItem( - itemData: ChannelsViewModel.ChannelItemData, + itemData: ChannelItemData, isSelected: Boolean, onClick: () -> Unit, ) { diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/ChannelsListLoading.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/state/ChannelsListLoading.kt similarity index 100% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/ChannelsListLoading.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/state/ChannelsListLoading.kt diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/ChannelsListUnselected.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/state/ChannelsListUnselected.kt similarity index 100% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/ChannelsListUnselected.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/state/ChannelsListUnselected.kt diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/Chat.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanel.kt similarity index 71% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/Chat.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanel.kt index 01206eca..1d093ad3 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/Chat.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanel.kt @@ -3,6 +3,7 @@ package com.xinto.opencord.ui.screens.home.panels.chat import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -10,17 +11,38 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.xinto.opencord.R -import com.xinto.opencord.ui.screens.home.panels.chatinput.ChatInput -import com.xinto.opencord.ui.viewmodel.ChatViewModel -import org.koin.androidx.compose.getViewModel +import com.xinto.opencord.domain.emoji.DomainEmoji +import com.xinto.opencord.ui.screens.home.panels.chat.state.ChatError +import com.xinto.opencord.ui.screens.home.panels.chat.state.ChatLoaded +import com.xinto.opencord.ui.screens.home.panels.chat.component.HomeChatPanelInput +import com.xinto.opencord.ui.screens.home.panels.chat.model.MessageItem +import org.koin.androidx.compose.koinViewModel @Composable -fun Chat( - onChannelsButtonClick: () -> Unit, - onMembersButtonClick: () -> Unit, +fun HomeChatPanel(modifier: Modifier = Modifier) { + val viewModel: HomeChatPanelViewModel = koinViewModel() + HomeChatPanel( + modifier = modifier, + state = viewModel.state, + channelName = viewModel.channelName, + messages = viewModel.sortedMessages, + onMessageReact = viewModel::reactToMessage, + onPinsButtonClick = { /*TODO*/ }, + onMembersButtonClick = { /*TODO*/ }, + onChannelsButtonClick = { /*TODO*/ } + ) +} + +@Composable +fun HomeChatPanel( + state: HomeChatPanelState, + channelName: String, + messages: SnapshotStateList, + onMessageReact: (Long, DomainEmoji) -> Unit, onPinsButtonClick: () -> Unit, + onMembersButtonClick: () -> Unit, + onChannelsButtonClick: () -> Unit, modifier: Modifier = Modifier, - viewModel: ChatViewModel = getViewModel(), ) { Scaffold( modifier = modifier, @@ -31,7 +53,7 @@ fun Chat( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (viewModel.channelName.isNotEmpty()) { + if (channelName.isNotEmpty()) { Icon( painter = painterResource(R.drawable.ic_tag), contentDescription = null, @@ -39,7 +61,7 @@ fun Chat( ) } Text( - text = viewModel.channelName, + text = channelName, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleLarge, @@ -75,22 +97,22 @@ fun Chat( modifier = Modifier.fillMaxSize(), tonalElevation = 2.dp, ) { - when (viewModel.state) { - is ChatViewModel.State.Unselected -> { + when (state) { + is HomeChatPanelState.Unselected -> { ChatUnselected( modifier = Modifier .fillMaxSize() .padding(paddingValues), ) } - is ChatViewModel.State.Loading -> { + is HomeChatPanelState.Loading -> { ChatLoading( modifier = Modifier .fillMaxSize() .padding(paddingValues), ) } - is ChatViewModel.State.Loaded -> { + is HomeChatPanelState.Loaded -> { Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier @@ -98,25 +120,26 @@ fun Chat( .padding(paddingValues), ) { ChatLoaded( - viewModel = viewModel, modifier = Modifier .fillMaxSize() .weight(1f), + messages = messages, + onMessageReact = onMessageReact ) - ChatInput( + HomeChatPanelInput( modifier = Modifier.padding( start = 8.dp, end = 8.dp, bottom = 4.dp, ), hint = { - Text(stringResource(R.string.chat_input_hint, viewModel.channelName)) + Text(stringResource(R.string.chat_input_hint, channelName)) }, ) } } - is ChatViewModel.State.Error -> { + is HomeChatPanelState.Error -> { ChatError( modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelState.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelState.kt new file mode 100644 index 00000000..62b4abae --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelState.kt @@ -0,0 +1,8 @@ +package com.xinto.opencord.ui.screens.home.panels.chat + +sealed interface HomeChatPanelState { + object Unselected : HomeChatPanelState + object Loading : HomeChatPanelState + object Loaded : HomeChatPanelState + object Error : HomeChatPanelState +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelViewModel.kt new file mode 100644 index 00000000..c897ed9e --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelViewModel.kt @@ -0,0 +1,205 @@ +package com.xinto.opencord.ui.screens.home.panels.chat + +import androidx.compose.runtime.* +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.xinto.opencord.domain.emoji.DomainEmoji +import com.xinto.opencord.domain.message.DomainMessage +import com.xinto.opencord.domain.message.DomainMessageRegular +import com.xinto.opencord.rest.service.DiscordApiService +import com.xinto.opencord.store.* +import com.xinto.opencord.ui.screens.home.panels.chat.model.MessageItem +import com.xinto.opencord.ui.screens.home.panels.chat.model.ReactionState +import com.xinto.opencord.util.collectIn +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.flattenMerge +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch + +@OptIn(FlowPreview::class) +@Stable +class HomeChatPanelViewModel( + private val messageStore: MessageStore, + private val reactionStore: ReactionStore, + private val channelStore: ChannelStore, + private val currentUserStore: CurrentUserStore, + private val persistentDataStore: PersistentDataStore, + private val api: DiscordApiService, +) : ViewModel() { + + var state by mutableStateOf(HomeChatPanelState.Unselected) + private set + + var channelName by mutableStateOf("") + private set + var currentUserId by mutableStateOf(null) + private set + + // Reverse sorted (decreasing) message list + val sortedMessages = mutableStateListOf() + + private fun getMessageItemIndex(messageId: Long): Int? { + return sortedMessages + .binarySearch { messageId compareTo it.message.id } + .takeIf { it >= 0 } + } + + fun reactToMessage(messageId: Long, emoji: DomainEmoji) { + viewModelScope.launch { + val meReacted = getMessageItemIndex(messageId) + ?.let { sortedMessages.getOrNull(it)?.reactions?.get(emoji.identifier)?.meReacted } + ?: false + + val channelId = persistentDataStore.observeCurrentChannel().last() + if (meReacted) { + api.removeMeReaction(channelId, messageId, emoji) + } else { + api.addMeReaction(channelId, messageId, emoji) + } + } + } + + init { + persistentDataStore.observeCurrentChannel() + .collectIn(viewModelScope) { channelId -> + if (channelId == 0L) { + state = HomeChatPanelState.Unselected + return@collectIn + } + + state = HomeChatPanelState.Loading + + val channel = channelStore.fetchChannel(channelId) + if (channel == null) { + state = HomeChatPanelState.Error + return@collectIn + } + + val messages = messageStore.fetchMessages(channelId) + .sortedByDescending { it.id } + + val messageItems = messages.map { + val reactions = reactionStore.getReactions(it.id).mapIndexed { i, reaction -> + ReactionState( + reactionOrder = i.toLong(), + emoji = reaction.emoji, + count = reaction.count, + meReacted = reaction.meReacted, + ) + } + + MessageItem( + message = it, + reactions = reactions, + currentUserId = currentUserId + ) + } + + for (i in 0 until (messageItems.size - 1)) { + val curMessage = messageItems[i] + val prevMessage = messageItems[i + 1] + + val canMerge = canMessagesMerge(curMessage.message, prevMessage.message) + curMessage.topMerged = canMerge + prevMessage.bottomMerged = canMerge + } + + channelName = channel.name + + sortedMessages.clear() + sortedMessages.addAll(messageItems) + + state = HomeChatPanelState.Loaded + } + + persistentDataStore.observeCurrentChannel() + .mapNotNull { channelId -> + if (channelId == 0L) null else messageStore.observeChannel(channelId) + }.flattenMerge().collectIn(viewModelScope) { event -> + event.fold( + onAdd = { msg -> + val topMessage = sortedMessages.getOrNull(0) + val canMerge = canMessagesMerge(msg, topMessage?.message) + + sortedMessages.getOrNull(0)?.bottomMerged = canMerge + sortedMessages.add(0, MessageItem(msg, topMerged = canMerge, currentUserId = currentUserId)) + }, + onUpdate = { msg -> + val i = getMessageItemIndex(msg.id) + ?: return@fold + + val topMessage = sortedMessages.getOrNull(i + 1) + val bottomMessage = sortedMessages.getOrNull(i - 1) + val canMerge = canMessagesMerge(bottomMessage?.message, topMessage?.message) + + topMessage?.bottomMerged = canMerge + bottomMessage?.topMerged = canMerge + sortedMessages.getOrNull(i)?.message = msg + }, + onDelete = { data -> + val i = getMessageItemIndex(data.messageId.value) + ?: return@fold + + val topMessage = sortedMessages.getOrNull(i + 1) + val bottomMessage = sortedMessages.getOrNull(i - 1) + val canMerge = canMessagesMerge(bottomMessage?.message, topMessage?.message) + + topMessage?.bottomMerged = canMerge + bottomMessage?.topMerged = canMerge + sortedMessages.removeAt(i) + }, + ) + } + + persistentDataStore.observeCurrentChannel() + .mapNotNull { channelId -> + if (channelId == 0L) null else reactionStore.observeChannel(channelId) + }.flattenMerge().collectIn(viewModelScope) { event -> + event.fold( + onAdd = { /* onAdd doesn't exist */ }, + onUpdate = { data -> + val reactions = getMessageItemIndex(data.messageId) + ?.let { sortedMessages.getOrNull(it)?.reactions } + ?: return@fold + + reactions.compute(data.emoji.identifier) { _, state -> + val newCount = data.countDiff + (state?.count ?: 0) + if (newCount <= 0) return@compute null + + state?.apply { + count = newCount + data.meReacted?.let { meReacted = it } + } ?: ReactionState( + reactionOrder = System.currentTimeMillis(), + emoji = data.emoji, + count = newCount, + meReacted = data.meReacted ?: false, + ) + } + }, + onDelete = { data -> + getMessageItemIndex(data.messageId) + ?.let { sortedMessages.getOrNull(it)?.reactions?.clear() } + }, + ) + } + + currentUserStore.observeCurrentUser().collectIn(viewModelScope) { user -> + currentUserId = user.id + } + } + + private fun canMessagesMerge(message: DomainMessage?, prevMessage: DomainMessage?): Boolean { + return message != null && prevMessage != null + && message.author.id == prevMessage.author.id + && prevMessage is DomainMessageRegular + && message is DomainMessageRegular + && !message.isReply + && (message.timestamp - prevMessage.timestamp).inWholeMinutes < 1 + && message.attachments.isEmpty() + && prevMessage.attachments.isEmpty() + && message.embeds.isEmpty() + && prevMessage.embeds.isEmpty() + } +} diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chatinput/ChatInput.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/ChatInput.kt similarity index 97% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chatinput/ChatInput.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/ChatInput.kt index 7976699a..fea03fe8 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chatinput/ChatInput.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/ChatInput.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.screens.home.panels.chatinput +package com.xinto.opencord.ui.screens.home.panels.chat.component import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInHorizontally @@ -16,7 +16,7 @@ import com.xinto.opencord.ui.viewmodel.ChatInputViewModel import org.koin.androidx.compose.getViewModel @Composable -fun ChatInput( +fun HomeChatPanelInput( modifier: Modifier = Modifier, hint: @Composable () -> Unit, viewModel: ChatInputViewModel = getViewModel(), diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/model/MessageItem.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/model/MessageItem.kt new file mode 100644 index 00000000..2b0ebb43 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/model/MessageItem.kt @@ -0,0 +1,34 @@ +package com.xinto.opencord.ui.screens.home.panels.chat.model + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.xinto.opencord.domain.emoji.DomainEmojiIdentifier +import com.xinto.opencord.domain.message.DomainMessage +import com.xinto.opencord.domain.message.DomainMessageRegular + +@Stable +class MessageItem( + message: DomainMessage, + reactions: List? = null, + topMerged: Boolean = false, + currentUserId: Long?, +) { + var topMerged by mutableStateOf(topMerged) + var bottomMerged by mutableStateOf(false) + var message by mutableStateOf(message) + var reactions = mutableStateMapOf() + .apply { reactions?.let { putAll(it.map { r -> r.emoji.identifier to r }) } } + + val meMentioned by derivedStateOf { + when { + message !is DomainMessageRegular -> false + message.mentionEveryone -> true + message.mentions.any { it.id == currentUserId } -> true + else -> false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/model/ReactionState.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/model/ReactionState.kt new file mode 100644 index 00000000..19d94389 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/model/ReactionState.kt @@ -0,0 +1,18 @@ +package com.xinto.opencord.ui.screens.home.panels.chat.model + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.xinto.opencord.domain.emoji.DomainEmoji + +@Stable +class ReactionState( + val emoji: DomainEmoji, + val reactionOrder: Long, + meReacted: Boolean, + count: Int, +) { + var meReacted by mutableStateOf(meReacted) + var count by mutableStateOf(count) +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/ChatError.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/state/ChatError.kt similarity index 95% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/ChatError.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/state/ChatError.kt index 59a1e99f..bc38f221 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/ChatError.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/state/ChatError.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.screens.home.panels.chat +package com.xinto.opencord.ui.screens.home.panels.chat.state import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/ChatLoaded.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/state/ChatLoaded.kt similarity index 96% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/ChatLoaded.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/state/ChatLoaded.kt index 790ffb6f..240db2e3 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/ChatLoaded.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/state/ChatLoaded.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.screens.home.panels.chat +package com.xinto.opencord.ui.screens.home.panels.chat.state import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -8,6 +8,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -18,6 +19,7 @@ import androidx.compose.ui.unit.sp import com.xinto.opencord.R import com.xinto.opencord.domain.attachment.DomainPictureAttachment import com.xinto.opencord.domain.attachment.DomainVideoAttachment +import com.xinto.opencord.domain.emoji.DomainEmoji import com.xinto.opencord.domain.emoji.DomainGuildEmoji import com.xinto.opencord.domain.emoji.DomainUnicodeEmoji import com.xinto.opencord.domain.emoji.DomainUnknownEmoji @@ -31,23 +33,24 @@ import com.xinto.opencord.ui.components.message.* import com.xinto.opencord.ui.components.message.reply.MessageReferenced import com.xinto.opencord.ui.components.message.reply.MessageReferencedAuthor import com.xinto.opencord.ui.components.message.reply.MessageReferencedContent +import com.xinto.opencord.ui.screens.home.panels.chat.model.MessageItem import com.xinto.opencord.ui.screens.home.panels.messagemenu.MessageMenu import com.xinto.opencord.ui.util.ifComposable import com.xinto.opencord.ui.util.ifNotEmptyComposable import com.xinto.opencord.ui.util.ifNotNullComposable import com.xinto.opencord.ui.util.toUnsafeImmutableList -import com.xinto.opencord.ui.viewmodel.ChatViewModel @Composable fun ChatLoaded( - viewModel: ChatViewModel, modifier: Modifier = Modifier, onUsernameClicked: ((userId: Long) -> Unit)? = null, + messages: SnapshotStateList, + onMessageReact: (Long, DomainEmoji) -> Unit ) { val listState = rememberLazyListState() // TODO: scroll to target message if jumping var messageMenuTarget by remember { mutableStateOf(null) } - LaunchedEffect(viewModel.sortedMessages.size) { + LaunchedEffect(messages.size) { if (listState.firstVisibleItemIndex <= 1) { listState.animateScrollToItem(0) } @@ -65,7 +68,7 @@ fun ChatLoaded( modifier = modifier, reverseLayout = true, ) { - items(viewModel.sortedMessages, key = { it.message.id }) { item -> + items(messages, key = { it.message.id }) { item -> when (val message = item.message) { is DomainMessageRegular -> { val messageReactions by remember { @@ -254,7 +257,7 @@ fun ChatLoaded( key(reaction.emoji.identifier) { MessageReaction( onClick = { - viewModel.reactToMessage(message.id, reaction.emoji) + onMessageReact(message.id, reaction.emoji) }, count = reaction.count, meReacted = reaction.meReacted, diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/ChatLoading.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/state/ChatLoading.kt similarity index 100% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/ChatLoading.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/state/ChatLoading.kt diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/ChatUnselected.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/state/ChatUnselected.kt similarity index 100% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/ChatUnselected.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/state/ChatUnselected.kt diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/currentuser/CurrentUser.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/currentuser/CurrentUser.kt deleted file mode 100644 index eed3bc9f..00000000 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/currentuser/CurrentUser.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.xinto.opencord.ui.screens.home.panels.currentuser - -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.xinto.opencord.ui.screens.home.panels.currentuser.sheet.CurrentUserSheet -import com.xinto.opencord.ui.viewmodel.CurrentUserViewModel -import org.koin.androidx.compose.getViewModel - -@Composable -fun CurrentUser( - modifier: Modifier = Modifier, - onSettingsClick: () -> Unit, - viewModel: CurrentUserViewModel = getViewModel() -) { - var showStatusSheet by remember { mutableStateOf(false) } - - Surface( - modifier = modifier, - onClick = { showStatusSheet = true }, - shape = MaterialTheme.shapes.medium, - tonalElevation = 1.dp, - ) { - when (viewModel.state) { - CurrentUserViewModel.State.Loading -> { - CurrentUserLoading( - onSettingsClick = onSettingsClick, - ) - } - CurrentUserViewModel.State.Loaded -> { - CurrentUserLoaded( - onSettingsClick = onSettingsClick, - avatarUrl = viewModel.avatarUrl, - username = viewModel.username, - discriminator = viewModel.discriminator, - status = viewModel.userStatus, - isStreaming = viewModel.isStreaming, - customStatus = viewModel.userCustomStatus, - ) - } - CurrentUserViewModel.State.Error -> { - // TODO: CurrentUserError - } - } - } - - if (showStatusSheet) { - CurrentUserSheet( - onClose = { showStatusSheet = false }, - ) - } -} diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/GuildsList.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/GuildsList.kt index d8ca199e..b73fad6b 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/GuildsList.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/GuildsList.kt @@ -1,31 +1,41 @@ package com.xinto.opencord.ui.screens.home.panels.guild import androidx.compose.runtime.Composable +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier -import com.xinto.opencord.ui.viewmodel.GuildsViewModel -import org.koin.androidx.compose.getViewModel +import com.xinto.opencord.ui.screens.home.panels.guild.model.GuildItem +import org.koin.androidx.compose.koinViewModel @Composable -fun GuildsList( - onGuildSelect: () -> Unit, +fun HomeGuildsPanel(modifier: Modifier = Modifier) { + val viewModel: GuildsViewModel = koinViewModel() + HomeGuildsPanel( + modifier = modifier, + onGuildSelect = viewModel::selectGuild, + state = viewModel.state, + guilds = viewModel.listItems + ) +} + +@Composable +fun HomeGuildsPanel( + onGuildSelect: (Long) -> Unit, + state: HomeGuildPanelState, + guilds: SnapshotStateList, modifier: Modifier = Modifier, - viewModel: GuildsViewModel = getViewModel(), ) { - when (viewModel.state) { - GuildsViewModel.State.Loading -> { + when (state) { + HomeGuildPanelState.Loading -> { GuildsListLoading(modifier = modifier) } - GuildsViewModel.State.Loaded -> { + HomeGuildPanelState.Loaded -> { GuildsListLoaded( - items = viewModel.listItems, + items = guilds, modifier = modifier, - onGuildSelect = { - viewModel.selectGuild(it) - onGuildSelect() - }, + onGuildSelect = onGuildSelect, ) } - GuildsViewModel.State.Error -> { + HomeGuildPanelState.Error -> { } } diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/GuildsViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/GuildsViewModel.kt new file mode 100644 index 00000000..840e9fb7 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/GuildsViewModel.kt @@ -0,0 +1,93 @@ +package com.xinto.opencord.ui.screens.home.panels.guild + +import androidx.compose.runtime.* +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.xinto.opencord.store.GuildStore +import com.xinto.opencord.store.PersistentDataStore +import com.xinto.opencord.store.fold +import com.xinto.opencord.ui.screens.home.panels.guild.model.GuildItem +import com.xinto.opencord.util.collectIn +import com.xinto.opencord.util.removeFirst +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch + +@Stable +class GuildsViewModel( + guildStore: GuildStore, + private val persistentDataStore: PersistentDataStore, +) : ViewModel() { + var state by mutableStateOf(HomeGuildPanelState.Loading, referentialEqualityPolicy()) + private set + + // List of all items to display (not just guilds) + val listItems = mutableStateListOf() + + // Dual reference to all Guild items in listItems for performance + private val guildItemRefs = mutableMapOf() + + fun selectGuild(guildId: Long) { + viewModelScope.launch { + persistentDataStore.updateCurrentGuild(guildId) + + guildItemRefs.values + .find { it.selected } + ?.selected = false + + guildItemRefs[guildId]?.selected = true + } + } + + init { + persistentDataStore.observeCurrentGuild() + .onStart { + val guilds = guildStore.fetchGuilds() // TODO: don't do anything w/o cache (keep Loading state) + val items = guilds.map { + GuildItem.Guild( + guild = it, + selected = false, + ) + } + + guildItemRefs.putAll(items.map { it.data.id to it }) + listItems.add(GuildItem.Header) + listItems.add(GuildItem.Divider) + listItems.addAll(items) + state = HomeGuildPanelState.Loaded + } + .collectIn(viewModelScope) { guildId -> + guildItemRefs.values + .find { it.selected } + ?.selected = false + + guildItemRefs[guildId]?.selected = true + } + + guildStore.observeGuilds().collectIn(viewModelScope) { event -> + event.fold( + onAdd = { + val existingItem = guildItemRefs[it.id] + + if (existingItem != null) { + existingItem.data = it + } else { + val item = GuildItem.Guild(it) + listItems.add(item) + guildItemRefs[it.id] = item + } + }, + onUpdate = { guildItemRefs[it.id]?.data = it }, + onDelete = { guildId -> + val item = guildItemRefs.remove(guildId) + ?: return@fold + + // Guaranteed if item retrieved + listItems.removeFirst { it === item } + }, + ) + + state = HomeGuildPanelState.Loaded // Will have an effect on no-cache app load + } + } + +} diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/HomeGuildPanelState.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/HomeGuildPanelState.kt new file mode 100644 index 00000000..5e861eee --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/HomeGuildPanelState.kt @@ -0,0 +1,7 @@ +package com.xinto.opencord.ui.screens.home.panels.guild + +sealed interface HomeGuildPanelState { + object Loading : HomeGuildPanelState + object Loaded : HomeGuildPanelState + object Error : HomeGuildPanelState +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/model/GuildItem.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/model/GuildItem.kt new file mode 100644 index 00000000..778d6630 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/model/GuildItem.kt @@ -0,0 +1,35 @@ +package com.xinto.opencord.ui.screens.home.panels.guild.model + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.setValue +import com.xinto.opencord.domain.guild.DomainGuild + +@Stable +sealed interface GuildItem { + val key: Any + + @Immutable + object Header : GuildItem { + override val key get() = "HEADER" + } + + @Immutable + object Divider : GuildItem { + override val key get() = "DIVIDER" + } + + @Stable + class Guild( + guild: DomainGuild, + selected: Boolean = false, + ) : GuildItem { + var data by mutableStateOf(guild, neverEqualPolicy()) + var selected by mutableStateOf(selected) + + override val key get() = data.id + } +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/GuildsListLoaded.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/state/GuildsListLoaded.kt similarity index 97% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/GuildsListLoaded.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/state/GuildsListLoaded.kt index 123e843b..bcb44b85 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/GuildsListLoaded.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/state/GuildsListLoaded.kt @@ -17,7 +17,7 @@ import com.xinto.opencord.ui.components.guild.list.GuildsListHeaderItem import com.xinto.opencord.ui.components.guild.list.GuildsListItemImage import com.xinto.opencord.ui.components.guild.list.GuildsListTextItem import com.xinto.opencord.ui.components.guild.list.RegularGuildItem -import com.xinto.opencord.ui.viewmodel.GuildsViewModel.GuildItem +import com.xinto.opencord.ui.screens.home.panels.guild.model.GuildItem @Composable fun GuildsListLoaded( diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/GuildsListLoading.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/state/GuildsListLoading.kt similarity index 100% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/GuildsListLoading.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/guild/state/GuildsListLoading.kt diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/HomeMessageMenuPanelState.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/HomeMessageMenuPanelState.kt new file mode 100644 index 00000000..f005d6e6 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/HomeMessageMenuPanelState.kt @@ -0,0 +1,7 @@ +package com.xinto.opencord.ui.screens.home.panels.messagemenu + +sealed interface HomeMessageMenuPanelState { + object Loading : HomeMessageMenuPanelState + object Loaded : HomeMessageMenuPanelState + object Closing : HomeMessageMenuPanelState +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenu.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenu.kt index 1db93472..b5c4e188 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenu.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenu.kt @@ -4,8 +4,8 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.* +import com.xinto.opencord.ui.screens.home.panels.messagemenu.state.MessageMenuLoaded import com.xinto.opencord.ui.util.getLocalViewModel -import com.xinto.opencord.ui.viewmodel.MessageMenuViewModel import kotlinx.coroutines.launch import org.koin.core.parameter.parametersOf @@ -29,7 +29,7 @@ fun MessageMenu( } LaunchedEffect(viewModel.state) { - if (viewModel.state == MessageMenuViewModel.State.Closing) { + if (viewModel.state == HomeMessageMenuPanelState.Closing) { onDismiss?.invoke() } } @@ -43,11 +43,11 @@ fun MessageMenu( }, ) { when (viewModel.state) { - MessageMenuViewModel.State.Loading -> MessageMenuLoading() - MessageMenuViewModel.State.Loaded -> MessageMenuLoaded( + HomeMessageMenuPanelState.Loading -> MessageMenuLoading() + HomeMessageMenuPanelState.Loaded -> MessageMenuLoaded( viewModel = viewModel, ) - MessageMenuViewModel.State.Closing -> { + HomeMessageMenuPanelState.Closing -> { LaunchedEffect(Unit) { sheetState.hide() } diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/MessageMenuViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenuViewModel.kt similarity index 91% rename from app/src/main/java/com/xinto/opencord/ui/viewmodel/MessageMenuViewModel.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenuViewModel.kt index 639a00c0..341f6f76 100644 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/MessageMenuViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenuViewModel.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.viewmodel +package com.xinto.opencord.ui.screens.home.panels.messagemenu import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -29,11 +29,6 @@ class MessageMenuViewModel( private val clipboard: ClipboardManager, private val toasts: ToastManager, ) : ViewModel() { - sealed interface State { - object Loading : State - object Loaded : State - object Closing : State - } sealed interface PinState { object Pinnable : PinState @@ -41,7 +36,7 @@ class MessageMenuViewModel( object None : PinState } - var state by mutableStateOf(State.Loading) + var state by mutableStateOf(HomeMessageMenuPanelState.Loading) private set var message by mutableStateOf(null) @@ -96,7 +91,7 @@ class MessageMenuViewModel( val message = messages.getMessage(messageId) if (message == null || currentUser == null) { - state = State.Closing + state = HomeMessageMenuPanelState.Closing return@launch } @@ -120,14 +115,14 @@ class MessageMenuViewModel( isDeletable = currentUser.id == message.author.id isEditable = currentUser.id == message.author.id this@MessageMenuViewModel.message = message - state = State.Loaded + state = HomeMessageMenuPanelState.Loaded } messages.observeMessage(messageId).collectIn(viewModelScope) { event -> event.fold( onAdd = {}, onUpdate = { message = it }, - onDelete = { state = State.Closing }, + onDelete = { state = HomeMessageMenuPanelState.Closing }, ) } diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenuLoaded.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/state/MessageMenuLoaded.kt similarity index 96% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenuLoaded.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/state/MessageMenuLoaded.kt index a97eb721..b3426c9a 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenuLoaded.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/state/MessageMenuLoaded.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.screens.home.panels.messagemenu +package com.xinto.opencord.ui.screens.home.panels.messagemenu.state import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -16,7 +16,8 @@ import com.xinto.opencord.domain.emoji.DomainUnicodeEmoji import com.xinto.opencord.domain.emoji.DomainUnknownEmoji import com.xinto.opencord.ui.components.OCImage import com.xinto.opencord.ui.components.OCSize -import com.xinto.opencord.ui.viewmodel.MessageMenuViewModel +import com.xinto.opencord.ui.screens.home.panels.messagemenu.MessageMenuPreviewMessage +import com.xinto.opencord.ui.screens.home.panels.messagemenu.MessageMenuViewModel import com.xinto.opencord.util.Quad @Composable diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenuLoading.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/state/MessageMenuLoading.kt similarity index 100% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenuLoading.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/state/MessageMenuLoading.kt diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/CurrentUser.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/CurrentUser.kt new file mode 100644 index 00000000..b4d1c697 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/CurrentUser.kt @@ -0,0 +1,76 @@ +package com.xinto.opencord.ui.screens.home.panels.user + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.xinto.opencord.domain.usersettings.DomainCustomStatus +import com.xinto.opencord.domain.usersettings.DomainUserStatus +import com.xinto.opencord.ui.screens.home.panels.user.component.CurrentUserSheet +import org.koin.androidx.compose.koinViewModel + +@Composable +fun HomeUserPanel(modifier: Modifier = Modifier) { + val viewModel: HomeUserPanelViewModel = koinViewModel() + HomeUserPanel( + onSettingsClick = {}, + state = viewModel.state, + avatarUrl = viewModel.avatarUrl, + username = viewModel.username, + discriminator = viewModel.discriminator, + status = viewModel.userStatus, + isStreaming = viewModel.isStreaming, + customStatus = viewModel.userCustomStatus, + ) +} + +@Composable +fun HomeUserPanel( + modifier: Modifier = Modifier, + onSettingsClick: () -> Unit, + state: HomeUserPanelState, + avatarUrl: String, + username: String, + discriminator: String, + status: DomainUserStatus?, + isStreaming: Boolean, + customStatus: DomainCustomStatus? +) { + var showStatusSheet by remember { mutableStateOf(false) } + + Surface( + modifier = modifier, + onClick = { showStatusSheet = true }, + shape = MaterialTheme.shapes.medium, + tonalElevation = 1.dp, + ) { + when (state) { + HomeUserPanelState.Loading -> { + CurrentUserLoading( + onSettingsClick = onSettingsClick, + ) + } + HomeUserPanelState.Loaded -> { + CurrentUserLoaded( + onSettingsClick = onSettingsClick, + avatarUrl = avatarUrl, + username = username, + discriminator = discriminator, + status = status, + isStreaming = isStreaming, + customStatus = customStatus, + ) + } + HomeUserPanelState.Error -> { + // TODO: CurrentUserError + } + } + } + + if (showStatusSheet) { + CurrentUserSheet( + onClose = { showStatusSheet = false }, + ) + } +} diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/HomeUserPanelState.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/HomeUserPanelState.kt new file mode 100644 index 00000000..fcea470b --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/HomeUserPanelState.kt @@ -0,0 +1,7 @@ +package com.xinto.opencord.ui.screens.home.panels.user + +sealed interface HomeUserPanelState { + object Loading : HomeUserPanelState + object Loaded : HomeUserPanelState + object Error : HomeUserPanelState +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/CurrentUserViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/HomeUserPanelViewModel.kt similarity index 94% rename from app/src/main/java/com/xinto/opencord/ui/viewmodel/CurrentUserViewModel.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/HomeUserPanelViewModel.kt index 2f2da485..f2dbe405 100644 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/CurrentUserViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/HomeUserPanelViewModel.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.viewmodel +package com.xinto.opencord.ui.screens.home.panels.user import androidx.annotation.DrawableRes import androidx.compose.runtime.Stable @@ -27,19 +27,14 @@ import kotlinx.coroutines.launch import kotlinx.datetime.Clock @Stable -class CurrentUserViewModel( +class HomeUserPanelViewModel( private val gateway: DiscordGateway, private val sessionStore: SessionStore, private val currentUserStore: CurrentUserStore, private val userSettingsStore: UserSettingsStore, ) : ViewModel() { - sealed interface State { - object Loading : State - object Loaded : State - object Error : State - } - var state by mutableStateOf(State.Loading) + var state by mutableStateOf(HomeUserPanelState.Loading) private set var avatarUrl by mutableStateOf("") @@ -135,7 +130,7 @@ class CurrentUserViewModel( avatarUrl = user.avatarUrl username = user.username discriminator = user.formattedDiscriminator - state = State.Loaded + state = HomeUserPanelState.Loaded } userSettingsStore.observeUserSettings().collectIn(viewModelScope) { event -> diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/currentuser/sheet/CurrentUserSheet.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/component/CurrentUserSheet.kt similarity index 95% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/currentuser/sheet/CurrentUserSheet.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/component/CurrentUserSheet.kt index 4379c21c..82376cf0 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/currentuser/sheet/CurrentUserSheet.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/component/CurrentUserSheet.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.screens.home.panels.currentuser.sheet +package com.xinto.opencord.ui.screens.home.panels.user.component import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -11,13 +11,13 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.xinto.opencord.R -import com.xinto.opencord.ui.viewmodel.CurrentUserViewModel +import com.xinto.opencord.ui.screens.home.panels.user.HomeUserPanelViewModel import org.koin.androidx.compose.getViewModel @Composable fun CurrentUserSheet( onClose: () -> Unit, - viewModel: CurrentUserViewModel = getViewModel(), + viewModel: HomeUserPanelViewModel = getViewModel(), ) { ModalBottomSheet( onDismissRequest = onClose, diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/currentuser/CurrentUserContent.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/layout/CurrentUserContent.kt similarity index 96% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/currentuser/CurrentUserContent.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/layout/CurrentUserContent.kt index 89adc4ae..a73f9f72 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/currentuser/CurrentUserContent.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/layout/CurrentUserContent.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.screens.home.panels.currentuser +package com.xinto.opencord.ui.screens.home.panels.user.layout import androidx.compose.foundation.layout.* import androidx.compose.material3.MaterialTheme @@ -11,7 +11,7 @@ import com.xinto.opencord.ui.util.ContentAlpha import com.xinto.opencord.ui.util.ProvideContentAlpha @Composable -fun CurrentUserContent( +fun HomeUserPanelLayout( avatar: @Composable () -> Unit, username: @Composable () -> Unit, discriminator: @Composable () -> Unit, diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/currentuser/CurrentUserLoaded.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/state/CurrentUserLoaded.kt similarity index 93% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/currentuser/CurrentUserLoaded.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/state/CurrentUserLoaded.kt index 026d65af..f15e66b9 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/currentuser/CurrentUserLoaded.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/state/CurrentUserLoaded.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.screens.home.panels.currentuser +package com.xinto.opencord.ui.screens.home.panels.user import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -17,6 +17,7 @@ import com.xinto.opencord.domain.usersettings.DomainUserStatus import com.xinto.opencord.ui.components.OCBadgeBox import com.xinto.opencord.ui.components.OCImage import com.xinto.opencord.ui.components.indicator.UserStatusIcon +import com.xinto.opencord.ui.screens.home.panels.user.layout.HomeUserPanelLayout import com.xinto.opencord.ui.util.ifNotNullComposable @Composable @@ -29,7 +30,7 @@ fun CurrentUserLoaded( isStreaming: Boolean, customStatus: DomainCustomStatus?, ) { - CurrentUserContent( + HomeUserPanelLayout( avatar = { OCBadgeBox( badge = status.ifNotNullComposable { userStatus -> diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/currentuser/CurrentUserLoading.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/state/CurrentUserLoading.kt similarity index 93% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/currentuser/CurrentUserLoading.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/state/CurrentUserLoading.kt index 24ff3271..e22fb4ab 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/currentuser/CurrentUserLoading.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/state/CurrentUserLoading.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.screens.home.panels.currentuser +package com.xinto.opencord.ui.screens.home.panels.user import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -18,13 +18,14 @@ import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer import com.valentinilk.shimmer.shimmer import com.xinto.opencord.R +import com.xinto.opencord.ui.screens.home.panels.user.layout.HomeUserPanelLayout @Composable fun CurrentUserLoading( onSettingsClick: () -> Unit ) { val shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.View) - CurrentUserContent( + HomeUserPanelLayout( avatar = { Box( modifier = Modifier diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/login/LoginScreen.kt b/app/src/main/java/com/xinto/opencord/ui/screens/login/LoginScreen.kt index efe58791..fad9ff72 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/login/LoginScreen.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/login/LoginScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import com.xinto.opencord.R import com.xinto.opencord.ui.components.captcha.HCaptcha -import com.xinto.opencord.ui.viewmodel.LoginViewModel import org.koin.androidx.compose.getViewModel @Composable diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/LoginViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/login/LoginViewModel.kt similarity index 99% rename from app/src/main/java/com/xinto/opencord/ui/viewmodel/LoginViewModel.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/login/LoginViewModel.kt index 7fa097e1..0f4e9d99 100644 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/LoginViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/login/LoginViewModel.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.viewmodel +package com.xinto.opencord.ui.screens.login import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsScreen.kt b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsScreen.kt index e3910ef0..4b87762d 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsScreen.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsScreen.kt @@ -44,7 +44,6 @@ import com.xinto.opencord.ui.components.message.reply.MessageReferencedAuthor import com.xinto.opencord.ui.components.message.reply.MessageReferencedContent import com.xinto.opencord.ui.screens.home.panels.messagemenu.MessageMenu import com.xinto.opencord.ui.util.* -import com.xinto.opencord.ui.viewmodel.MentionsViewModel import kotlinx.coroutines.launch import org.koin.androidx.compose.getViewModel diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/MentionsViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsViewModel.kt similarity index 97% rename from app/src/main/java/com/xinto/opencord/ui/viewmodel/MentionsViewModel.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsViewModel.kt index 75d72d7c..4ff78f50 100644 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/MentionsViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsViewModel.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.viewmodel +package com.xinto.opencord.ui.screens.mentions import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -12,7 +12,7 @@ import com.xinto.opencord.manager.PersistentDataManager import com.xinto.opencord.manager.ToastManager import com.xinto.opencord.rest.service.DiscordApiService import com.xinto.opencord.store.GuildStore -import com.xinto.opencord.ui.viewmodel.base.BasePersistenceViewModel +import com.xinto.opencord.ui.viewmodel.BasePersistenceViewModel import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreen.kt b/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreen.kt index 2538df2a..4c86b741 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreen.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -16,26 +17,47 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.xinto.opencord.R +import com.xinto.opencord.domain.message.DomainMessage import com.xinto.opencord.ui.navigation.PinsScreenData +import com.xinto.opencord.ui.screens.pins.state.PinsScreenError +import com.xinto.opencord.ui.screens.pins.state.PinsScreenLoaded +import com.xinto.opencord.ui.screens.pins.state.PinsScreenLoading import com.xinto.opencord.ui.util.ContentAlpha import com.xinto.opencord.ui.util.VoidablePaddingValues import com.xinto.opencord.ui.util.paddingOrGestureNav import com.xinto.opencord.ui.util.toUnsafeImmutableList -import com.xinto.opencord.ui.viewmodel.ChannelPinsViewModel import org.koin.androidx.compose.getViewModel +import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf @Composable fun PinsScreen( data: PinsScreenData, + modifier: Modifier, + onBackClick: () -> Unit, +) { + val viewModel: PinsScreenViewModel = koinViewModel { parametersOf(data) } + PinsScreen( + onBackClick = onBackClick, + state = viewModel.state, + modifier = modifier, + messages = viewModel.messages, + channelName = viewModel.channelName + ) +} + +@Composable +fun PinsScreen( + state: PinsScreenState, onBackClick: () -> Unit, modifier: Modifier = Modifier, - viewModel: ChannelPinsViewModel = getViewModel { parametersOf(data) } + messages: SnapshotStateMap, + channelName: String?, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) - val pins by remember(viewModel.messages) { + val pins by remember(messages) { derivedStateOf { - viewModel.messages.values + messages.values .sortedByDescending { it.timestamp } .toUnsafeImmutableList() } @@ -50,9 +72,9 @@ fun PinsScreen( Column { Text(stringResource(R.string.pins_title)) - if (viewModel.channelName != null) { + if (channelName != null) { Text( - text = "#${viewModel.channelName}", + text = "#${channelName}", style = MaterialTheme.typography.bodyMedium, modifier = Modifier .alpha(ContentAlpha.medium) @@ -74,15 +96,15 @@ fun PinsScreen( ) }, ) { paddingValues -> - when (viewModel.state) { - is ChannelPinsViewModel.State.Loading -> { + when (state) { + is PinsScreenState.Loading -> { PinsScreenLoading( modifier = Modifier .fillMaxSize() .paddingOrGestureNav(paddingValues), ) } - is ChannelPinsViewModel.State.Loaded -> { + is PinsScreenState.Loaded -> { PinsScreenLoaded( pins = pins, contentPadding = VoidablePaddingValues(paddingValues, top = false), @@ -91,7 +113,7 @@ fun PinsScreen( .paddingOrGestureNav(paddingValues), ) } - is ChannelPinsViewModel.State.Error -> { + is PinsScreenState.Error -> { PinsScreenError( modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreenState.kt b/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreenState.kt new file mode 100644 index 00000000..6dcb000a --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreenState.kt @@ -0,0 +1,7 @@ +package com.xinto.opencord.ui.screens.pins + +sealed interface PinsScreenState { + object Loading : PinsScreenState + object Loaded : PinsScreenState + object Error : PinsScreenState +} \ No newline at end of file diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChannelPinsViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreenViewModel.kt similarity index 83% rename from app/src/main/java/com/xinto/opencord/ui/viewmodel/ChannelPinsViewModel.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreenViewModel.kt index 563d5401..91954d68 100644 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChannelPinsViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreenViewModel.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.viewmodel +package com.xinto.opencord.ui.screens.pins import androidx.compose.runtime.* import androidx.lifecycle.ViewModel @@ -12,18 +12,13 @@ import com.xinto.opencord.util.collectIn import kotlinx.coroutines.launch @Stable -class ChannelPinsViewModel( - val data: PinsScreenData, +class PinsScreenViewModel( + private val data: PinsScreenData, messageStore: MessageStore, channelStore: ChannelStore, ) : ViewModel() { - sealed interface State { - object Loading : State - object Loaded : State - object Error : State - } - var state by mutableStateOf(State.Loading) + var state by mutableStateOf(PinsScreenState.Loading) val messages = mutableStateMapOf() var channelName by mutableStateOf(null) @@ -32,7 +27,7 @@ class ChannelPinsViewModel( init { viewModelScope.launch { try { - state = State.Loading + state = PinsScreenState.Loading val channel = channelStore.fetchChannel(data.channelId) ?: error("Channel not cached for pins screen") @@ -42,7 +37,7 @@ class ChannelPinsViewModel( val fetchedMessages = messageStore.fetchPinnedMessages(data.channelId) messages.putAll(fetchedMessages.map { it.id to it }) - state = State.Loaded + state = PinsScreenState.Loaded } catch (e: Exception) { e.printStackTrace() } diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreenError.kt b/app/src/main/java/com/xinto/opencord/ui/screens/pins/state/PinsScreenError.kt similarity index 96% rename from app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreenError.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/pins/state/PinsScreenError.kt index 4e461fa4..25bbb540 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreenError.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/pins/state/PinsScreenError.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.screens.pins +package com.xinto.opencord.ui.screens.pins.state import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreenLoaded.kt b/app/src/main/java/com/xinto/opencord/ui/screens/pins/state/PinsScreenLoaded.kt similarity index 99% rename from app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreenLoaded.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/pins/state/PinsScreenLoaded.kt index fc8fa326..96a8385c 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreenLoaded.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/pins/state/PinsScreenLoaded.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.screens.pins +package com.xinto.opencord.ui.screens.pins.state import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreenLoading.kt b/app/src/main/java/com/xinto/opencord/ui/screens/pins/state/PinsScreenLoading.kt similarity index 89% rename from app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreenLoading.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/pins/state/PinsScreenLoading.kt index 6617e8c8..15cb1eea 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreenLoading.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/pins/state/PinsScreenLoading.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.screens.pins +package com.xinto.opencord.ui.screens.pins.state import androidx.compose.foundation.layout.Box import androidx.compose.material3.CircularProgressIndicator diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/base/BasePersistenceViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/viewmodel/BasePersistenceViewModel.kt similarity index 95% rename from app/src/main/java/com/xinto/opencord/ui/viewmodel/base/BasePersistenceViewModel.kt rename to app/src/main/java/com/xinto/opencord/ui/viewmodel/BasePersistenceViewModel.kt index 22083a39..d1a25343 100644 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/base/BasePersistenceViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/viewmodel/BasePersistenceViewModel.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.viewmodel.base +package com.xinto.opencord.ui.viewmodel import androidx.lifecycle.ViewModel import com.xinto.opencord.manager.PersistentDataManager diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatInputViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatInputViewModel.kt index 801f4a5e..f7a5aa3b 100644 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatInputViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatInputViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope import com.xinto.opencord.manager.PersistentDataManager import com.xinto.opencord.rest.body.MessageBody import com.xinto.opencord.rest.service.DiscordApiService -import com.xinto.opencord.ui.viewmodel.base.BasePersistenceViewModel import com.xinto.opencord.util.throttle import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatViewModel.kt deleted file mode 100644 index 539d99e1..00000000 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatViewModel.kt +++ /dev/null @@ -1,244 +0,0 @@ -package com.xinto.opencord.ui.viewmodel - -import androidx.compose.runtime.* -import androidx.lifecycle.viewModelScope -import com.xinto.opencord.domain.emoji.DomainEmoji -import com.xinto.opencord.domain.emoji.DomainEmojiIdentifier -import com.xinto.opencord.domain.message.DomainMessage -import com.xinto.opencord.domain.message.DomainMessageRegular -import com.xinto.opencord.manager.PersistentDataManager -import com.xinto.opencord.rest.service.DiscordApiService -import com.xinto.opencord.store.* -import com.xinto.opencord.ui.viewmodel.base.BasePersistenceViewModel -import com.xinto.opencord.util.collectIn -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@Stable -class ChatViewModel( - private val messageStore: MessageStore, - private val reactionStore: ReactionStore, - private val channelStore: ChannelStore, - private val currentUserStore: CurrentUserStore, - private val api: DiscordApiService, - private val persistentDataManager: PersistentDataManager, -) : BasePersistenceViewModel(persistentDataManager) { - sealed interface State { - object Unselected : State - object Loading : State - object Loaded : State - object Error : State - } - - @Stable - class ReactionState( - val emoji: DomainEmoji, - val reactionOrder: Long, - meReacted: Boolean, - count: Int, - ) { - var meReacted by mutableStateOf(meReacted) - var count by mutableStateOf(count) - } - - @Stable - inner class MessageItem( - message: DomainMessage, - reactions: List? = null, - topMerged: Boolean = false, - ) { - var topMerged by mutableStateOf(topMerged) - var bottomMerged by mutableStateOf(false) - var message by mutableStateOf(message) - var reactions = mutableStateMapOf() - .apply { reactions?.let { putAll(it.map { r -> r.emoji.identifier to r }) } } - - val meMentioned by derivedStateOf { - when { - message !is DomainMessageRegular -> false - message.mentionEveryone -> true - message.mentions.any { it.id == currentUserId } -> true - else -> false - } - } - } - - var state by mutableStateOf(State.Unselected) - private set - - var channelName by mutableStateOf("") - private set - var currentUserId by mutableStateOf(null) - private set - - // Reverse sorted (decreasing) message list - val sortedMessages = mutableStateListOf() - - private fun getMessageItemIndex(messageId: Long): Int? { - return sortedMessages - .binarySearch { messageId compareTo it.message.id } - .takeIf { it >= 0 } - } - - fun load() { - val channelId = persistentDataManager.persistentChannelId - if (channelId <= 0L || persistentDataManager.persistentGuildId <= 0L) return - - viewModelScope.coroutineContext.cancelChildren() - - state = State.Loading - viewModelScope.launch(Dispatchers.IO) { - try { - val channel = channelStore.fetchChannel(channelId) - ?: throw Error("Failed to load channel $channelId") - val messages = messageStore.fetchMessages(channelId) - .sortedByDescending { it.id } - - val messageItems = messages.map { - val reactions = reactionStore.getReactions(it.id).mapIndexed { i, reaction -> - ReactionState( - reactionOrder = i.toLong(), - emoji = reaction.emoji, - count = reaction.count, - meReacted = reaction.meReacted, - ) - } - - MessageItem( - message = it, - reactions = reactions, - ) - } - - for (i in 0 until (messageItems.size - 1)) { - val curMessage = messageItems[i] - val prevMessage = messageItems[i + 1] - - val canMerge = canMessagesMerge(curMessage.message, prevMessage.message) - curMessage.topMerged = canMerge - prevMessage.bottomMerged = canMerge - } - - withContext(Dispatchers.Main) { - channelName = channel.name - - sortedMessages.clear() - sortedMessages.addAll(messageItems) - - state = State.Loaded - } - } catch (t: Throwable) { - t.printStackTrace() - - withContext(Dispatchers.Main) { - state = State.Error - } - } - } - - messageStore.observeChannel(channelId).collectIn(viewModelScope) { event -> - event.fold( - onAdd = { msg -> - val topMessage = sortedMessages.getOrNull(0) - val canMerge = canMessagesMerge(msg, topMessage?.message) - - sortedMessages.getOrNull(0)?.bottomMerged = canMerge - sortedMessages.add(0, MessageItem(msg, topMerged = canMerge)) - }, - onUpdate = { msg -> - val i = getMessageItemIndex(msg.id) - ?: return@fold - - val topMessage = sortedMessages.getOrNull(i + 1) - val bottomMessage = sortedMessages.getOrNull(i - 1) - val canMerge = canMessagesMerge(bottomMessage?.message, topMessage?.message) - - topMessage?.bottomMerged = canMerge - bottomMessage?.topMerged = canMerge - sortedMessages.getOrNull(i)?.message = msg - }, - onDelete = { data -> - val i = getMessageItemIndex(data.messageId.value) - ?: return@fold - - val topMessage = sortedMessages.getOrNull(i + 1) - val bottomMessage = sortedMessages.getOrNull(i - 1) - val canMerge = canMessagesMerge(bottomMessage?.message, topMessage?.message) - - topMessage?.bottomMerged = canMerge - bottomMessage?.topMerged = canMerge - sortedMessages.removeAt(i) - }, - ) - } - - reactionStore.observeChannel(channelId).collectIn(viewModelScope) { event -> - event.fold( - onAdd = { /* onAdd doesn't exist */ }, - onUpdate = { data -> - val reactions = getMessageItemIndex(data.messageId) - ?.let { sortedMessages.getOrNull(it)?.reactions } - ?: return@fold - - reactions.compute(data.emoji.identifier) { _, state -> - val newCount = data.countDiff + (state?.count ?: 0) - if (newCount <= 0) return@compute null - - state?.apply { - count = newCount - data.meReacted?.let { meReacted = it } - } ?: ReactionState( - reactionOrder = System.currentTimeMillis(), - emoji = data.emoji, - count = newCount, - meReacted = data.meReacted ?: false, - ) - } - }, - onDelete = { data -> - getMessageItemIndex(data.messageId) - ?.let { sortedMessages.getOrNull(it)?.reactions?.clear() } - }, - ) - } - } - - fun reactToMessage(messageId: Long, emoji: DomainEmoji) { - viewModelScope.launch { - val meReacted = getMessageItemIndex(messageId) - ?.let { sortedMessages.getOrNull(it)?.reactions?.get(emoji.identifier)?.meReacted } - ?: false - - if (meReacted) { - api.removeMeReaction(persistentChannelId, messageId, emoji) - } else { - api.addMeReaction(persistentChannelId, messageId, emoji) - } - } - } - - init { - if (persistentGuildId != 0L && persistentChannelId != 0L) { - load() - } - - currentUserStore.observeCurrentUser().collectIn(viewModelScope) { user -> - currentUserId = user.id - } - } - - private fun canMessagesMerge(message: DomainMessage?, prevMessage: DomainMessage?): Boolean { - return message != null && prevMessage != null - && message.author.id == prevMessage.author.id - && prevMessage is DomainMessageRegular - && message is DomainMessageRegular - && !message.isReply - && (message.timestamp - prevMessage.timestamp).inWholeMinutes < 1 - && message.attachments.isEmpty() - && prevMessage.attachments.isEmpty() - && message.embeds.isEmpty() - && prevMessage.embeds.isEmpty() - } -} diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/GuildsViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/viewmodel/GuildsViewModel.kt deleted file mode 100644 index 53b817f0..00000000 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/GuildsViewModel.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.xinto.opencord.ui.viewmodel - -import androidx.compose.runtime.* -import androidx.lifecycle.viewModelScope -import com.xinto.opencord.domain.guild.DomainGuild -import com.xinto.opencord.manager.PersistentDataManager -import com.xinto.opencord.store.GuildStore -import com.xinto.opencord.store.fold -import com.xinto.opencord.ui.viewmodel.base.BasePersistenceViewModel -import com.xinto.opencord.util.collectIn -import com.xinto.opencord.util.removeFirst -import kotlinx.coroutines.launch - -@Stable -class GuildsViewModel( - guildStore: GuildStore, - persistentDataManager: PersistentDataManager, -) : BasePersistenceViewModel(persistentDataManager) { - var state by mutableStateOf(State.Loading, referentialEqualityPolicy()) - private set - - // List of all items to display (not just guilds) - val listItems = mutableStateListOf() - - // Dual reference to all Guild items in listItems for performance - private val guildItemRefs = mutableMapOf() - - fun selectGuild(guildId: Long) { - persistentGuildId = guildId - - guildItemRefs.values - .find { it.selected } - ?.selected = false - - guildItemRefs[guildId]?.selected = true - } - - init { - viewModelScope.launch { - val guilds = guildStore.fetchGuilds() // TODO: don't do anything w/o cache (keep Loading state) - val items = guilds.map { - GuildItem.Guild( - guild = it, - selected = persistentGuildId == it.id, - ) - } - - guildItemRefs.putAll(items.asSequence().map { it.data.id to it }) - listItems.add(GuildItem.Header) - listItems.add(GuildItem.Divider) - listItems.addAll(items) - state = State.Loaded - } - - guildStore.observeGuilds().collectIn(viewModelScope) { event -> - event.fold( - onAdd = { - val existingItem = guildItemRefs[it.id] - - if (existingItem != null) { - existingItem.data = it - } else { - val item = GuildItem.Guild(it) - listItems.add(item) - guildItemRefs[it.id] = item - } - }, - onUpdate = { guildItemRefs[it.id]?.data = it }, - onDelete = { guildId -> - val item = guildItemRefs.remove(guildId) - ?: return@fold - - // Guaranteed if item retrieved - listItems.removeFirst { it === item } - }, - ) - - state = State.Loaded // Will have an effect on no-cache app load - } - } - - sealed interface State { - object Loading : State - object Loaded : State - object Error : State - } - - @Stable - sealed interface GuildItem { - val key: Any - - @Immutable - object Header : GuildItem { - override val key get() = "HEADER" - } - - @Immutable - object Divider : GuildItem { - override val key get() = "DIVIDER" - } - - @Stable - class Guild( - guild: DomainGuild, - selected: Boolean = false, - ) : GuildItem { - var data by mutableStateOf(guild, neverEqualPolicy()) - var selected by mutableStateOf(selected) - - override val key get() = data.id - } - } -} diff --git a/gradle.properties b/gradle.properties index 51e4cfda..a28dec27 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,3 +20,6 @@ kotlin.code.style=official # Enable R8 full mode. android.enableR8.fullMode=true +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 43a59241..7452cf70 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ androidx-media3 = "1.0.0" materii-panels = "1.0.1" materii-partial = "1.1.0" materii-enumutil = "1.0.0" -agp = "7.4.1" +agp = "8.0.0" kotlin = "1.8.10" ksp = "1.8.10-1.0.9" @@ -17,6 +17,7 @@ ksp = "1.8.10-1.0.9" androidx-core = { group = "androidx.core", name = "core-ktx", version = "1.9.0" } androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version = "1.0.0" } androidx-preferences = { group = "androidx.preference", name = "preference", version = "1.2.0" } +androidx-datastore = { group = "androidx.datastore", name = "datastore-preferences", version = "1.0.0"} androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } @@ -54,7 +55,7 @@ ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version ktor-serialization-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } [bundles] -androidx-core = ["androidx-core", "androidx-core-splashscreen", "androidx-preferences"] +androidx-core = ["androidx-core", "androidx-core-splashscreen", "androidx-datastore"] androidx-room = ["androidx-room-ktx", "androidx-room-runtime"] androidx-compose = ["androidx-compose-activity", "androidx-compose-foundation", "androidx-compose-material", "androidx-compose-material3"] androidx-paging = ["androidx-paging-runtime", "androidx-paging-compose"] From d342a772b3d84ec1261c91a449e032c40f35e8df Mon Sep 17 00:00:00 2001 From: Xinto Date: Fri, 14 Apr 2023 21:48:24 +0400 Subject: [PATCH 2/5] merge chat input viewmodel with the chat viewmodel --- .../com/xinto/opencord/di/ViewModelModule.kt | 1 - .../screens/home/panels/chat/HomeChatPanel.kt | 11 ++- .../panels/chat/HomeChatPanelViewModel.kt | 28 ++++++ .../home/panels/chat/component/ChatInput.kt | 97 ++++++------------- .../ui/viewmodel/ChatInputViewModel.kt | 52 ---------- 5 files changed, 68 insertions(+), 121 deletions(-) delete mode 100644 app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatInputViewModel.kt diff --git a/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt b/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt index 4548a1c6..939844ea 100644 --- a/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt +++ b/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt @@ -21,5 +21,4 @@ val viewModelModule = module { viewModelOf(::HomeUserPanelViewModel) viewModelOf(::MessageMenuViewModel) viewModelOf(::MentionsViewModel) - viewModelOf(::ChatInputViewModel) } diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanel.kt index 1d093ad3..cec18778 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanel.kt @@ -29,7 +29,10 @@ fun HomeChatPanel(modifier: Modifier = Modifier) { onMessageReact = viewModel::reactToMessage, onPinsButtonClick = { /*TODO*/ }, onMembersButtonClick = { /*TODO*/ }, - onChannelsButtonClick = { /*TODO*/ } + onChannelsButtonClick = { /*TODO*/ }, + inputText = viewModel.inputText, + onInputTextChange = viewModel::updateInputText, + onInputTextSend = viewModel::sendMessage ) } @@ -42,6 +45,9 @@ fun HomeChatPanel( onPinsButtonClick: () -> Unit, onMembersButtonClick: () -> Unit, onChannelsButtonClick: () -> Unit, + inputText: String, + onInputTextChange: (String) -> Unit, + onInputTextSend: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -136,6 +142,9 @@ fun HomeChatPanel( hint = { Text(stringResource(R.string.chat_input_hint, channelName)) }, + text = inputText, + onTextChange = onInputTextChange, + onSend = onInputTextSend, ) } } diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelViewModel.kt index c897ed9e..bd3389aa 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelViewModel.kt @@ -6,11 +6,13 @@ import androidx.lifecycle.viewModelScope import com.xinto.opencord.domain.emoji.DomainEmoji import com.xinto.opencord.domain.message.DomainMessage import com.xinto.opencord.domain.message.DomainMessageRegular +import com.xinto.opencord.rest.body.MessageBody import com.xinto.opencord.rest.service.DiscordApiService import com.xinto.opencord.store.* import com.xinto.opencord.ui.screens.home.panels.chat.model.MessageItem import com.xinto.opencord.ui.screens.home.panels.chat.model.ReactionState import com.xinto.opencord.util.collectIn +import com.xinto.opencord.util.throttle import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.flattenMerge import kotlinx.coroutines.flow.last @@ -39,6 +41,13 @@ class HomeChatPanelViewModel( // Reverse sorted (decreasing) message list val sortedMessages = mutableStateListOf() + var inputText by mutableStateOf("") + private set + + private val startTyping = throttle(9500, viewModelScope) { + api.startTyping(persistentDataStore.observeCurrentChannel().last()) + } + private fun getMessageItemIndex(messageId: Long): Int? { return sortedMessages .binarySearch { messageId compareTo it.message.id } @@ -60,6 +69,25 @@ class HomeChatPanelViewModel( } } + fun updateInputText(input: String) { + inputText = input + startTyping() + } + + fun sendMessage() { + val message = MessageBody( + content = inputText, + ) + + viewModelScope.launch { + val channelId = persistentDataStore.observeCurrentChannel().last() + api.postChannelMessage( + channelId = channelId, + body = message, + ) + } + } + init { persistentDataStore.observeCurrentChannel() .collectIn(viewModelScope) { channelId -> diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/ChatInput.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/ChatInput.kt index fea03fe8..be927e41 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/ChatInput.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/ChatInput.kt @@ -12,18 +12,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.xinto.opencord.ui.components.OCBasicTextField -import com.xinto.opencord.ui.viewmodel.ChatInputViewModel -import org.koin.androidx.compose.getViewModel @Composable fun HomeChatPanelInput( modifier: Modifier = Modifier, hint: @Composable () -> Unit, - viewModel: ChatInputViewModel = getViewModel(), + text: String, + onTextChange: (String) -> Unit, + onSend: () -> Unit, ) { - val isEmpty by remember { derivedStateOf { viewModel.pendingContent.isEmpty() } } - val sendEnabled by remember { derivedStateOf { !isEmpty && viewModel.sendEnabled } } - Row( modifier = modifier .fillMaxWidth() @@ -33,73 +30,39 @@ fun HomeChatPanelInput( ) { OCBasicTextField( modifier = Modifier.weight(1f), - value = viewModel.pendingContent, - onValueChange = viewModel::setPendingMessage, + value = text, + onValueChange = onTextChange, maxLines = 7, decorationBox = { innerTextField -> - InputInnerTextField( - isEmpty = isEmpty, - hint = hint, - innerTextField = innerTextField, - ) + Surface(shape = MaterialTheme.shapes.large) { + Box( + modifier = Modifier + .padding(16.dp), + ) { + innerTextField() + CompositionLocalProvider( + LocalContentColor provides LocalContentColor.current.copy(alpha = 0.7f), + LocalTextStyle provides MaterialTheme.typography.bodyMedium, + ) { + if (text.isEmpty()) { + hint() + } + } + } + } }, ) - - AnimatedSendButton( - visible = sendEnabled, - enabled = viewModel.sendEnabled, - onClick = viewModel::sendMessage, - ) - } -} - -@Composable -private fun InputInnerTextField( - isEmpty: Boolean, - hint: @Composable () -> Unit, - innerTextField: @Composable () -> Unit, -) { - CompositionLocalProvider(LocalAbsoluteTonalElevation provides 0.dp) { - Surface( - shape = MaterialTheme.shapes.large, + AnimatedVisibility( + visible = text.isNotEmpty(), + enter = slideInHorizontally { it * 2 }, + exit = slideOutHorizontally { it * 2 }, ) { - Box( - modifier = Modifier - .padding(16.dp), - ) { - innerTextField() - CompositionLocalProvider( - LocalContentColor provides LocalContentColor.current.copy(alpha = 0.7f), - LocalTextStyle provides MaterialTheme.typography.bodyMedium, - ) { - if (isEmpty) { - hint() - } - } + FilledIconButton(onClick = onSend) { + Icon( + imageVector = Icons.Rounded.Send, + contentDescription = null, + ) } } } } - -@Composable -private fun AnimatedSendButton( - visible: Boolean, - enabled: Boolean, - onClick: () -> Unit, -) { - AnimatedVisibility( - visible = visible, - enter = slideInHorizontally { it * 2 }, - exit = slideOutHorizontally { it * 2 }, - ) { - FilledIconButton( - onClick = onClick, - enabled = enabled, - ) { - Icon( - imageVector = Icons.Rounded.Send, - contentDescription = null, - ) - } - } -} diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatInputViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatInputViewModel.kt deleted file mode 100644 index f7a5aa3b..00000000 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatInputViewModel.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.xinto.opencord.ui.viewmodel - -import androidx.compose.runtime.* -import androidx.lifecycle.viewModelScope -import com.xinto.opencord.manager.PersistentDataManager -import com.xinto.opencord.rest.body.MessageBody -import com.xinto.opencord.rest.service.DiscordApiService -import com.xinto.opencord.util.throttle -import kotlinx.coroutines.launch - -@Stable -class ChatInputViewModel( - private val api: DiscordApiService, - persistentDataManager: PersistentDataManager, -) : BasePersistenceViewModel(persistentDataManager) { - var sendEnabled by mutableStateOf(true) - private set - var pendingContent by mutableStateOf("", neverEqualPolicy()) - private set - - fun setPendingMessage(content: String) { - pendingContent = content - startTyping() - } - - fun sendMessage() { - val content = pendingContent - pendingContent = "" - - if (content.isBlank()) { - return - } - - sendEnabled = false - - val message = MessageBody( - content = content, - ) - - viewModelScope.launch { - api.postChannelMessage( - channelId = persistentChannelId, - body = message, - ) - sendEnabled = true - } - } - - private val startTyping = throttle(9500, viewModelScope) { - api.startTyping(persistentDataManager.persistentChannelId) - } -} From 583c44cddadfe64ba75a1b7b7e7fc859d35a78be Mon Sep 17 00:00:00 2001 From: Xinto Date: Fri, 14 Apr 2023 21:56:24 +0400 Subject: [PATCH 3/5] ditch base persistence viewmodel --- .../ui/screens/mentions/MentionsViewModel.kt | 29 ++++++++------- .../ui/viewmodel/BasePersistenceViewModel.kt | 36 ------------------- 2 files changed, 17 insertions(+), 48 deletions(-) delete mode 100644 app/src/main/java/com/xinto/opencord/ui/viewmodel/BasePersistenceViewModel.kt diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsViewModel.kt index 4ff78f50..93341a6a 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsViewModel.kt @@ -4,25 +4,25 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.* import com.xinto.opencord.domain.message.DomainMessage import com.xinto.opencord.domain.message.toDomain -import com.xinto.opencord.manager.PersistentDataManager import com.xinto.opencord.manager.ToastManager import com.xinto.opencord.rest.service.DiscordApiService import com.xinto.opencord.store.GuildStore -import com.xinto.opencord.ui.viewmodel.BasePersistenceViewModel +import com.xinto.opencord.store.PersistentDataStore +import com.xinto.opencord.util.collectIn import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.launch @Stable class MentionsViewModel( - persistentDataManager: PersistentDataManager, guilds: GuildStore, + persistentDataStore: PersistentDataStore, private val toasts: ToastManager, private val api: DiscordApiService, -) : BasePersistenceViewModel(persistentDataManager) { +) : ViewModel() { var includeRoles by mutableStateOf(true) private set var includeEveryone by mutableStateOf(true) @@ -35,6 +35,8 @@ class MentionsViewModel( var messages by mutableStateOf(emptyFlow>()) private set + private var guildId = 0L + fun toggleRoles() { includeRoles = !includeRoles initPager() @@ -46,7 +48,7 @@ class MentionsViewModel( } fun toggleCurrentServer() { - if (includeAllServers && persistentGuildId <= 0) { + if (includeAllServers && guildId <= 0) { toasts.showToast("No server currently selected!") } else { includeAllServers = !includeAllServers @@ -57,14 +59,17 @@ class MentionsViewModel( init { initPager() - if (persistentGuildId > 0) { - viewModelScope.launch { - val guild = guilds.fetchGuild(persistentGuildId) - ?: return@launch + persistentDataStore.observeCurrentGuild() + .collectIn(viewModelScope) { + guildId = it + + if (it <= 0) return@collectIn + + val guild = guilds.fetchGuild(guildId) + ?: return@collectIn currentGuildName = guild.name } - } } private fun initPager() { @@ -76,7 +81,7 @@ class MentionsViewModel( initialLoadSize = 25, ), pagingSourceFactory = { - val guildId = if (!includeAllServers) persistentGuildId else null + val guildId = if (!includeAllServers) guildId else null MentionsPagingSource(api, includeRoles, includeEveryone, guildId) }, ).flow.cachedIn(viewModelScope) diff --git a/app/src/main/java/com/xinto/opencord/ui/viewmodel/BasePersistenceViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/viewmodel/BasePersistenceViewModel.kt deleted file mode 100644 index d1a25343..00000000 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/BasePersistenceViewModel.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.xinto.opencord.ui.viewmodel - -import androidx.lifecycle.ViewModel -import com.xinto.opencord.manager.PersistentDataManager - -abstract class BasePersistenceViewModel( - private val persistentDataManager: PersistentDataManager -) : ViewModel() { - // TODO: migrate this to a store - - protected var persistentGuildId - get() = persistentDataManager.persistentGuildId - set(value) { - persistentDataManager.persistentGuildId = value - } - - protected var persistentChannelId - get() = persistentDataManager.persistentChannelId - set(value) { - persistentDataManager.persistentChannelId = value - } - - protected var persistentCollapsedCategories - get() = persistentDataManager.collapsedCategories - set(value) { - persistentDataManager.collapsedCategories = value - } - - protected fun addPersistentCollapseCategory(id: Long) { - persistentDataManager.addCollapsedCategory(id) - } - - protected fun removePersistentCollapseCategory(id: Long) { - persistentDataManager.removeCollapsedCategory(id) - } -} From db183d052b7b237220448d3ee59b144ab24b25a7 Mon Sep 17 00:00:00 2001 From: Xinto Date: Fri, 14 Apr 2023 21:57:07 +0400 Subject: [PATCH 4/5] update workflow --- .github/workflows/android.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 25a70315..4b6b7ad0 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -19,10 +19,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v2 with: - java-version: 11 + java-version: 17 distribution: 'zulu' cache: 'gradle' From 135952814f3e39ace0d2f5ab899e6e74ab8b04c2 Mon Sep 17 00:00:00 2001 From: Xinto Date: Sat, 22 Apr 2023 00:31:36 +0400 Subject: [PATCH 5/5] refactor more screens --- .../com/xinto/opencord/di/ViewModelModule.kt | 5 +- .../java/com/xinto/opencord/ui/AppActivity.kt | 12 +- .../opencord/ui/navigation/AppDestination.kt | 2 + .../opencord/ui/screens/SettingsScreen.kt | 13 + .../opencord/ui/screens/home/HomeScreen.kt | 31 ++ .../home/panels/channel/HomeChannelsPanel.kt | 1 + .../channel/state/ChannelsListLoaded.kt | 2 +- .../screens/home/panels/chat/HomeChatPanel.kt | 81 ++-- .../panels/chat/HomeChatPanelViewModel.kt | 7 + .../{ChatInput.kt => HomeChatPanelInput.kt} | 0 .../chat/component/HomeChatPanelTopBar.kt | 69 +++ .../home/panels/chat/state/ChatLoaded.kt | 4 +- .../{MessageMenu.kt => HomeMessageMenu.kt} | 4 +- ...el.kt => HomeMessageMenuPanelViewModel.kt} | 16 +- .../messagemenu/HomeMessageMenuPinState.kt | 7 + .../MessageMenuPreviewMessage.kt | 0 .../messagemenu/state/MessageMenuLoaded.kt | 9 +- .../user/{CurrentUser.kt => HomeUserPanel.kt} | 14 +- .../ui/screens/mentions/MentionsScreen.kt | 423 ++++-------------- .../ui/screens/mentions/MentionsViewModel.kt | 35 +- .../mentions/component/MentionsFilterMenu.kt | 106 +++++ .../component/MentionsScreenMessage.kt | 244 ++++++++++ .../mentions/component/MentionsTopBar.kt | 82 ++++ .../mentions/model/MentionsPagingSource.kt | 41 ++ .../opencord/ui/screens/pins/PinsScreen.kt | 55 +-- .../ui/screens/pins/component/PinsTopBar.kt | 56 +++ .../ui/screens/pins/state/PinsScreenLoaded.kt | 4 +- 27 files changed, 817 insertions(+), 506 deletions(-) rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/{ChatInput.kt => HomeChatPanelInput.kt} (100%) create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/HomeChatPanelTopBar.kt rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/{MessageMenu.kt => HomeMessageMenu.kt} (96%) rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/{MessageMenuViewModel.kt => HomeMessageMenuPanelViewModel.kt} (91%) create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/HomeMessageMenuPinState.kt rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/{ => component}/MessageMenuPreviewMessage.kt (100%) rename app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/{CurrentUser.kt => HomeUserPanel.kt} (85%) create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/mentions/component/MentionsFilterMenu.kt create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/mentions/component/MentionsScreenMessage.kt create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/mentions/component/MentionsTopBar.kt create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/mentions/model/MentionsPagingSource.kt create mode 100644 app/src/main/java/com/xinto/opencord/ui/screens/pins/component/PinsTopBar.kt diff --git a/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt b/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt index 939844ea..8def0e6e 100644 --- a/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt +++ b/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt @@ -3,12 +3,11 @@ package com.xinto.opencord.di import com.xinto.opencord.ui.screens.home.panels.channel.HomeChannelsPanelViewModel import com.xinto.opencord.ui.screens.home.panels.chat.HomeChatPanelViewModel import com.xinto.opencord.ui.screens.home.panels.guild.GuildsViewModel -import com.xinto.opencord.ui.screens.home.panels.messagemenu.MessageMenuViewModel +import com.xinto.opencord.ui.screens.home.panels.messagemenu.HomeMessageMenuPanelViewModel import com.xinto.opencord.ui.screens.home.panels.user.HomeUserPanelViewModel import com.xinto.opencord.ui.screens.login.LoginViewModel import com.xinto.opencord.ui.screens.mentions.MentionsViewModel import com.xinto.opencord.ui.screens.pins.PinsScreenViewModel -import com.xinto.opencord.ui.viewmodel.* import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.dsl.module @@ -19,6 +18,6 @@ val viewModelModule = module { viewModelOf(::HomeChannelsPanelViewModel) viewModelOf(::PinsScreenViewModel) viewModelOf(::HomeUserPanelViewModel) - viewModelOf(::MessageMenuViewModel) + viewModelOf(::HomeMessageMenuPanelViewModel) viewModelOf(::MentionsViewModel) } diff --git a/app/src/main/java/com/xinto/opencord/ui/AppActivity.kt b/app/src/main/java/com/xinto/opencord/ui/AppActivity.kt index f611c5c7..6bf7940d 100644 --- a/app/src/main/java/com/xinto/opencord/ui/AppActivity.kt +++ b/app/src/main/java/com/xinto/opencord/ui/AppActivity.kt @@ -87,25 +87,21 @@ class AppActivity : ComponentActivity() { modifier = Modifier .fillMaxSize() .imePadding(), - onPinsClick = { nav.navigate(AppDestination.Pins(data = it)) }, - onSettingsClick = { nav.navigate(AppDestination.Settings) }, - onMentionsClick = { nav.navigate(AppDestination.Mentions) }, - onFriendsClick = { /* TODO */ }, - onSearchClick = { /* TODO */ }, + navigator = nav, ) AppDestination.Settings -> Settings( modifier = Modifier .fillMaxSize() .imePadding(), - onBackClick = { nav.back() }, + navigator = nav, ) AppDestination.Mentions -> MentionsScreen( modifier = Modifier .fillMaxSize() .imePadding(), - onBackClick = { nav.back() }, + navigator = nav, ) is AppDestination.Pins -> PinsScreen( @@ -113,7 +109,7 @@ class AppActivity : ComponentActivity() { modifier = Modifier .fillMaxSize() .imePadding(), - onBackClick = { nav.back() }, + navigator = nav, ) } } diff --git a/app/src/main/java/com/xinto/opencord/ui/navigation/AppDestination.kt b/app/src/main/java/com/xinto/opencord/ui/navigation/AppDestination.kt index 1180b6de..3728ec44 100644 --- a/app/src/main/java/com/xinto/opencord/ui/navigation/AppDestination.kt +++ b/app/src/main/java/com/xinto/opencord/ui/navigation/AppDestination.kt @@ -9,6 +9,8 @@ import dev.olshevski.navigation.reimagined.pop import dev.olshevski.navigation.reimagined.replaceAll import kotlinx.parcelize.Parcelize +typealias AppNavigator = NavController + @Parcelize sealed interface AppDestination : Parcelable { @Parcelize diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/SettingsScreen.kt b/app/src/main/java/com/xinto/opencord/ui/screens/SettingsScreen.kt index 86964ccf..808c93b0 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/SettingsScreen.kt @@ -9,6 +9,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.xinto.opencord.R +import com.xinto.opencord.ui.navigation.AppNavigator +import dev.olshevski.navigation.reimagined.pop + +@Composable +fun Settings( + modifier: Modifier = Modifier, + navigator: AppNavigator +) { + Settings( + modifier = modifier, + onBackClick = { navigator.pop() } + ) +} @Composable fun Settings( diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/HomeScreen.kt index f3eb5b7d..f2e9d435 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/HomeScreen.kt @@ -24,6 +24,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import com.xinto.opencord.ui.navigation.AppDestination +import com.xinto.opencord.ui.navigation.AppNavigator import com.xinto.opencord.ui.navigation.PinsScreenData import com.xinto.opencord.ui.screens.home.panels.HomeNavButtons import com.xinto.opencord.ui.screens.home.panels.channel.HomeChannelsPanel @@ -32,10 +34,31 @@ import com.xinto.opencord.ui.screens.home.panels.guild.HomeGuildsPanel import com.xinto.opencord.ui.screens.home.panels.member.MembersList import com.xinto.opencord.ui.screens.home.panels.user.HomeUserPanel import com.xinto.opencord.ui.util.animateCornerBasedShapeAsState +import dev.olshevski.navigation.reimagined.navigate +import dev.olshevski.navigation.reimagined.popUpTo import io.github.materiiapps.panels.SwipePanels import io.github.materiiapps.panels.SwipePanelsValue import io.github.materiiapps.panels.rememberSwipePanelsState +@Composable +fun HomeScreen( + modifier: Modifier = Modifier, + navigator: AppNavigator, +) { + HomeScreen( + modifier = modifier, + onSettingsClick = { + navigator.navigate(AppDestination.Settings) + }, + onPinsClick = { + navigator.navigate(AppDestination.Pins(it)) + }, + onSearchClick = { /*TODO*/ }, + onMentionsClick = { navigator.navigate(AppDestination.Mentions) }, + onFriendsClick = { /*TODO*/ }, + ) +} + @Composable fun HomeScreen( onSettingsClick: () -> Unit, @@ -84,6 +107,7 @@ fun HomeScreen( modifier = Modifier .fillMaxWidth() .padding(start = 6.dp), + onSettingsClick = onSettingsClick ) } @@ -114,6 +138,13 @@ fun HomeScreen( modifier = Modifier .fillMaxSize() .clip(centerPanelShape), + onPinsButtonClick = onPinsClick, + onMembersButtonClick = { + panelState.openEnd() + }, + onChannelsButtonClick = { + panelState.openStart() + } ) }, diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/HomeChannelsPanel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/HomeChannelsPanel.kt index ff594744..5bba6485 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/HomeChannelsPanel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/HomeChannelsPanel.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.xinto.opencord.ui.screens.home.panels.channel.model.CategoryItemData import com.xinto.opencord.ui.screens.home.panels.channel.model.ChannelItemData +import com.xinto.opencord.ui.screens.home.panels.channel.state.ChannelsListLoaded import org.koin.androidx.compose.koinViewModel @Composable diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/state/ChannelsListLoaded.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/state/ChannelsListLoaded.kt index 8f0636a0..54bb9826 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/state/ChannelsListLoaded.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/state/ChannelsListLoaded.kt @@ -1,4 +1,4 @@ -package com.xinto.opencord.ui.screens.home.panels.channel +package com.xinto.opencord.ui.screens.home.panels.channel.state import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanel.kt index cec18778..a6f1c363 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanel.kt @@ -1,25 +1,34 @@ package com.xinto.opencord.ui.screens.home.panels.chat -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.xinto.opencord.R import com.xinto.opencord.domain.emoji.DomainEmoji -import com.xinto.opencord.ui.screens.home.panels.chat.state.ChatError -import com.xinto.opencord.ui.screens.home.panels.chat.state.ChatLoaded +import com.xinto.opencord.ui.navigation.PinsScreenData import com.xinto.opencord.ui.screens.home.panels.chat.component.HomeChatPanelInput +import com.xinto.opencord.ui.screens.home.panels.chat.component.HomeChatPanelTopBar import com.xinto.opencord.ui.screens.home.panels.chat.model.MessageItem +import com.xinto.opencord.ui.screens.home.panels.chat.state.ChatError +import com.xinto.opencord.ui.screens.home.panels.chat.state.ChatLoaded import org.koin.androidx.compose.koinViewModel @Composable -fun HomeChatPanel(modifier: Modifier = Modifier) { +fun HomeChatPanel( + modifier: Modifier = Modifier, + onPinsButtonClick: (PinsScreenData) -> Unit, + onMembersButtonClick: () -> Unit, + onChannelsButtonClick: () -> Unit, +) { val viewModel: HomeChatPanelViewModel = koinViewModel() HomeChatPanel( modifier = modifier, @@ -27,9 +36,11 @@ fun HomeChatPanel(modifier: Modifier = Modifier) { channelName = viewModel.channelName, messages = viewModel.sortedMessages, onMessageReact = viewModel::reactToMessage, - onPinsButtonClick = { /*TODO*/ }, - onMembersButtonClick = { /*TODO*/ }, - onChannelsButtonClick = { /*TODO*/ }, + onPinsButtonClick = { + onPinsButtonClick(PinsScreenData(viewModel.channelId)) + }, + onMembersButtonClick = onMembersButtonClick, + onChannelsButtonClick = onChannelsButtonClick, inputText = viewModel.inputText, onInputTextChange = viewModel::updateInputText, onInputTextSend = viewModel::sendMessage @@ -53,49 +64,11 @@ fun HomeChatPanel( Scaffold( modifier = modifier, topBar = { - TopAppBar( - title = { - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (channelName.isNotEmpty()) { - Icon( - painter = painterResource(R.drawable.ic_tag), - contentDescription = null, - modifier = Modifier, - ) - } - Text( - text = channelName, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleLarge, - ) - } - }, - navigationIcon = { - IconButton(onChannelsButtonClick) { - Icon( - painter = painterResource(R.drawable.ic_menu), - contentDescription = null, - ) - } - }, - actions = { - IconButton(onClick = onPinsButtonClick) { - Icon( - painter = painterResource(R.drawable.ic_pin), - contentDescription = null, - ) - } - IconButton(onMembersButtonClick) { - Icon( - painter = painterResource(R.drawable.ic_people), - contentDescription = null, - ) - } - }, + HomeChatPanelTopBar( + channelName = channelName, + onChannelsButtonClick = onChannelsButtonClick, + onPinsButtonClick = onPinsButtonClick, + onMembersButtonClick = onMembersButtonClick ) }, ) { paddingValues -> diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelViewModel.kt index bd3389aa..5a1a1763 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelViewModel.kt @@ -38,6 +38,9 @@ class HomeChatPanelViewModel( var currentUserId by mutableStateOf(null) private set + var channelId = 0L + private set + // Reverse sorted (decreasing) message list val sortedMessages = mutableStateListOf() @@ -213,6 +216,10 @@ class HomeChatPanelViewModel( ) } + persistentDataStore.observeCurrentChannel().collectIn(viewModelScope) { + channelId = it + } + currentUserStore.observeCurrentUser().collectIn(viewModelScope) { user -> currentUserId = user.id } diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/ChatInput.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/HomeChatPanelInput.kt similarity index 100% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/ChatInput.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/HomeChatPanelInput.kt diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/HomeChatPanelTopBar.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/HomeChatPanelTopBar.kt new file mode 100644 index 00000000..90bbfd6d --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/HomeChatPanelTopBar.kt @@ -0,0 +1,69 @@ +package com.xinto.opencord.ui.screens.home.panels.chat.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.xinto.opencord.R + +@Composable +fun HomeChatPanelTopBar( + channelName: String, + onChannelsButtonClick: () -> Unit, + onPinsButtonClick: () -> Unit, + onMembersButtonClick: () -> Unit +) { + TopAppBar( + title = { + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (channelName.isNotEmpty()) { + Icon( + painter = painterResource(R.drawable.ic_tag), + contentDescription = null, + modifier = Modifier, + ) + } + Text( + text = channelName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleLarge, + ) + } + }, + navigationIcon = { + IconButton(onChannelsButtonClick) { + Icon( + painter = painterResource(R.drawable.ic_menu), + contentDescription = null, + ) + } + }, + actions = { + IconButton(onClick = onPinsButtonClick) { + Icon( + painter = painterResource(R.drawable.ic_pin), + contentDescription = null, + ) + } + IconButton(onMembersButtonClick) { + Icon( + painter = painterResource(R.drawable.ic_people), + contentDescription = null, + ) + } + }, + ) +} diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/state/ChatLoaded.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/state/ChatLoaded.kt index 240db2e3..0390ff0d 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/state/ChatLoaded.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/state/ChatLoaded.kt @@ -34,7 +34,7 @@ import com.xinto.opencord.ui.components.message.reply.MessageReferenced import com.xinto.opencord.ui.components.message.reply.MessageReferencedAuthor import com.xinto.opencord.ui.components.message.reply.MessageReferencedContent import com.xinto.opencord.ui.screens.home.panels.chat.model.MessageItem -import com.xinto.opencord.ui.screens.home.panels.messagemenu.MessageMenu +import com.xinto.opencord.ui.screens.home.panels.messagemenu.HomeMessageMenu import com.xinto.opencord.ui.util.ifComposable import com.xinto.opencord.ui.util.ifNotEmptyComposable import com.xinto.opencord.ui.util.ifNotNullComposable @@ -57,7 +57,7 @@ fun ChatLoaded( } if (messageMenuTarget != null) { - MessageMenu( + HomeMessageMenu( messageId = messageMenuTarget!!, onDismiss = { messageMenuTarget = null }, ) diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenu.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/HomeMessageMenu.kt similarity index 96% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenu.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/HomeMessageMenu.kt index b5c4e188..b2e11150 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenu.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/HomeMessageMenu.kt @@ -10,11 +10,11 @@ import kotlinx.coroutines.launch import org.koin.core.parameter.parametersOf @Composable -fun MessageMenu( +fun HomeMessageMenu( messageId: Long, onDismiss: (() -> Unit)? = null, sheetState: SheetState = rememberModalBottomSheetState(), - viewModel: MessageMenuViewModel = + viewModel: HomeMessageMenuPanelViewModel = getLocalViewModel(parameters = { parametersOf(messageId) }), ) { val coroutineScope = rememberCoroutineScope() diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenuViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/HomeMessageMenuPanelViewModel.kt similarity index 91% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenuViewModel.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/HomeMessageMenuPanelViewModel.kt index 341f6f76..29ec2b98 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenuViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/HomeMessageMenuPanelViewModel.kt @@ -21,7 +21,7 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch @Stable -class MessageMenuViewModel( +class HomeMessageMenuPanelViewModel( messageId: Long, private val messages: MessageStore, private val currentUserStore: CurrentUserStore, @@ -30,12 +30,6 @@ class MessageMenuViewModel( private val toasts: ToastManager, ) : ViewModel() { - sealed interface PinState { - object Pinnable : PinState - object Unpinnable : PinState - object None : PinState - } - var state by mutableStateOf(HomeMessageMenuPanelState.Loading) private set @@ -45,7 +39,7 @@ class MessageMenuViewModel( private set var isDeletable by mutableStateOf(false) private set - var pinState by mutableStateOf(PinState.None) + var pinState by mutableStateOf(HomeMessageMenuPinState.None) private set val frequentReactions = mutableListOf() @@ -111,10 +105,10 @@ class MessageMenuViewModel( ) // TODO: message permissions - pinState = PinState.None + pinState = HomeMessageMenuPinState.None isDeletable = currentUser.id == message.author.id isEditable = currentUser.id == message.author.id - this@MessageMenuViewModel.message = message + this@HomeMessageMenuPanelViewModel.message = message state = HomeMessageMenuPanelState.Loaded } @@ -129,7 +123,7 @@ class MessageMenuViewModel( currentUserStore.observeCurrentUser().collectIn(viewModelScope) { user -> if (message == null) return@collectIn - pinState = PinState.None + pinState = HomeMessageMenuPinState.None isDeletable = user.id == message?.author?.id isEditable = user.id == message?.author?.id } diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/HomeMessageMenuPinState.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/HomeMessageMenuPinState.kt new file mode 100644 index 00000000..03ab30bd --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/HomeMessageMenuPinState.kt @@ -0,0 +1,7 @@ +package com.xinto.opencord.ui.screens.home.panels.messagemenu + +sealed interface HomeMessageMenuPinState { + object Pinnable : HomeMessageMenuPinState + object Unpinnable : HomeMessageMenuPinState + object None : HomeMessageMenuPinState +} diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenuPreviewMessage.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/component/MessageMenuPreviewMessage.kt similarity index 100% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/MessageMenuPreviewMessage.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/component/MessageMenuPreviewMessage.kt diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/state/MessageMenuLoaded.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/state/MessageMenuLoaded.kt index b3426c9a..087f8da6 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/state/MessageMenuLoaded.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/state/MessageMenuLoaded.kt @@ -17,12 +17,13 @@ import com.xinto.opencord.domain.emoji.DomainUnknownEmoji import com.xinto.opencord.ui.components.OCImage import com.xinto.opencord.ui.components.OCSize import com.xinto.opencord.ui.screens.home.panels.messagemenu.MessageMenuPreviewMessage -import com.xinto.opencord.ui.screens.home.panels.messagemenu.MessageMenuViewModel +import com.xinto.opencord.ui.screens.home.panels.messagemenu.HomeMessageMenuPanelViewModel +import com.xinto.opencord.ui.screens.home.panels.messagemenu.HomeMessageMenuPinState import com.xinto.opencord.util.Quad @Composable fun MessageMenuLoaded( - viewModel: MessageMenuViewModel, + viewModel: HomeMessageMenuPanelViewModel, ) { Column( verticalArrangement = Arrangement.spacedBy(2.dp), @@ -119,8 +120,8 @@ fun MessageMenuLoaded( Quad("Copy Message Link", R.drawable.ic_link, viewModel::onCopyLink, true), Quad("Copy Message", R.drawable.ic_file_copy, viewModel::onCopyMessage, true), Quad("Mark Unread", R.drawable.ic_mark_unread, viewModel::onMarkUnread, true), - Quad("Pin", R.drawable.ic_pin, viewModel::togglePinned, viewModel.pinState == MessageMenuViewModel.PinState.Pinnable), - Quad("Unpin", R.drawable.ic_pin, viewModel::togglePinned, viewModel.pinState == MessageMenuViewModel.PinState.Unpinnable), + Quad("Pin", R.drawable.ic_pin, viewModel::togglePinned, viewModel.pinState == HomeMessageMenuPinState.Pinnable), + Quad("Unpin", R.drawable.ic_pin, viewModel::togglePinned, viewModel.pinState == HomeMessageMenuPinState.Unpinnable), Quad("Copy ID", R.drawable.ic_file_copy, viewModel::onCopyId, true), ) } diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/CurrentUser.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/HomeUserPanel.kt similarity index 85% rename from app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/CurrentUser.kt rename to app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/HomeUserPanel.kt index b4d1c697..e64eaa47 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/CurrentUser.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/HomeUserPanel.kt @@ -2,7 +2,11 @@ package com.xinto.opencord.ui.screens.home.panels.user import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.xinto.opencord.domain.usersettings.DomainCustomStatus @@ -11,10 +15,14 @@ import com.xinto.opencord.ui.screens.home.panels.user.component.CurrentUserSheet import org.koin.androidx.compose.koinViewModel @Composable -fun HomeUserPanel(modifier: Modifier = Modifier) { +fun HomeUserPanel( + modifier: Modifier = Modifier, + onSettingsClick: () -> Unit +) { val viewModel: HomeUserPanelViewModel = koinViewModel() HomeUserPanel( - onSettingsClick = {}, + modifier = modifier, + onSettingsClick = onSettingsClick, state = viewModel.state, avatarUrl = viewModel.avatarUrl, username = viewModel.username, diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsScreen.kt b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsScreen.kt index 4b87762d..bb49f7c6 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsScreen.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsScreen.kt @@ -1,210 +1,133 @@ package com.xinto.opencord.ui.screens.mentions -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.with -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.paging.LoadState +import androidx.paging.PagingData import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.items -import com.xinto.opencord.R -import com.xinto.opencord.domain.attachment.DomainPictureAttachment -import com.xinto.opencord.domain.attachment.DomainVideoAttachment import com.xinto.opencord.domain.message.DomainMessage -import com.xinto.opencord.domain.message.DomainMessageRegular -import com.xinto.opencord.ui.components.OCImage -import com.xinto.opencord.ui.components.OCSize -import com.xinto.opencord.ui.components.attachment.AttachmentPicture -import com.xinto.opencord.ui.components.attachment.AttachmentVideo -import com.xinto.opencord.ui.components.embed.* -import com.xinto.opencord.ui.components.message.MessageAuthor -import com.xinto.opencord.ui.components.message.MessageAvatar -import com.xinto.opencord.ui.components.message.MessageContent -import com.xinto.opencord.ui.components.message.MessageRegular -import com.xinto.opencord.ui.components.message.reply.MessageReferenced -import com.xinto.opencord.ui.components.message.reply.MessageReferencedAuthor -import com.xinto.opencord.ui.components.message.reply.MessageReferencedContent -import com.xinto.opencord.ui.screens.home.panels.messagemenu.MessageMenu -import com.xinto.opencord.ui.util.* +import com.xinto.opencord.ui.navigation.AppNavigator +import com.xinto.opencord.ui.screens.home.panels.messagemenu.HomeMessageMenu +import com.xinto.opencord.ui.screens.mentions.component.MentionsFilterMenu +import com.xinto.opencord.ui.screens.mentions.component.MentionsPageMessage +import com.xinto.opencord.ui.screens.mentions.component.MentionsTopBar +import com.xinto.opencord.ui.util.CompositePaddingValues +import com.xinto.opencord.ui.util.VoidablePaddingValues +import dev.olshevski.navigation.reimagined.pop +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch -import org.koin.androidx.compose.getViewModel +import org.koin.androidx.compose.koinViewModel + +@Composable +fun MentionsScreen( + modifier: Modifier = Modifier, + navigator: AppNavigator, +) { + val viewModel: MentionsViewModel = koinViewModel() + MentionsScreen( + modifier = modifier, + onBackClick = { + navigator.pop() + }, + currentGuildName = viewModel.currentGuildName, + messages = viewModel.messages, + includeEveryone = viewModel.includeEveryone, + onToggleEveryone = viewModel::toggleEveryone, + includeRoles = viewModel.includeRoles, + onToggleRoles = viewModel::toggleRoles, + includeAllServers = viewModel.includeAllServers, + onToggleAllServers = viewModel::toggleCurrentServer, + ) +} @Composable fun MentionsScreen( onBackClick: () -> Unit, modifier: Modifier = Modifier, - viewModel: MentionsViewModel = getViewModel(), + currentGuildName: String?, + messages: Flow>, + includeEveryone: Boolean, + onToggleEveryone: () -> Unit, + includeRoles: Boolean, + onToggleRoles: () -> Unit, + includeAllServers: Boolean, + onToggleAllServers: () -> Unit ) { var messageMenuTarget by remember { mutableStateOf(null) } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val filterMenuState = rememberModalBottomSheetState() val scope = rememberCoroutineScope() - if (messageMenuTarget != null) { - MessageMenu( + HomeMessageMenu( messageId = messageMenuTarget!!, onDismiss = { messageMenuTarget = null }, ) } if (filterMenuState.isVisible || filterMenuState.targetValue != SheetValue.Hidden) { - ModalBottomSheet( - sheetState = filterMenuState, + MentionsFilterMenu( + filterMenuState = filterMenuState, onDismissRequest = { scope.launch { filterMenuState.hide() } }, - ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .padding(bottom = 30.dp), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(start = 8.dp, bottom = 12.dp), - ) { - Icon( - painter = painterResource(R.drawable.ic_filter), - contentDescription = null, - modifier = Modifier.size(25.dp), - ) - - Text( - text = "Mention Filters", - style = MaterialTheme.typography.labelLarge, - ) - } - - Divider(thickness = 1.dp) - - val filterItems = remember( - viewModel.includeEveryone, - viewModel.includeRoles, - viewModel.includeAllServers, - ) { - arrayOf( - Triple("Include @everyone mentions", viewModel.includeEveryone, viewModel::toggleEveryone), - Triple("Include role mentions", viewModel.includeRoles, viewModel::toggleRoles), - Triple("Include mentions from all servers", viewModel.includeAllServers, viewModel::toggleCurrentServer), - ) - } - - for ((name, isEnabled, onClick) in filterItems) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.medium) - .clickable(onClick = onClick) - .padding(vertical = 2.dp) - .padding(start = 6.dp), - ) { - Text( - text = name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(end = 8.dp) - .weight(1f, fill = false), - ) - - Checkbox( - checked = isEnabled, - onCheckedChange = { onClick() }, - ) - } - } - } - } + includeEveryone = includeEveryone, + includeRoles = includeRoles, + includeAllServers = includeAllServers, + onToggleEveryone = onToggleEveryone, + onToggleRoles = onToggleRoles, + onToggleAllServers = onToggleAllServers + ) } Scaffold( topBar = { - TopAppBar( - title = { - Column { - Text( - text = "Recent mentions", - modifier = Modifier, - ) - - val serverName = if (!viewModel.includeAllServers && viewModel.currentGuildName != null) { - viewModel.currentGuildName ?: "" - } else { - "All servers" - } - - AnimatedContent( - targetState = serverName, - transitionSpec = { - slideIntoContainer(AnimatedContentScope.SlideDirection.Up) with slideOutOfContainer(AnimatedContentScope.SlideDirection.Up) - }, - ) { - Text( - text = serverName, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .alpha(ContentAlpha.medium) - .offset(y = (-2).dp) - .padding(bottom = 1.dp), - ) - } - } - }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Icon( - painter = painterResource(R.drawable.ic_arrow_back), - contentDescription = stringResource(R.string.navigation_back), - ) - } - }, - actions = { - IconButton( - onClick = { - scope.launch { - filterMenuState.show() - } - }, - ) { - Icon( - painter = painterResource(R.drawable.ic_filter), - contentDescription = "Open mention filters", - ) - } - }, + MentionsTopBar( + includeAllServers = includeAllServers, + currentGuildName = currentGuildName, + onBackClick = onBackClick, scrollBehavior = scrollBehavior, + onFilterClick = { + scope.launch { + filterMenuState.show() + } + } ) }, modifier = modifier .nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> - val messages = viewModel.messages.collectAsLazyPagingItems() + val messages = messages.collectAsLazyPagingItems() val refreshState = rememberPullRefreshState( refreshing = messages.loadState.refresh == LoadState.Loading, onRefresh = messages::refresh, @@ -277,187 +200,3 @@ fun MentionsScreen( } } -@Composable -private fun MentionsPageMessage( - message: DomainMessage, - onLongClick: () -> Unit, - modifier: Modifier, -) { - when (message) { - is DomainMessageRegular -> { - Surface( - modifier = modifier, - shape = MaterialTheme.shapes.medium, - tonalElevation = 1.dp, - ) { - MessageRegular( - onLongClick = onLongClick, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - reply = message.isReply.ifComposable { - val referencedMessage = message.referencedMessage - if (referencedMessage != null) { - MessageReferenced( - avatar = { - MessageAvatar(url = referencedMessage.author.avatarUrl) - }, - author = { - MessageReferencedAuthor(author = referencedMessage.author.username) - }, - content = { - MessageReferencedContent( - text = referencedMessage.contentRendered, - ) - }, - ) - } else { - ProvideTextStyle(MaterialTheme.typography.bodySmall) { - Text(stringResource(R.string.message_reply_unknown)) - } - } - }, - avatar = { - MessageAvatar(url = message.author.avatarUrl) - }, - author = { - MessageAuthor( - author = message.author.username, - timestamp = message.formattedTimestamp, - isEdited = message.isEdited, - isBot = message.author.bot, - ) - }, - content = { - MessageContent( - text = message.contentRendered, - ) - }, - embeds = message.embeds.ifNotEmptyComposable { embeds -> - val renderedEmbeds = if (message.isTwitterMultiImageMessage) listOf(embeds.first()) else embeds - - for (embed in renderedEmbeds) key(embed) { - if (embed.isVideoOnlyEmbed) { - val video = embed.video!! - AttachmentVideo( - url = video.proxyUrl!!, - modifier = Modifier - .heightIn(max = 400.dp) - .aspectRatio( - ratio = video.aspectRatio, - matchHeightConstraintsFirst = true, - ), - ) - } else if (embed.isSpotifyEmbed) { - SpotifyEmbed( - embedUrl = embed.spotifyEmbedUrl!!, - isSpotifyTrack = embed.isSpotifyTrack, - ) - } else { - Embed( - title = embed.title, - url = embed.url, - description = embed.description, - color = embed.color, - author = embed.author.ifNotNullComposable { - EmbedAuthor( - name = it.name, - url = it.url, - iconUrl = it.iconUrl, - ) - }, - media = if (!message.isTwitterMultiImageMessage) { - embed.image.ifNotNullComposable { - AttachmentPicture( - url = it.sizedUrl, - width = it.width ?: 500, - height = it.height ?: 500, - modifier = Modifier - .heightIn(max = 400.dp), - ) - } ?: embed.video.ifNotNullComposable { - EmbedVideo( - video = it, - videoPublicUrl = embed.url, - thumbnail = embed.thumbnail, - ) - } - } else { - { - @OptIn(ExperimentalLayoutApi::class) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(5.dp, Alignment.CenterHorizontally), - maxItemsInEachRow = 2, - modifier = Modifier - .clip(MaterialTheme.shapes.small), - ) { - for ((i, twitterEmbed) in embeds.withIndex()) key(twitterEmbed.image) { - val image = twitterEmbed.image!! - val isLastRow = i >= embeds.size - 2 // needed or parent clipping breaks - - OCImage( - url = image.sizedUrl, - size = OCSize(image.width ?: 500, image.height ?: 500), - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxWidth(0.48f) - .heightIn(max = 350.dp) - .padding(bottom = if (isLastRow) 0.dp else 5.dp), - ) - } - } - } - }, - thumbnail = embed.thumbnail.ifNotNullComposable { - AttachmentPicture( - url = it.sizedUrl, - width = it.width ?: 256, - height = it.height ?: 256, - modifier = Modifier - .size(45.dp), - ) - }, - fields = embed.fields.ifNotNullComposable { - for (field in it) key(field) { - EmbedField( - name = field.name, - value = field.value, - ) - } - }, - footer = embed.footer.ifNotNullComposable { - EmbedFooter( - text = it.text, - iconUrl = it.displayUrl, - timestamp = it.formattedTimestamp, - ) - }, - ) - } - } - }, - attachments = message.attachments.ifNotEmptyComposable { attachments -> - for (attachment in attachments) key(attachment) { - when (attachment) { - is DomainPictureAttachment -> { - AttachmentPicture( - modifier = Modifier - .heightIn(max = 250.dp), - url = attachment.proxyUrl, - width = attachment.width, - height = attachment.height, - ) - } - is DomainVideoAttachment -> { - AttachmentVideo(url = attachment.url) - } - else -> {} - } - } - }, - ) - } - } - else -> {} - } -} diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsViewModel.kt index 93341a6a..5499a625 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsViewModel.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsViewModel.kt @@ -8,11 +8,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.* import com.xinto.opencord.domain.message.DomainMessage -import com.xinto.opencord.domain.message.toDomain import com.xinto.opencord.manager.ToastManager import com.xinto.opencord.rest.service.DiscordApiService import com.xinto.opencord.store.GuildStore import com.xinto.opencord.store.PersistentDataStore +import com.xinto.opencord.ui.screens.mentions.model.MentionsPagingSource import com.xinto.opencord.util.collectIn import kotlinx.coroutines.flow.emptyFlow @@ -87,37 +87,4 @@ class MentionsViewModel( ).flow.cachedIn(viewModelScope) } - private class MentionsPagingSource( - private val api: DiscordApiService, - private val includeRoles: Boolean, - private val includeEveryone: Boolean, - private val guildId: Long?, - ) : PagingSource() { - override fun getRefreshKey(state: PagingState) = null - - override suspend fun load(params: LoadParams): LoadResult { - return try { - val beforeMessageId = params.key - val messages = api.getUserMentions( - includeRoles = includeRoles, - includeEveryone = includeEveryone, - guildId = guildId, - beforeId = beforeMessageId, - ) - - LoadResult.Page( - data = messages.map { it.toDomain() }, - prevKey = null, - nextKey = if (messages.size < params.loadSize) { - null - } else { - messages.lastOrNull()?.id?.value - }, - ) - } catch (e: Exception) { - e.printStackTrace() - LoadResult.Error(e) - } - } - } } diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/mentions/component/MentionsFilterMenu.kt b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/component/MentionsFilterMenu.kt new file mode 100644 index 00000000..a2293e84 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/component/MentionsFilterMenu.kt @@ -0,0 +1,106 @@ +package com.xinto.opencord.ui.screens.mentions.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.xinto.opencord.R + + +@Composable +fun MentionsFilterMenu( + filterMenuState: SheetState, + onDismissRequest: () -> Unit, + includeEveryone: Boolean, + includeRoles: Boolean, + includeAllServers: Boolean, + onToggleEveryone: () -> Unit, + onToggleRoles: () -> Unit, + onToggleAllServers: () -> Unit +) { + ModalBottomSheet( + sheetState = filterMenuState, + onDismissRequest = onDismissRequest + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 30.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 8.dp, bottom = 12.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_filter), + contentDescription = null, + modifier = Modifier.size(25.dp), + ) + + Text( + text = "Mention Filters", + style = MaterialTheme.typography.labelLarge, + ) + } + + Divider(thickness = 1.dp) + + val filterItems = remember(includeEveryone, includeRoles, includeAllServers) { + arrayOf( + Triple("Include @everyone mentions", includeEveryone, onToggleEveryone), + Triple("Include role mentions", includeRoles, onToggleRoles), + Triple("Include mentions from all servers", includeAllServers, onToggleAllServers), + ) + } + + for ((name, isEnabled, onClick) in filterItems) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onClick) + .padding(vertical = 2.dp) + .padding(start = 6.dp), + ) { + Text( + text = name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(end = 8.dp) + .weight(1f, fill = false), + ) + + Checkbox( + checked = isEnabled, + onCheckedChange = { onClick() }, + ) + } + } + } + } +} + diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/mentions/component/MentionsScreenMessage.kt b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/component/MentionsScreenMessage.kt new file mode 100644 index 00000000..fe5da11d --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/component/MentionsScreenMessage.kt @@ -0,0 +1,244 @@ +package com.xinto.opencord.ui.screens.mentions.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.xinto.opencord.R +import com.xinto.opencord.domain.attachment.DomainPictureAttachment +import com.xinto.opencord.domain.attachment.DomainVideoAttachment +import com.xinto.opencord.domain.message.DomainMessage +import com.xinto.opencord.domain.message.DomainMessageRegular +import com.xinto.opencord.ui.components.OCImage +import com.xinto.opencord.ui.components.OCSize +import com.xinto.opencord.ui.components.attachment.AttachmentPicture +import com.xinto.opencord.ui.components.attachment.AttachmentVideo +import com.xinto.opencord.ui.components.embed.Embed +import com.xinto.opencord.ui.components.embed.EmbedAuthor +import com.xinto.opencord.ui.components.embed.EmbedField +import com.xinto.opencord.ui.components.embed.EmbedFooter +import com.xinto.opencord.ui.components.embed.EmbedVideo +import com.xinto.opencord.ui.components.embed.SpotifyEmbed +import com.xinto.opencord.ui.components.message.MessageAuthor +import com.xinto.opencord.ui.components.message.MessageAvatar +import com.xinto.opencord.ui.components.message.MessageContent +import com.xinto.opencord.ui.components.message.MessageRegular +import com.xinto.opencord.ui.components.message.reply.MessageReferenced +import com.xinto.opencord.ui.components.message.reply.MessageReferencedAuthor +import com.xinto.opencord.ui.components.message.reply.MessageReferencedContent +import com.xinto.opencord.ui.util.ifComposable +import com.xinto.opencord.ui.util.ifNotEmptyComposable +import com.xinto.opencord.ui.util.ifNotNullComposable + +@Composable +fun MentionsPageMessage( + message: DomainMessage, + onLongClick: () -> Unit, + modifier: Modifier, +) { + when (message) { + is DomainMessageRegular -> { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.medium, + tonalElevation = 1.dp, + ) { + MessageRegular( + onLongClick = onLongClick, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + reply = message.isReply.ifComposable { + val referencedMessage = message.referencedMessage + if (referencedMessage != null) { + MessageReferenced( + avatar = { + MessageAvatar(url = referencedMessage.author.avatarUrl) + }, + author = { + MessageReferencedAuthor(author = referencedMessage.author.username) + }, + content = { + MessageReferencedContent( + text = referencedMessage.contentRendered, + ) + }, + ) + } else { + ProvideTextStyle(MaterialTheme.typography.bodySmall) { + Text(stringResource(R.string.message_reply_unknown)) + } + } + }, + avatar = { + MessageAvatar(url = message.author.avatarUrl) + }, + author = { + MessageAuthor( + author = message.author.username, + timestamp = message.formattedTimestamp, + isEdited = message.isEdited, + isBot = message.author.bot, + ) + }, + content = { + MessageContent( + text = message.contentRendered, + ) + }, + embeds = message.embeds.ifNotEmptyComposable { embeds -> + val renderedEmbeds = + if (message.isTwitterMultiImageMessage) listOf(embeds.first()) else embeds + + for (embed in renderedEmbeds) key(embed) { + if (embed.isVideoOnlyEmbed) { + val video = embed.video!! + AttachmentVideo( + url = video.proxyUrl!!, + modifier = Modifier + .heightIn(max = 400.dp) + .aspectRatio( + ratio = video.aspectRatio, + matchHeightConstraintsFirst = true, + ), + ) + } else if (embed.isSpotifyEmbed) { + SpotifyEmbed( + embedUrl = embed.spotifyEmbedUrl!!, + isSpotifyTrack = embed.isSpotifyTrack, + ) + } else { + Embed( + title = embed.title, + url = embed.url, + description = embed.description, + color = embed.color, + author = embed.author.ifNotNullComposable { + EmbedAuthor( + name = it.name, + url = it.url, + iconUrl = it.iconUrl, + ) + }, + media = if (!message.isTwitterMultiImageMessage) { + embed.image.ifNotNullComposable { + AttachmentPicture( + url = it.sizedUrl, + width = it.width ?: 500, + height = it.height ?: 500, + modifier = Modifier + .heightIn(max = 400.dp), + ) + } ?: embed.video.ifNotNullComposable { + EmbedVideo( + video = it, + videoPublicUrl = embed.url, + thumbnail = embed.thumbnail, + ) + } + } else { + { + @OptIn(ExperimentalLayoutApi::class) + (FlowRow( + horizontalArrangement = Arrangement.spacedBy( + 5.dp, + Alignment.CenterHorizontally + ), + maxItemsInEachRow = 2, + modifier = Modifier + .clip(MaterialTheme.shapes.small), + ) { + for ((i, twitterEmbed) in embeds.withIndex()) key( + twitterEmbed.image + ) { + val image = twitterEmbed.image!! + val isLastRow = + i >= embeds.size - 2 // needed or parent clipping breaks + + OCImage( + url = image.sizedUrl, + size = OCSize( + image.width ?: 500, + image.height ?: 500 + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxWidth(0.48f) + .heightIn(max = 350.dp) + .padding(bottom = if (isLastRow) 0.dp else 5.dp), + ) + } + }) + } + }, + thumbnail = embed.thumbnail.ifNotNullComposable { + AttachmentPicture( + url = it.sizedUrl, + width = it.width ?: 256, + height = it.height ?: 256, + modifier = Modifier + .size(45.dp), + ) + }, + fields = embed.fields.ifNotNullComposable { + for (field in it) key(field) { + EmbedField( + name = field.name, + value = field.value, + ) + } + }, + footer = embed.footer.ifNotNullComposable { + EmbedFooter( + text = it.text, + iconUrl = it.displayUrl, + timestamp = it.formattedTimestamp, + ) + }, + ) + } + } + }, + attachments = message.attachments.ifNotEmptyComposable { attachments -> + for (attachment in attachments) key(attachment) { + when (attachment) { + is DomainPictureAttachment -> { + AttachmentPicture( + modifier = Modifier + .heightIn(max = 250.dp), + url = attachment.proxyUrl, + width = attachment.width, + height = attachment.height, + ) + } + + is DomainVideoAttachment -> { + AttachmentVideo(url = attachment.url) + } + + else -> {} + } + } + }, + ) + } + } + else -> {} + } +} diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/mentions/component/MentionsTopBar.kt b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/component/MentionsTopBar.kt new file mode 100644 index 00000000..b99ba978 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/component/MentionsTopBar.kt @@ -0,0 +1,82 @@ +package com.xinto.opencord.ui.screens.mentions.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.with +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.xinto.opencord.R +import com.xinto.opencord.ui.util.ContentAlpha + + +@Composable +fun MentionsTopBar( + includeAllServers: Boolean, + currentGuildName: String?, + onBackClick: () -> Unit, + onFilterClick: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior +) { + TopAppBar( + title = { + Column { + Text( + text = "Recent mentions", + modifier = Modifier, + ) + + val serverName = if (!includeAllServers && currentGuildName != null) { + currentGuildName + } else { + "All servers" + } + + AnimatedContent( + targetState = serverName, + transitionSpec = { + slideIntoContainer(AnimatedContentScope.SlideDirection.Up) with slideOutOfContainer(AnimatedContentScope.SlideDirection.Up) + }, + ) { + Text( + text = serverName, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .alpha(ContentAlpha.medium) + .offset(y = (-2).dp) + .padding(bottom = 1.dp), + ) + } + } + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = stringResource(R.string.navigation_back), + ) + } + }, + actions = { + IconButton(onClick = onFilterClick) { + Icon( + painter = painterResource(R.drawable.ic_filter), + contentDescription = "Open mention filters", + ) + } + }, + scrollBehavior = scrollBehavior, + ) +} diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/mentions/model/MentionsPagingSource.kt b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/model/MentionsPagingSource.kt new file mode 100644 index 00000000..e78a444d --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/model/MentionsPagingSource.kt @@ -0,0 +1,41 @@ +package com.xinto.opencord.ui.screens.mentions.model + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.xinto.opencord.domain.message.DomainMessage +import com.xinto.opencord.domain.message.toDomain +import com.xinto.opencord.rest.service.DiscordApiService + +class MentionsPagingSource( + private val api: DiscordApiService, + private val includeRoles: Boolean, + private val includeEveryone: Boolean, + private val guildId: Long?, +) : PagingSource() { + override fun getRefreshKey(state: PagingState) = null + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val beforeMessageId = params.key + val messages = api.getUserMentions( + includeRoles = includeRoles, + includeEveryone = includeEveryone, + guildId = guildId, + beforeId = beforeMessageId, + ) + + LoadResult.Page( + data = messages.map { it.toDomain() }, + prevKey = null, + nextKey = if (messages.size < params.loadSize) { + null + } else { + messages.lastOrNull()?.id?.value + }, + ) + } catch (e: Exception) { + e.printStackTrace() + LoadResult.Error(e) + } + } +} diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreen.kt b/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreen.kt index 4c86b741..bcb19687 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreen.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/pins/PinsScreen.kt @@ -1,44 +1,41 @@ package com.xinto.opencord.ui.screens.pins -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.* +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.xinto.opencord.R import com.xinto.opencord.domain.message.DomainMessage +import com.xinto.opencord.ui.navigation.AppNavigator import com.xinto.opencord.ui.navigation.PinsScreenData +import com.xinto.opencord.ui.screens.pins.component.PinsTopBar import com.xinto.opencord.ui.screens.pins.state.PinsScreenError import com.xinto.opencord.ui.screens.pins.state.PinsScreenLoaded import com.xinto.opencord.ui.screens.pins.state.PinsScreenLoading -import com.xinto.opencord.ui.util.ContentAlpha import com.xinto.opencord.ui.util.VoidablePaddingValues import com.xinto.opencord.ui.util.paddingOrGestureNav import com.xinto.opencord.ui.util.toUnsafeImmutableList -import org.koin.androidx.compose.getViewModel +import dev.olshevski.navigation.reimagined.pop import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf @Composable fun PinsScreen( data: PinsScreenData, - modifier: Modifier, - onBackClick: () -> Unit, + modifier: Modifier = Modifier, + navigator: AppNavigator ) { val viewModel: PinsScreenViewModel = koinViewModel { parametersOf(data) } PinsScreen( - onBackClick = onBackClick, + onBackClick = { + navigator.pop() + }, state = viewModel.state, modifier = modifier, messages = viewModel.messages, @@ -67,32 +64,10 @@ fun PinsScreen( modifier = modifier .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar( - title = { - Column { - Text(stringResource(R.string.pins_title)) - - if (channelName != null) { - Text( - text = "#${channelName}", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .alpha(ContentAlpha.medium) - .offset(y = (-2).dp) - .padding(bottom = 1.dp), - ) - } - } - }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Icon( - painter = painterResource(R.drawable.ic_arrow_back), - contentDescription = null, - ) - } - }, - scrollBehavior = scrollBehavior, + PinsTopBar( + channelName = channelName, + onBackClick = onBackClick, + scrollBehavior = scrollBehavior ) }, ) { paddingValues -> diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/pins/component/PinsTopBar.kt b/app/src/main/java/com/xinto/opencord/ui/screens/pins/component/PinsTopBar.kt new file mode 100644 index 00000000..ac22a3a4 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/pins/component/PinsTopBar.kt @@ -0,0 +1,56 @@ +package com.xinto.opencord.ui.screens.pins.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.xinto.opencord.R +import com.xinto.opencord.ui.util.ContentAlpha + + +@Composable +fun PinsTopBar( + channelName: String?, + onBackClick: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior +) { + TopAppBar( + title = { + Column { + Text(stringResource(R.string.pins_title)) + + if (channelName != null) { + Text( + text = "#${channelName}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .alpha(ContentAlpha.medium) + .offset(y = (-2).dp) + .padding(bottom = 1.dp), + ) + } + } + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = null, + ) + } + }, + scrollBehavior = scrollBehavior, + ) +} + diff --git a/app/src/main/java/com/xinto/opencord/ui/screens/pins/state/PinsScreenLoaded.kt b/app/src/main/java/com/xinto/opencord/ui/screens/pins/state/PinsScreenLoaded.kt index 96a8385c..2b058a47 100644 --- a/app/src/main/java/com/xinto/opencord/ui/screens/pins/state/PinsScreenLoaded.kt +++ b/app/src/main/java/com/xinto/opencord/ui/screens/pins/state/PinsScreenLoaded.kt @@ -31,7 +31,7 @@ import com.xinto.opencord.ui.components.message.MessageRegular import com.xinto.opencord.ui.components.message.reply.MessageReferenced import com.xinto.opencord.ui.components.message.reply.MessageReferencedAuthor import com.xinto.opencord.ui.components.message.reply.MessageReferencedContent -import com.xinto.opencord.ui.screens.home.panels.messagemenu.MessageMenu +import com.xinto.opencord.ui.screens.home.panels.messagemenu.HomeMessageMenu import com.xinto.opencord.ui.util.CompositePaddingValues import com.xinto.opencord.ui.util.ifComposable import com.xinto.opencord.ui.util.ifNotEmptyComposable @@ -47,7 +47,7 @@ fun PinsScreenLoaded( var messageMenuTarget by remember { mutableStateOf(null) } if (messageMenuTarget != null) { - MessageMenu( + HomeMessageMenu( messageId = messageMenuTarget!!, onDismiss = { messageMenuTarget = null }, )