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.material:material'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.material3:material3-window-size-class'
implementation "androidx.compose.ui:ui-tooling-preview"
implementation "androidx.compose.runtime:runtime-livedata"
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.material3.MaterialTheme
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.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -45,6 +48,7 @@ import io.sentry.android.core.SentryAndroid
@AndroidEntryPoint
class MainActivity : FragmentActivity() {
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -56,7 +60,8 @@ class MainActivity : FragmentActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false)
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)
@Composable
fun AppEntrypoint() {
fun AppEntrypoint(windowSizeClass: WindowSizeClass) {
val navController = rememberNavController()
RevoltTheme(
@ -126,7 +131,7 @@ fun AppEntrypoint() {
}
composable("register/onboarding") { OnboardingScreen(navController) }
composable("chat") { ChatRouterScreen(navController) }
composable("chat") { ChatRouterScreen(navController, windowSizeClass) }
composable("settings") { SettingsScreen(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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@ -31,6 +32,7 @@ fun ChannelHeader(
channel: Channel,
onChannelClick: (String) -> Unit,
onToggleDrawer: () -> Unit,
useDrawer: Boolean,
) {
Row(
modifier = Modifier
@ -41,17 +43,26 @@ fun ChannelHeader(
.padding(vertical = 4.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = {
onToggleDrawer()
}) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.menu),
if (useDrawer) {
IconButton(onClick = {
onToggleDrawer()
}) {
Icon(
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 {
ChannelIcon(
channelType = it,

View File

@ -5,6 +5,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
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.DismissibleDrawerSheet
import androidx.compose.material3.DismissibleNavigationDrawer
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@ -27,6 +29,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState
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.LaunchedEffect
import androidx.compose.runtime.getValue
@ -46,6 +50,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.dialog
@ -181,7 +186,11 @@ class ChatRouterViewModel @Inject constructor(
ExperimentalMaterial3Api::class
)
@Composable
fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hiltViewModel()) {
fun ChatRouterScreen(
topNav: NavController,
windowSizeClass: WindowSizeClass,
viewModel: ChatRouterViewModel = hiltViewModel()
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val keyboardController = LocalSoftwareKeyboardController.current
@ -206,6 +215,8 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hil
var userContextSheetTarget by remember { mutableStateOf("") }
var userContextSheetServer by remember { mutableStateOf<String?>(null) }
var useTabletAwareUI by remember { mutableStateOf(false) }
val drawerBackHandler = remember {
{
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) {
AlertDialog(
onDismissRequest = {},
@ -408,241 +427,329 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = hil
})
}
DismissibleNavigationDrawer(
drawerState = drawerState,
drawerContent = {
DismissibleDrawerSheet(
drawerContainerColor = Color.Transparent,
) {
Column(Modifier.fillMaxWidth()) {
Row {
Column(
if (useTabletAwareUI) {
Row {
Sidebar(
viewModel = viewModel,
navController = navController,
onShowStatusSheet = {
showStatusSheet = true
},
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
.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 = {
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
},
)
}
.padding(8.dp)
.size(48.dp)
)
}
}
}
},
content = {
Column(Modifier.fillMaxSize()) {
NavHost(navController = navController, startDestination = "home") {
composable("home") {
BackHandler {
drawerBackHandler()
}
HomeScreen(navController = topNav)
}
composable("channel/{channelId}") { backStackEntry ->
BackHandler {
drawerBackHandler()
}
ServerDrawerSeparator()
val channelId = backStackEntry.arguments?.getString("channelId")
if (channelId != null) {
ChannelScreen(
navController = navController,
channelId = channelId,
onToggleDrawer = {
scope.launch {
if (drawerState.isOpen) drawerState.close()
else drawerState.open()
}
},
onUserSheetOpenFor = { target, server ->
userContextSheetTarget = target
userContextSheetServer = server
// 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
showUserContextSheet = true
},
)
}
}
composable("no_current_channel") {
BackHandler {
drawerBackHandler()
}
NoCurrentChannelScreen()
}
dialog("report/message/{messageId}") { backStackEntry ->
val messageId = backStackEntry.arguments?.getString("messageId")
if (messageId != null) {
ReportMessageDialog(
navController = navController,
messageId = messageId
)
}
DrawerServer(
iconId = server.icon?.id,
serverName = server.name,
hasUnreads = RevoltAPI.unreads.serverHasUnread(
server.id
),
onLongClick = {
/*serverContextSheetTarget = server.id
showServerContextSheet = true*/
onShowServerContextSheet(server.id)
},
) {
viewModel.navigateToServer(
server.id,
navController
)
}
}
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,
onToggleDrawer: () -> Unit,
onUserSheetOpenFor: (String, String?) -> Unit,
useDrawer: Boolean,
viewModel: ChannelScreenViewModel = viewModel()
) {
val channel = viewModel.activeChannel
@ -238,7 +239,8 @@ fun ChannelScreen(
onChannelClick = {
channelInfoSheetShown = true
},
onToggleDrawer = onToggleDrawer
onToggleDrawer = onToggleDrawer,
useDrawer = useDrawer,
)
val isScrolledToBottom = remember(lazyListState) {