feat: initial tablet support

Signed-off-by: Infi <wingit@geist.ga>
This commit is contained in:
Infi 2023-08-02 23:10:41 +02:00
parent 6a7cbc335d
commit 5dd89fa070
5 changed files with 365 additions and 239 deletions

View File

@ -126,6 +126,7 @@ dependencies {
implementation "androidx.compose.ui:ui-util" implementation "androidx.compose.ui:ui-util"
implementation 'androidx.compose.material:material' implementation 'androidx.compose.material:material'
implementation 'androidx.compose.material3:material3' implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.material3:material3-window-size-class'
implementation "androidx.compose.ui:ui-tooling-preview" implementation "androidx.compose.ui:ui-tooling-preview"
implementation "androidx.compose.runtime:runtime-livedata" implementation "androidx.compose.runtime:runtime-livedata"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'

View File

@ -10,6 +10,9 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -45,6 +48,7 @@ import io.sentry.android.core.SentryAndroid
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : FragmentActivity() { class MainActivity : FragmentActivity() {
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -56,7 +60,8 @@ class MainActivity : FragmentActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
AppEntrypoint() val windowSizeClass = calculateWindowSizeClass(this)
AppEntrypoint(windowSizeClass)
} }
} }
} }
@ -67,7 +72,7 @@ val RevoltTweenDp: FiniteAnimationSpec<Dp> = tween(400, easing = EaseInOutExpo)
val RevoltTweenColour: FiniteAnimationSpec<Color> = tween(400, easing = EaseInOutExpo) val RevoltTweenColour: FiniteAnimationSpec<Color> = tween(400, easing = EaseInOutExpo)
@Composable @Composable
fun AppEntrypoint() { fun AppEntrypoint(windowSizeClass: WindowSizeClass) {
val navController = rememberNavController() val navController = rememberNavController()
RevoltTheme( RevoltTheme(
@ -126,7 +131,7 @@ fun AppEntrypoint() {
} }
composable("register/onboarding") { OnboardingScreen(navController) } composable("register/onboarding") { OnboardingScreen(navController) }
composable("chat") { ChatRouterScreen(navController) } composable("chat") { ChatRouterScreen(navController, windowSizeClass) }
composable("settings") { SettingsScreen(navController) } composable("settings") { SettingsScreen(navController) }
composable("settings/appearance") { AppearanceSettingsScreen(navController) } composable("settings/appearance") { AppearanceSettingsScreen(navController) }

View File

@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@ -31,6 +32,7 @@ fun ChannelHeader(
channel: Channel, channel: Channel,
onChannelClick: (String) -> Unit, onChannelClick: (String) -> Unit,
onToggleDrawer: () -> Unit, onToggleDrawer: () -> Unit,
useDrawer: Boolean,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -41,17 +43,26 @@ fun ChannelHeader(
.padding(vertical = 4.dp, horizontal = 4.dp), .padding(vertical = 4.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
IconButton(onClick = { if (useDrawer) {
onToggleDrawer() IconButton(onClick = {
}) { onToggleDrawer()
Icon( }) {
imageVector = Icons.Default.Menu, Icon(
contentDescription = stringResource(R.string.menu), imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.menu),
)
}
Spacer(modifier = Modifier.width(4.dp))
} else {
// Compensate for the IconButton not increasing our height
Spacer(
modifier = Modifier
.height(48.dp)
.width(12.dp)
) )
} }
Spacer(modifier = Modifier.width(4.dp))
channel.channelType?.let { channel.channelType?.let {
ChannelIcon( ChannelIcon(
channelType = it, channelType = it,

View File

@ -5,6 +5,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -19,6 +20,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DismissibleDrawerSheet import androidx.compose.material3.DismissibleDrawerSheet
import androidx.compose.material3.DismissibleNavigationDrawer import androidx.compose.material3.DismissibleNavigationDrawer
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -27,6 +29,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -46,6 +50,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.dialog import androidx.navigation.compose.dialog
@ -181,7 +186,11 @@ class ChatRouterViewModel @Inject constructor(
ExperimentalMaterial3Api::class ExperimentalMaterial3Api::class
) )
@Composable @Composable
fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hiltViewModel()) { fun ChatRouterScreen(
topNav: NavController,
windowSizeClass: WindowSizeClass,
viewModel: ChatRouterViewModel = hiltViewModel()
) {
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@ -206,6 +215,8 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hil
var userContextSheetTarget by remember { mutableStateOf("") } var userContextSheetTarget by remember { mutableStateOf("") }
var userContextSheetServer by remember { mutableStateOf<String?>(null) } var userContextSheetServer by remember { mutableStateOf<String?>(null) }
var useTabletAwareUI by remember { mutableStateOf(false) }
val drawerBackHandler = remember { val drawerBackHandler = remember {
{ {
scope.launch { scope.launch {
@ -270,6 +281,14 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hil
} }
} }
LaunchedEffect(windowSizeClass) {
snapshotFlow { windowSizeClass }
.distinctUntilChanged()
.collect { sizeClass ->
useTabletAwareUI = sizeClass.widthSizeClass == WindowWidthSizeClass.Expanded
}
}
if (showSidebarSpark.value) { if (showSidebarSpark.value) {
AlertDialog( AlertDialog(
onDismissRequest = {}, onDismissRequest = {},
@ -408,241 +427,329 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hil
}) })
} }
DismissibleNavigationDrawer( if (useTabletAwareUI) {
drawerState = drawerState, Row {
drawerContent = { Sidebar(
DismissibleDrawerSheet( viewModel = viewModel,
drawerContainerColor = Color.Transparent, navController = navController,
) { onShowStatusSheet = {
Column(Modifier.fillMaxWidth()) { showStatusSheet = true
Row { },
Column( onShowServerContextSheet = {
serverContextSheetTarget = it
showServerContextSheet = true
},
onShowAddServerSheet = {
showAddServerSheet = true
},
useDrawer = false,
)
ChannelNavigator(
navController = navController,
topNav = topNav,
useDrawer = false,
drawerBackHandler = {
drawerBackHandler()
},
onShowUserContextSheet = { target, server ->
userContextSheetTarget = target
userContextSheetServer = server
showUserContextSheet = true
},
)
}
} else {
DismissibleNavigationDrawer(
drawerState = drawerState,
drawerContent = {
DismissibleDrawerSheet(
drawerContainerColor = Color.Transparent,
) {
Row(Modifier.fillMaxWidth()) {
Sidebar(
viewModel = viewModel,
navController = navController,
onShowStatusSheet = {
showStatusSheet = true
},
onShowServerContextSheet = {
serverContextSheetTarget = it
showServerContextSheet = true
},
onShowAddServerSheet = {
showAddServerSheet = true
},
drawerState = drawerState,
useDrawer = true,
)
}
}
},
content = {
Row(Modifier.fillMaxSize()) {
ChannelNavigator(
navController = navController,
topNav = topNav,
useDrawer = true,
drawerBackHandler = {
drawerBackHandler()
},
drawerState = drawerState,
onShowUserContextSheet = { target, server ->
userContextSheetTarget = target
userContextSheetServer = server
showUserContextSheet = true
},
)
}
})
}
}
}
@Composable
fun RowScope.Sidebar(
viewModel: ChatRouterViewModel,
navController: NavHostController,
drawerState: DrawerState? = null,
onShowStatusSheet: () -> Unit,
onShowServerContextSheet: (String) -> Unit,
onShowAddServerSheet: () -> Unit,
useDrawer: Boolean = false,
) {
val scope = rememberCoroutineScope()
Column(Modifier.then(if (useDrawer) Modifier.fillMaxWidth() else Modifier.weight(0.3f))) {
Row {
Column(
modifier = Modifier
.fillMaxHeight()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
UserAvatar(
username = RevoltAPI.userCache[RevoltAPI.selfId]?.let {
User.resolveDefaultName(
it
)
}
?: "",
presence = presenceFromStatus(
RevoltAPI.userCache[RevoltAPI.selfId]?.status?.presence
?: ""
),
userId = RevoltAPI.selfId ?: "",
avatar = RevoltAPI.userCache[RevoltAPI.selfId]?.avatar,
size = 48.dp,
presenceSize = 16.dp,
onClick = {
viewModel.navigateToServer("home", navController)
},
onLongClick = onShowStatusSheet,
modifier = Modifier
.padding(8.dp)
.size(48.dp)
)
DirectMessages.unreadDMs().forEach {
when (it.channelType) {
ChannelType.Group -> GroupIcon(
name = it.name ?: "?",
size = 48.dp,
onClick = {
it.id?.let { id ->
viewModel.navigateToServer(
"home",
navController
)
viewModel.navigateToChannel(
id,
navController
)
}
},
icon = it.icon,
modifier = Modifier
.padding(8.dp)
.size(48.dp)
)
else -> {
val partner =
if (it.channelType == ChannelType.DirectMessage) RevoltAPI.userCache[ChannelUtils.resolveDMPartner(
it
)] else null
UserAvatar(
username = partner?.let { p ->
User.resolveDefaultName(
p
)
} ?: it.name ?: "?",
presence = presenceFromStatus(
partner?.status?.presence ?: ""
),
userId = partner?.id ?: it.id ?: "",
avatar = partner?.avatar ?: it.icon,
size = 48.dp,
presenceSize = 16.dp,
onClick = {
it.id?.let { id ->
viewModel.navigateToServer(
"home",
navController
)
viewModel.navigateToChannel(
id,
navController
)
}
},
modifier = Modifier modifier = Modifier
.fillMaxHeight() .padding(8.dp)
.verticalScroll(rememberScrollState()), .size(48.dp)
horizontalAlignment = Alignment.CenterHorizontally )
) {
UserAvatar(
username = RevoltAPI.userCache[RevoltAPI.selfId]?.let {
User.resolveDefaultName(
it
)
}
?: "",
presence = presenceFromStatus(
RevoltAPI.userCache[RevoltAPI.selfId]?.status?.presence
?: ""
),
userId = RevoltAPI.selfId ?: "",
avatar = RevoltAPI.userCache[RevoltAPI.selfId]?.avatar,
size = 48.dp,
presenceSize = 16.dp,
onClick = {
viewModel.navigateToServer("home", navController)
},
onLongClick = {
showStatusSheet = true
},
modifier = Modifier
.padding(8.dp)
.size(48.dp)
)
DirectMessages.unreadDMs().forEach {
when (it.channelType) {
ChannelType.Group -> GroupIcon(
name = it.name ?: "?",
size = 48.dp,
onClick = {
it.id?.let { id ->
viewModel.navigateToServer(
"home",
navController
)
viewModel.navigateToChannel(
id,
navController
)
}
},
icon = it.icon,
modifier = Modifier
.padding(8.dp)
.size(48.dp)
)
else -> {
val partner =
if (it.channelType == ChannelType.DirectMessage) RevoltAPI.userCache[ChannelUtils.resolveDMPartner(
it
)] else null
UserAvatar(
username = partner?.let { p ->
User.resolveDefaultName(
p
)
} ?: it.name ?: "?",
presence = presenceFromStatus(
partner?.status?.presence ?: ""
),
userId = partner?.id ?: it.id ?: "",
avatar = partner?.avatar ?: it.icon,
size = 48.dp,
presenceSize = 16.dp,
onClick = {
it.id?.let { id ->
viewModel.navigateToServer(
"home",
navController
)
viewModel.navigateToChannel(
id,
navController
)
}
},
modifier = Modifier
.padding(8.dp)
.size(48.dp)
)
}
}
}
ServerDrawerSeparator()
// This seems to confuse the formatter, here's what it does:
// - Take the list of servers and filter them by the ones that are in the ordering.
// - Sort the servers that are in the ordering using the ordering.
// - Add the servers that aren't in the ordering to the end of the list.
// - Sort the servers that aren't in the ordering by their ID (creation order).
((RevoltAPI.serverCache.values.filter {
SyncedSettings.ordering.servers.contains(
it.id
)
}
.sortedBy { SyncedSettings.ordering.servers.indexOf(it.id) }) + (RevoltAPI.serverCache.values.filter {
!SyncedSettings.ordering.servers.contains(
it.id
)
}.sortedBy { it.id }
))
.forEach { server ->
if (server.id == null || server.name == null) return@forEach
DrawerServer(
iconId = server.icon?.id,
serverName = server.name,
hasUnreads = RevoltAPI.unreads.serverHasUnread(
server.id
),
onLongClick = {
serverContextSheetTarget = server.id
showServerContextSheet = true
},
) {
viewModel.navigateToServer(
server.id,
navController
)
}
}
DrawerServerlikeIcon(
onClick = {
showAddServerSheet = true
}
) {
Icon(
Icons.Default.Add,
contentDescription = stringResource(id = R.string.server_plus_alt),
modifier = Modifier.padding(4.dp)
)
}
}
Crossfade(
targetState = viewModel.currentServer,
label = "Channel List"
) {
ChannelList(
serverId = it,
currentDestination = navController.currentDestination?.route,
currentChannel = viewModel.currentChannel,
onChannelClick = { channelId ->
viewModel.navigateToChannel(channelId, navController)
scope.launch { drawerState.close() }
},
onSpecialClick = { destination ->
viewModel.navigateToSpecial(destination, navController)
scope.launch { drawerState.close() }
},
onServerSheetOpenFor = { target ->
serverContextSheetTarget = target
showServerContextSheet = true
},
)
}
} }
} }
} }
},
content = {
Column(Modifier.fillMaxSize()) {
NavHost(navController = navController, startDestination = "home") {
composable("home") {
BackHandler {
drawerBackHandler()
}
HomeScreen(navController = topNav)
}
composable("channel/{channelId}") { backStackEntry -> ServerDrawerSeparator()
BackHandler {
drawerBackHandler()
}
val channelId = backStackEntry.arguments?.getString("channelId") // This seems to confuse the formatter, here's what it does:
if (channelId != null) { // - Take the list of servers and filter them by the ones that are in the ordering.
ChannelScreen( // - Sort the servers that are in the ordering using the ordering.
navController = navController, // - Add the servers that aren't in the ordering to the end of the list.
channelId = channelId, // - Sort the servers that aren't in the ordering by their ID (creation order).
onToggleDrawer = { ((RevoltAPI.serverCache.values.filter {
scope.launch { SyncedSettings.ordering.servers.contains(
if (drawerState.isOpen) drawerState.close() it.id
else drawerState.open() )
} }
}, .sortedBy { SyncedSettings.ordering.servers.indexOf(it.id) }) + (RevoltAPI.serverCache.values.filter {
onUserSheetOpenFor = { target, server -> !SyncedSettings.ordering.servers.contains(
userContextSheetTarget = target it.id
userContextSheetServer = server )
}.sortedBy { it.id }
))
.forEach { server ->
if (server.id == null || server.name == null) return@forEach
showUserContextSheet = true DrawerServer(
}, iconId = server.icon?.id,
) serverName = server.name,
} hasUnreads = RevoltAPI.unreads.serverHasUnread(
} server.id
),
composable("no_current_channel") { onLongClick = {
BackHandler { /*serverContextSheetTarget = server.id
drawerBackHandler() showServerContextSheet = true*/
} onShowServerContextSheet(server.id)
},
NoCurrentChannelScreen() ) {
} viewModel.navigateToServer(
server.id,
dialog("report/message/{messageId}") { backStackEntry -> navController
val messageId = backStackEntry.arguments?.getString("messageId") )
if (messageId != null) {
ReportMessageDialog(
navController = navController,
messageId = messageId
)
}
} }
} }
DrawerServerlikeIcon(
onClick = onShowAddServerSheet,
) {
Icon(
Icons.Default.Add,
contentDescription = stringResource(id = R.string.server_plus_alt),
modifier = Modifier.padding(4.dp)
)
} }
}) }
Crossfade(
targetState = viewModel.currentServer,
label = "Channel List"
) {
ChannelList(
serverId = it,
currentDestination = navController.currentDestination?.route,
currentChannel = viewModel.currentChannel,
onChannelClick = { channelId ->
viewModel.navigateToChannel(channelId, navController)
scope.launch { drawerState?.close() }
},
onSpecialClick = { destination ->
viewModel.navigateToSpecial(destination, navController)
scope.launch { drawerState?.close() }
},
onServerSheetOpenFor = { target ->
onShowServerContextSheet(target)
},
)
}
}
}
}
@Composable
fun RowScope.ChannelNavigator(
navController: NavHostController,
topNav: NavController,
useDrawer: Boolean,
drawerBackHandler: () -> Unit,
drawerState: DrawerState? = null,
onShowUserContextSheet: (String, String?) -> Unit,
) {
val scope = rememberCoroutineScope()
Column(Modifier.then(if (useDrawer) Modifier.fillMaxSize() else Modifier.weight(0.7f))) {
NavHost(navController = navController, startDestination = "home") {
composable("home") {
BackHandler(enabled = useDrawer) {
drawerBackHandler()
}
HomeScreen(navController = topNav)
}
composable("channel/{channelId}") { backStackEntry ->
BackHandler(enabled = useDrawer) {
drawerBackHandler()
}
val channelId = backStackEntry.arguments?.getString("channelId")
if (channelId != null) {
ChannelScreen(
navController = navController,
channelId = channelId,
onToggleDrawer = {
scope.launch {
if (drawerState?.isOpen == true) drawerState.close()
else drawerState?.open()
}
},
onUserSheetOpenFor = { target, server ->
onShowUserContextSheet(target, server)
},
useDrawer = useDrawer
)
}
}
composable("no_current_channel") {
BackHandler(enabled = useDrawer) {
drawerBackHandler()
}
NoCurrentChannelScreen()
}
dialog("report/message/{messageId}") { backStackEntry ->
val messageId = backStackEntry.arguments?.getString("messageId")
if (messageId != null) {
ReportMessageDialog(
navController = navController,
messageId = messageId
)
}
}
}
} }
} }

View File

@ -101,6 +101,7 @@ fun ChannelScreen(
channelId: String, channelId: String,
onToggleDrawer: () -> Unit, onToggleDrawer: () -> Unit,
onUserSheetOpenFor: (String, String?) -> Unit, onUserSheetOpenFor: (String, String?) -> Unit,
useDrawer: Boolean,
viewModel: ChannelScreenViewModel = viewModel() viewModel: ChannelScreenViewModel = viewModel()
) { ) {
val channel = viewModel.activeChannel val channel = viewModel.activeChannel
@ -238,7 +239,8 @@ fun ChannelScreen(
onChannelClick = { onChannelClick = {
channelInfoSheetShown = true channelInfoSheetShown = true
}, },
onToggleDrawer = onToggleDrawer onToggleDrawer = onToggleDrawer,
useDrawer = useDrawer,
) )
val isScrolledToBottom = remember(lazyListState) { val isScrolledToBottom = remember(lazyListState) {