diff --git a/app/build.gradle b/app/build.gradle index 50099403e..1c11ab7e5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index e09e3b70d..d40f05743 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -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) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt index 022378dd4..1fb30335f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt @@ -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 ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt index 6e794896b..3828f5815 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt @@ -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 = mutableStateOf(false) + var windowSizeClass = mutableStateOf(null) + var displayFeatures = mutableStateOf>(emptyList()) + val showProfilePictures = derivedStateOf { when (automaticallyShowProfilePictures) { ConnectivityType.WIFI_ONLY -> !isOnMobileData.value @@ -151,6 +156,15 @@ class SharedPreferencesViewModel : ViewModel() { } } + fun updateDisplaySettings(windowSizeClass: WindowSizeClass, displayFeatures: List) { + 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( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt index 1ab24c005..00c5de374 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt @@ -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 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt index 50e2abecb..f18ae378b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt @@ -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) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 68cbc2556..9335ae598 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -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) {