TwoPane display for chats in tablets and folds.

This commit is contained in:
Vitor Pamplona 2023-10-21 16:23:34 -04:00
parent a284c1b9c6
commit 884a124c7e
7 changed files with 220 additions and 6 deletions

View File

@ -102,9 +102,15 @@ dependencies {
// Observe Live data as State
implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
// Material 3 Design
implementation "androidx.compose.material3:material3:${material3_version}"
implementation "androidx.compose.material:material-icons-extended:$compose_ui_version"
// Adaptive Layout / Two Pane
implementation "androidx.compose.material3:material3-window-size-class:${material3_version}"
implementation "com.google.accompanist:accompanist-adaptive:0.33.2-alpha"
// Lifecycle
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"

View File

@ -16,10 +16,13 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.adaptive.calculateDisplayFeatures
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
@ -51,6 +54,7 @@ import java.nio.charset.StandardCharsets
class MainActivity : AppCompatActivity() {
private val isOnMobileDataState = mutableStateOf(false)
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) {
ExternalSignerUtils.start(this)
@ -60,9 +64,16 @@ class MainActivity : AppCompatActivity() {
setContent {
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
LaunchedEffect(key1 = sharedPreferencesViewModel, isOnMobileDataState) {
val displayFeatures = calculateDisplayFeatures(this)
val windowSizeClass = calculateWindowSizeClass(this)
LaunchedEffect(key1 = sharedPreferencesViewModel) {
sharedPreferencesViewModel.init()
}
LaunchedEffect(isOnMobileDataState) {
sharedPreferencesViewModel.updateConnectivityStatusState(isOnMobileDataState)
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
}
AmethystTheme(sharedPreferencesViewModel) {

View File

@ -238,7 +238,9 @@ private fun ChannelTitleWithLabelInfo(channelName: String, modifier: Modifier) {
text = channelNameAndBoostInfo,
fontWeight = FontWeight.Bold,
modifier = modifier,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}

View File

@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
@ -10,6 +11,7 @@ import androidx.compose.runtime.setValue
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.window.layout.DisplayFeature
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.ConnectivityType
@ -31,6 +33,9 @@ class SettingsState() {
var isOnMobileData: State<Boolean> = mutableStateOf(false)
var windowSizeClass = mutableStateOf<WindowSizeClass?>(null)
var displayFeatures = mutableStateOf<List<DisplayFeature>>(emptyList())
val showProfilePictures = derivedStateOf {
when (automaticallyShowProfilePictures) {
ConnectivityType.WIFI_ONLY -> !isOnMobileData.value
@ -151,6 +156,15 @@ class SharedPreferencesViewModel : ViewModel() {
}
}
fun updateDisplaySettings(windowSizeClass: WindowSizeClass, displayFeatures: List<DisplayFeature>) {
if (sharedPrefs.windowSizeClass.value != windowSizeClass) {
sharedPrefs.windowSizeClass.value = windowSizeClass
}
if (sharedPrefs.displayFeatures.value != displayFeatures) {
sharedPrefs.displayFeatures.value = displayFeatures
}
}
fun saveSharedSettings() {
viewModelScope.launch(Dispatchers.IO) {
LocalPreferences.saveSharedSettings(

View File

@ -8,10 +8,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
@ -20,6 +22,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
@ -38,24 +42,152 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.google.accompanist.adaptive.FoldAwareConfiguration
import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy
import com.google.accompanist.adaptive.TwoPane
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.screen.ChatroomListFeedView
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun ChatroomListScreen(
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
newFeedViewModel: NostrChatroomListNewFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val windowSizeClass = accountViewModel.settings.windowSizeClass.value
val twoPane by remember {
derivedStateOf {
when (windowSizeClass?.widthSizeClass) {
WindowWidthSizeClass.Compact -> false
WindowWidthSizeClass.Expanded, WindowWidthSizeClass.Medium -> true
else -> false
}
}
}
if (twoPane && windowSizeClass != null) {
ChatroomListTwoPane(
knownFeedViewModel = knownFeedViewModel,
newFeedViewModel = newFeedViewModel,
widthSizeClass = windowSizeClass.widthSizeClass,
accountViewModel = accountViewModel,
nav = nav
)
} else {
ChatroomListScreenOnlyList(
knownFeedViewModel = knownFeedViewModel,
newFeedViewModel = newFeedViewModel,
accountViewModel = accountViewModel,
nav = nav
)
}
}
data class RouteId(val route: String, val id: String)
@Composable
fun ChatroomListTwoPane(
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
newFeedViewModel: NostrChatroomListNewFeedViewModel,
widthSizeClass: WindowWidthSizeClass,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
/**
* The index of the currently selected word, or `null` if none is selected
*/
var selectedRoute: RouteId? by remember { mutableStateOf(null) }
val navInterceptor = remember {
{ fullRoute: String ->
if (fullRoute.startsWith("Room/") || fullRoute.startsWith("Channel/")) {
val route = fullRoute.substringBefore("/")
val id = fullRoute.substringAfter("/")
selectedRoute = RouteId(route, id)
} else {
nav(fullRoute)
}
}
}
val strategy = remember {
if (widthSizeClass == WindowWidthSizeClass.Expanded) {
HorizontalTwoPaneStrategy(
splitFraction = 1f / 3f
)
} else {
HorizontalTwoPaneStrategy(
splitFraction = 1f / 2.5f
)
}
}
TwoPane(
first = {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomEnd) {
ChatroomListScreenOnlyList(
knownFeedViewModel,
newFeedViewModel,
accountViewModel,
navInterceptor
)
Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) {
ChannelFabColumn(accountViewModel, nav)
}
Divider(
modifier = Modifier
.fillMaxHeight() // fill the max height
.width(DividerThickness)
)
}
},
second = {
selectedRoute?.let {
if (it.route == "Room") {
ChatroomScreen(
roomId = it.id,
accountViewModel = accountViewModel,
nav = nav
)
}
if (it.route == "Channel") {
ChannelScreen(
channelId = it.id,
accountViewModel = accountViewModel,
nav = nav
)
}
}
},
strategy = strategy,
displayFeatures = accountViewModel.settings.displayFeatures.value,
foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly,
modifier = Modifier.fillMaxSize()
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatroomListScreenOnlyList(
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
newFeedViewModel: NostrChatroomListNewFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val pagerState = rememberPagerState() { 2 }
val coroutineScope = rememberCoroutineScope()
@ -116,7 +248,6 @@ fun ChatroomListScreen(
IconButton(
modifier = Modifier
.padding(end = 5.dp)
.size(40.dp)
.align(Alignment.CenterEnd),
onClick = { moreActionsExpanded = true }

View File

@ -11,6 +11,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
@ -238,7 +242,48 @@ private fun DiscoverFeedLoaded(
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
val defaultModifier = remember {
Modifier.fillMaxWidth().animateItemPlacement()
Modifier
.fillMaxWidth()
.animateItemPlacement()
}
Row(defaultModifier) {
ChannelCardCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
modifier = Modifier,
forceEventKind = forceEventKind,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DiscoverFeedTwoColumnsLoaded(
state: FeedState.Loaded,
routeForLastRead: String?,
listState: LazyGridState,
forceEventKind: Int?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
val defaultModifier = remember {
Modifier
.fillMaxWidth()
.animateItemPlacement()
}
Row(defaultModifier) {

View File

@ -22,6 +22,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@ -474,7 +475,11 @@ private fun WritePermissionButtons(
when (currentRoute) {
Route.Home.base -> NewNoteButton(accountViewModel, nav)
Route.Message.base -> ChannelFabColumn(accountViewModel, nav)
Route.Message.base -> {
if (accountViewModel.settings.windowSizeClass.value?.widthSizeClass == WindowWidthSizeClass.Compact) {
ChannelFabColumn(accountViewModel, nav)
}
}
Route.Video.base -> NewImageButton(accountViewModel, nav, navScrollToTop)
Route.Community.base -> {
val communityId by remember(navEntryState.value) {