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' 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..8def0e6e 100644 --- a/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt +++ b/app/src/main/java/com/xinto/opencord/di/ViewModelModule.kt @@ -1,17 +1,23 @@ package com.xinto.opencord.di -import com.xinto.opencord.ui.viewmodel.* +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.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 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(::MessageMenuViewModel) + viewModelOf(::HomeChannelsPanelViewModel) + viewModelOf(::PinsScreenViewModel) + viewModelOf(::HomeUserPanelViewModel) + viewModelOf(::HomeMessageMenuPanelViewModel) 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/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 1d766bec..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 @@ -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 @@ -15,22 +24,40 @@ 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.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 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 -import org.koin.androidx.compose.getViewModel + +@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( @@ -41,12 +68,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 +92,22 @@ 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, + onSettingsClick = onSettingsClick ) } @@ -119,16 +134,17 @@ fun HomeScreen( }, ) - Chat( - onChannelsButtonClick = panelState::openStart, - onMembersButtonClick = panelState::openEnd, - onPinsButtonClick = { - onPinsClick(PinsScreenData(channelsViewModel.selectedChannelId)) - }, - viewModel = chatViewModel, + HomeChatPanel( 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/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..5bba6485 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/channel/HomeChannelsPanel.kt @@ -0,0 +1,87 @@ +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 com.xinto.opencord.ui.screens.home.panels.channel.state.ChannelsListLoaded +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..54bb9826 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 @@ -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 @@ -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/Chat.kt deleted file mode 100644 index 01206eca..00000000 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/Chat.kt +++ /dev/null @@ -1,129 +0,0 @@ -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.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.ui.screens.home.panels.chatinput.ChatInput -import com.xinto.opencord.ui.viewmodel.ChatViewModel -import org.koin.androidx.compose.getViewModel - -@Composable -fun Chat( - onChannelsButtonClick: () -> Unit, - onMembersButtonClick: () -> Unit, - onPinsButtonClick: () -> Unit, - modifier: Modifier = Modifier, - viewModel: ChatViewModel = getViewModel(), -) { - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = { - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (viewModel.channelName.isNotEmpty()) { - Icon( - painter = painterResource(R.drawable.ic_tag), - contentDescription = null, - modifier = Modifier, - ) - } - Text( - text = viewModel.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, - ) - } - }, - ) - }, - ) { paddingValues -> - Surface( - modifier = Modifier.fillMaxSize(), - tonalElevation = 2.dp, - ) { - when (viewModel.state) { - is ChatViewModel.State.Unselected -> { - ChatUnselected( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - ) - } - is ChatViewModel.State.Loading -> { - ChatLoading( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - ) - } - is ChatViewModel.State.Loaded -> { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - ChatLoaded( - viewModel = viewModel, - modifier = Modifier - .fillMaxSize() - .weight(1f), - ) - - ChatInput( - modifier = Modifier.padding( - start = 8.dp, - end = 8.dp, - bottom = 4.dp, - ), - hint = { - Text(stringResource(R.string.chat_input_hint, viewModel.channelName)) - }, - ) - } - } - is ChatViewModel.State.Error -> { - ChatError( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - ) - } - } - } - } -} 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 new file mode 100644 index 00000000..a6f1c363 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanel.kt @@ -0,0 +1,134 @@ +package com.xinto.opencord.ui.screens.home.panels.chat + +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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.xinto.opencord.R +import com.xinto.opencord.domain.emoji.DomainEmoji +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, + onPinsButtonClick: (PinsScreenData) -> Unit, + onMembersButtonClick: () -> Unit, + onChannelsButtonClick: () -> Unit, +) { + val viewModel: HomeChatPanelViewModel = koinViewModel() + HomeChatPanel( + modifier = modifier, + state = viewModel.state, + channelName = viewModel.channelName, + messages = viewModel.sortedMessages, + onMessageReact = viewModel::reactToMessage, + onPinsButtonClick = { + onPinsButtonClick(PinsScreenData(viewModel.channelId)) + }, + onMembersButtonClick = onMembersButtonClick, + onChannelsButtonClick = onChannelsButtonClick, + inputText = viewModel.inputText, + onInputTextChange = viewModel::updateInputText, + onInputTextSend = viewModel::sendMessage + ) +} + +@Composable +fun HomeChatPanel( + state: HomeChatPanelState, + channelName: String, + messages: SnapshotStateList, + onMessageReact: (Long, DomainEmoji) -> Unit, + onPinsButtonClick: () -> Unit, + onMembersButtonClick: () -> Unit, + onChannelsButtonClick: () -> Unit, + inputText: String, + onInputTextChange: (String) -> Unit, + onInputTextSend: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + HomeChatPanelTopBar( + channelName = channelName, + onChannelsButtonClick = onChannelsButtonClick, + onPinsButtonClick = onPinsButtonClick, + onMembersButtonClick = onMembersButtonClick + ) + }, + ) { paddingValues -> + Surface( + modifier = Modifier.fillMaxSize(), + tonalElevation = 2.dp, + ) { + when (state) { + is HomeChatPanelState.Unselected -> { + ChatUnselected( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) + } + is HomeChatPanelState.Loading -> { + ChatLoading( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) + } + is HomeChatPanelState.Loaded -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + ChatLoaded( + modifier = Modifier + .fillMaxSize() + .weight(1f), + messages = messages, + onMessageReact = onMessageReact + ) + + HomeChatPanelInput( + modifier = Modifier.padding( + start = 8.dp, + end = 8.dp, + bottom = 4.dp, + ), + hint = { + Text(stringResource(R.string.chat_input_hint, channelName)) + }, + text = inputText, + onTextChange = onInputTextChange, + onSend = onInputTextSend, + ) + } + } + is HomeChatPanelState.Error -> { + ChatError( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) + } + } + } + } +} 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..5a1a1763 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/HomeChatPanelViewModel.kt @@ -0,0 +1,240 @@ +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.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 +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 + + var channelId = 0L + private set + + // 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 } + .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) + } + } + } + + 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 -> + 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() } + }, + ) + } + + persistentDataStore.observeCurrentChannel().collectIn(viewModelScope) { + channelId = it + } + + 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/chat/component/HomeChatPanelInput.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/HomeChatPanelInput.kt new file mode 100644 index 00000000..be927e41 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chat/component/HomeChatPanelInput.kt @@ -0,0 +1,68 @@ +package com.xinto.opencord.ui.screens.home.panels.chat.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Send +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.xinto.opencord.ui.components.OCBasicTextField + +@Composable +fun HomeChatPanelInput( + modifier: Modifier = Modifier, + hint: @Composable () -> Unit, + text: String, + onTextChange: (String) -> Unit, + onSend: () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 48.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OCBasicTextField( + modifier = Modifier.weight(1f), + value = text, + onValueChange = onTextChange, + maxLines = 7, + decorationBox = { 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() + } + } + } + } + }, + ) + AnimatedVisibility( + visible = text.isNotEmpty(), + enter = slideInHorizontally { it * 2 }, + exit = slideOutHorizontally { it * 2 }, + ) { + FilledIconButton(onClick = onSend) { + Icon( + imageVector = Icons.Rounded.Send, + contentDescription = null, + ) + } + } + } +} 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/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..0390ff0d 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,30 +33,31 @@ 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.messagemenu.MessageMenu +import com.xinto.opencord.ui.screens.home.panels.chat.model.MessageItem +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 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) } } if (messageMenuTarget != null) { - MessageMenu( + HomeMessageMenu( messageId = messageMenuTarget!!, onDismiss = { messageMenuTarget = null }, ) @@ -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/chatinput/ChatInput.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chatinput/ChatInput.kt deleted file mode 100644 index 7976699a..00000000 --- a/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/chatinput/ChatInput.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.xinto.opencord.ui.screens.home.panels.chatinput - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Send -import androidx.compose.material3.* -import androidx.compose.runtime.* -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 ChatInput( - modifier: Modifier = Modifier, - hint: @Composable () -> Unit, - viewModel: ChatInputViewModel = getViewModel(), -) { - val isEmpty by remember { derivedStateOf { viewModel.pendingContent.isEmpty() } } - val sendEnabled by remember { derivedStateOf { !isEmpty && viewModel.sendEnabled } } - - Row( - modifier = modifier - .fillMaxWidth() - .heightIn(min = 48.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OCBasicTextField( - modifier = Modifier.weight(1f), - value = viewModel.pendingContent, - onValueChange = viewModel::setPendingMessage, - maxLines = 7, - decorationBox = { innerTextField -> - InputInnerTextField( - isEmpty = isEmpty, - hint = hint, - innerTextField = innerTextField, - ) - }, - ) - - 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, - ) { - Box( - modifier = Modifier - .padding(16.dp), - ) { - innerTextField() - CompositionLocalProvider( - LocalContentColor provides LocalContentColor.current.copy(alpha = 0.7f), - LocalTextStyle provides MaterialTheme.typography.bodyMedium, - ) { - if (isEmpty) { - hint() - } - } - } - } - } -} - -@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/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/MessageMenu.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/HomeMessageMenu.kt similarity index 77% 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 1db93472..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 @@ -4,17 +4,17 @@ 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 @Composable -fun MessageMenu( +fun HomeMessageMenu( messageId: Long, onDismiss: (() -> Unit)? = null, sheetState: SheetState = rememberModalBottomSheetState(), - viewModel: MessageMenuViewModel = + viewModel: HomeMessageMenuPanelViewModel = getLocalViewModel(parameters = { parametersOf(messageId) }), ) { val coroutineScope = rememberCoroutineScope() @@ -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/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/viewmodel/MessageMenuViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/HomeMessageMenuPanelViewModel.kt similarity index 83% 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/HomeMessageMenuPanelViewModel.kt index 639a00c0..29ec2b98 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/HomeMessageMenuPanelViewModel.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 @@ -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, @@ -29,19 +29,8 @@ 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 - object Unpinnable : PinState - object None : PinState - } - var state by mutableStateOf(State.Loading) + var state by mutableStateOf(HomeMessageMenuPanelState.Loading) private set var message by mutableStateOf(null) @@ -50,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() @@ -96,7 +85,7 @@ class MessageMenuViewModel( val message = messages.getMessage(messageId) if (message == null || currentUser == null) { - state = State.Closing + state = HomeMessageMenuPanelState.Closing return@launch } @@ -116,25 +105,25 @@ 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 - state = State.Loaded + this@HomeMessageMenuPanelViewModel.message = message + state = HomeMessageMenuPanelState.Loaded } messages.observeMessage(messageId).collectIn(viewModelScope) { event -> event.fold( onAdd = {}, onUpdate = { message = it }, - onDelete = { state = State.Closing }, + onDelete = { state = HomeMessageMenuPanelState.Closing }, ) } 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/MessageMenuLoaded.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/messagemenu/state/MessageMenuLoaded.kt similarity index 92% 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..087f8da6 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,12 +16,14 @@ 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.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), @@ -118,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/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/HomeUserPanel.kt b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/HomeUserPanel.kt new file mode 100644 index 00000000..e64eaa47 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/home/panels/user/HomeUserPanel.kt @@ -0,0 +1,84 @@ +package com.xinto.opencord.ui.screens.home.panels.user + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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 +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, + onSettingsClick: () -> Unit +) { + val viewModel: HomeUserPanelViewModel = koinViewModel() + HomeUserPanel( + modifier = modifier, + onSettingsClick = 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..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,211 +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.viewmodel.MentionsViewModel +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, @@ -278,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 new file mode 100644 index 00000000..5499a625 --- /dev/null +++ b/app/src/main/java/com/xinto/opencord/ui/screens/mentions/MentionsViewModel.kt @@ -0,0 +1,90 @@ +package com.xinto.opencord.ui.screens.mentions + +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.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 + +@Stable +class MentionsViewModel( + guilds: GuildStore, + persistentDataStore: PersistentDataStore, + private val toasts: ToastManager, + private val api: DiscordApiService, +) : ViewModel() { + var includeRoles by mutableStateOf(true) + private set + var includeEveryone by mutableStateOf(true) + private set + var includeAllServers by mutableStateOf(true) + private set + var currentGuildName by mutableStateOf(null) + private set + + var messages by mutableStateOf(emptyFlow>()) + private set + + private var guildId = 0L + + fun toggleRoles() { + includeRoles = !includeRoles + initPager() + } + + fun toggleEveryone() { + includeEveryone = !includeEveryone + initPager() + } + + fun toggleCurrentServer() { + if (includeAllServers && guildId <= 0) { + toasts.showToast("No server currently selected!") + } else { + includeAllServers = !includeAllServers + initPager() + } + } + + init { + initPager() + + persistentDataStore.observeCurrentGuild() + .collectIn(viewModelScope) { + guildId = it + + if (it <= 0) return@collectIn + + val guild = guilds.fetchGuild(guildId) + ?: return@collectIn + + currentGuildName = guild.name + } + } + + private fun initPager() { + messages = Pager( + config = PagingConfig( + pageSize = 25, + prefetchDistance = 25, + enablePlaceholders = false, + initialLoadSize = 25, + ), + pagingSourceFactory = { + 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/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 2538df2a..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,41 +1,60 @@ 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.util.ContentAlpha +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.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 dev.olshevski.navigation.reimagined.pop +import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf @Composable fun PinsScreen( data: PinsScreenData, + modifier: Modifier = Modifier, + navigator: AppNavigator +) { + val viewModel: PinsScreenViewModel = koinViewModel { parametersOf(data) } + PinsScreen( + onBackClick = { + navigator.pop() + }, + 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() } @@ -45,44 +64,22 @@ fun PinsScreen( modifier = modifier .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar( - title = { - Column { - Text(stringResource(R.string.pins_title)) - - if (viewModel.channelName != null) { - Text( - text = "#${viewModel.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 -> - 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 +88,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/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/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 98% 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..2b058a47 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 @@ -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 }, ) 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/ChatInputViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatInputViewModel.kt deleted file mode 100644 index 801f4a5e..00000000 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/ChatInputViewModel.kt +++ /dev/null @@ -1,53 +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.ui.viewmodel.base.BasePersistenceViewModel -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) - } -} 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/app/src/main/java/com/xinto/opencord/ui/viewmodel/MentionsViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/viewmodel/MentionsViewModel.kt deleted file mode 100644 index 75d72d7c..00000000 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/MentionsViewModel.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.xinto.opencord.ui.viewmodel - -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -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.base.BasePersistenceViewModel -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.launch - -@Stable -class MentionsViewModel( - persistentDataManager: PersistentDataManager, - guilds: GuildStore, - private val toasts: ToastManager, - private val api: DiscordApiService, -) : BasePersistenceViewModel(persistentDataManager) { - var includeRoles by mutableStateOf(true) - private set - var includeEveryone by mutableStateOf(true) - private set - var includeAllServers by mutableStateOf(true) - private set - var currentGuildName by mutableStateOf(null) - private set - - var messages by mutableStateOf(emptyFlow>()) - private set - - fun toggleRoles() { - includeRoles = !includeRoles - initPager() - } - - fun toggleEveryone() { - includeEveryone = !includeEveryone - initPager() - } - - fun toggleCurrentServer() { - if (includeAllServers && persistentGuildId <= 0) { - toasts.showToast("No server currently selected!") - } else { - includeAllServers = !includeAllServers - initPager() - } - } - - init { - initPager() - - if (persistentGuildId > 0) { - viewModelScope.launch { - val guild = guilds.fetchGuild(persistentGuildId) - ?: return@launch - - currentGuildName = guild.name - } - } - } - - private fun initPager() { - messages = Pager( - config = PagingConfig( - pageSize = 25, - prefetchDistance = 25, - enablePlaceholders = false, - initialLoadSize = 25, - ), - pagingSourceFactory = { - val guildId = if (!includeAllServers) persistentGuildId else null - MentionsPagingSource(api, includeRoles, includeEveryone, guildId) - }, - ).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/viewmodel/base/BasePersistenceViewModel.kt b/app/src/main/java/com/xinto/opencord/ui/viewmodel/base/BasePersistenceViewModel.kt deleted file mode 100644 index 22083a39..00000000 --- a/app/src/main/java/com/xinto/opencord/ui/viewmodel/base/BasePersistenceViewModel.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.xinto.opencord.ui.viewmodel.base - -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) - } -} 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"]