diff --git a/app/src/main/java/chat/revolt/activities/MainActivity.kt b/app/src/main/java/chat/revolt/activities/MainActivity.kt index e6e90371..dafe98a3 100644 --- a/app/src/main/java/chat/revolt/activities/MainActivity.kt +++ b/app/src/main/java/chat/revolt/activities/MainActivity.kt @@ -33,6 +33,7 @@ import chat.revolt.screens.about.AboutScreen import chat.revolt.screens.about.AttributionScreen import chat.revolt.screens.chat.ChatRouterScreen import chat.revolt.screens.chat.dialogs.FeedbackDialog +import chat.revolt.screens.labs.LabsRootScreen import chat.revolt.screens.login.LoginGreetingScreen import chat.revolt.screens.login.LoginScreen import chat.revolt.screens.login.MfaScreen @@ -159,6 +160,8 @@ fun AppEntrypoint(windowSizeClass: WindowSizeClass) { composable("about") { AboutScreen(navController) } composable("about/oss") { AttributionScreen(navController) } + + composable("labs") { LabsRootScreen(navController) } } } } diff --git a/app/src/main/java/chat/revolt/api/internals/SpecialUsers.kt b/app/src/main/java/chat/revolt/api/internals/SpecialUsers.kt index 886137b6..f44a492d 100644 --- a/app/src/main/java/chat/revolt/api/internals/SpecialUsers.kt +++ b/app/src/main/java/chat/revolt/api/internals/SpecialUsers.kt @@ -4,12 +4,14 @@ import android.content.Context import android.graphics.RuntimeShader import android.os.Build import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush as AndroidBrush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ShaderBrush import org.intellij.lang.annotations.Language +import androidx.compose.ui.graphics.Brush as AndroidBrush object SpecialUsers { + val JENNIFER = "01F1WKM5TK2V6KCZWR6DGBJDTZ" + val PLATFORM_MODERATION_USER = "01FC17E1WTM2BGE4F3ARN3FDAF" val TRUSTED_MODERATION_BOTS = listOf( diff --git a/app/src/main/java/chat/revolt/api/settings/FeatureFlags.kt b/app/src/main/java/chat/revolt/api/settings/FeatureFlags.kt index 3e933872..4befa33b 100644 --- a/app/src/main/java/chat/revolt/api/settings/FeatureFlags.kt +++ b/app/src/main/java/chat/revolt/api/settings/FeatureFlags.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import chat.revolt.api.RevoltAPI +import chat.revolt.api.internals.SpecialUsers annotation class FeatureFlag(val name: String) annotation class Treatment(val description: String) @@ -19,6 +20,14 @@ sealed class ClosedBetaAccessControlVariates { data object Unrestricted : ClosedBetaAccessControlVariates() } +@FeatureFlag("LabsAccessControl") +sealed class LabsAccessControlVariates { + @Treatment( + "Restrict access to Labs to users that meet certain or all criteria (implementation-specific)" + ) + data class Restricted(val predicate: () -> Boolean) : LabsAccessControlVariates() +} + object FeatureFlags { @FeatureFlag("ClosedBetaAccessControl") var closedBetaAccessControl by mutableStateOf( @@ -26,4 +35,11 @@ object FeatureFlags { RevoltAPI.channelCache.containsKey("01H7X2KRB0CA4QDSMB4N7WGERF") } ) + + @FeatureFlag("LabsAccessControl") + var labsAccessControl by mutableStateOf( + LabsAccessControlVariates.Restricted { + RevoltAPI.selfId == SpecialUsers.JENNIFER + } + ) } diff --git a/app/src/main/java/chat/revolt/screens/labs/LabsHomeScreen.kt b/app/src/main/java/chat/revolt/screens/labs/LabsHomeScreen.kt new file mode 100644 index 00000000..adf07e92 --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/labs/LabsHomeScreen.kt @@ -0,0 +1,134 @@ +package chat.revolt.screens.labs + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController + +enum class LabsHomeScreenTab { + Home, + Mockups, +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LabsHomeScreen(navController: NavController) { + val currentTab = rememberSaveable { mutableStateOf(LabsHomeScreenTab.Home) } + + Scaffold( + topBar = { + TopAppBar( + scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(), + title = { + Text("Labs") + } + ) + }, + bottomBar = { + NavigationBar { + NavigationBarItem( + selected = currentTab.value == LabsHomeScreenTab.Home, + onClick = { currentTab.value = LabsHomeScreenTab.Home }, + icon = { + Icon( + imageVector = Icons.Default.Home, + contentDescription = null, + ) + }, + label = { + Text("Home") + } + ) + NavigationBarItem( + selected = currentTab.value == LabsHomeScreenTab.Mockups, + onClick = { currentTab.value = LabsHomeScreenTab.Mockups }, + icon = { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = null, + ) + }, + label = { + Text("UI Mockups") + } + ) + } + } + ) { + Box(Modifier.padding(it)) { + when (currentTab.value) { + LabsHomeScreenTab.Home -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + "Hey, this is kinda secret 🤫", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(8.dp)) + Text( + "Remember, everything you see here can be broken and is not guaranteed to work.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(8.dp)) + Text( + "Don't tell anyone about anything either, okay?", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } + } + + LabsHomeScreenTab.Mockups -> { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + ListItem( + headlineContent = { + Text("Call Screen") + }, + modifier = Modifier.clickable { + navController.navigate("mockups/call") + } + ) + Divider() + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/labs/LabsRootScreen.kt b/app/src/main/java/chat/revolt/screens/labs/LabsRootScreen.kt new file mode 100644 index 00000000..69d244a1 --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/labs/LabsRootScreen.kt @@ -0,0 +1,71 @@ +package chat.revolt.screens.labs + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import chat.revolt.api.settings.FeatureFlags +import chat.revolt.api.settings.LabsAccessControlVariates +import chat.revolt.screens.labs.ui.mockups.CallScreenMockup + +annotation class LabsFeature + +@Composable +fun LabsGuard(onTurnBack: () -> Unit = {}, content: @Composable () -> Unit) { + if (FeatureFlags.labsAccessControl is LabsAccessControlVariates.Restricted && + (FeatureFlags.labsAccessControl as LabsAccessControlVariates.Restricted).predicate().not() + ) { + AlertDialog( + onDismissRequest = { onTurnBack() }, + confirmButton = { + TextButton(onClick = { onTurnBack() }) { + Text("Turn back") + } + }, + title = { + Text("You don't have access to Labs.") + }, + text = { + Text("Labs is where we test new features. However, these features may be unstable and may not work as expected. Hence, access to Labs is restricted.") + } + ) + } else { + content() + } +} + +@Composable +fun LabsRootScreen(topNav: NavController) { + val labsNav = rememberNavController() + + Column( + modifier = Modifier + .fillMaxSize() + ) { + LabsGuard( + onTurnBack = { + topNav.popBackStack() + } + ) { + NavHost( + navController = labsNav, + startDestination = "home", + ) { + composable("home") { + LabsHomeScreen(labsNav) + } + + composable("mockups/call") { + CallScreenMockup() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/labs/ui/mockups/CallScreenMockup.kt b/app/src/main/java/chat/revolt/screens/labs/ui/mockups/CallScreenMockup.kt new file mode 100644 index 00000000..580b71ca --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/labs/ui/mockups/CallScreenMockup.kt @@ -0,0 +1,262 @@ +package chat.revolt.screens.labs.ui.mockups + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.EaseInOutExpo +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import chat.revolt.R +import chat.revolt.api.schemas.ChannelType +import chat.revolt.components.screens.chat.ChannelIcon +import chat.revolt.screens.labs.LabsFeature + +@LabsFeature +@Composable +fun CallScreenMockup() { + var showOptions by remember { mutableStateOf(false) } + var pushToTalk by remember { mutableStateOf(false) } + + val interactionSource = remember { MutableInteractionSource() } + val pushToTalkIsHeld by interactionSource.collectIsPressedAsState() + + val pttBackground by animateColorAsState( + targetValue = if (pushToTalkIsHeld) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.secondaryContainer + }, + animationSpec = tween(200, easing = EaseInOutExpo), + label = "pttBackground" + ) + val pttText by animateColorAsState( + targetValue = if (pushToTalkIsHeld) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSecondaryContainer + }, + animationSpec = tween(200, easing = EaseInOutExpo), + label = "pttText" + ) + + if (showOptions) { + Dialog( + onDismissRequest = { showOptions = false } + ) { + BoxWithConstraints { + Column( + modifier = Modifier + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surface) + .padding(24.dp) + .width(maxWidth * 0.85f) + .heightIn(max = maxHeight * 0.85f) + ) { + Row { + Checkbox( + checked = pushToTalk, + onCheckedChange = { pushToTalk = it }, + modifier = Modifier + .padding(16.dp) + ) + Text( + text = "Push to talk", + modifier = Modifier + .padding(16.dp) + ) + } + } + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier + .weight(1f) + .padding(vertical = 4.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer( + modifier = Modifier + .height(48.dp) + .width(12.dp) + ) + + ChannelIcon( + channelType = ChannelType.VoiceChannel, + modifier = Modifier.alpha(0.6f) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Voice Channel", + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Icon( + imageVector = Icons.Default.KeyboardArrowRight, + contentDescription = stringResource(R.string.menu), + modifier = Modifier + .size(18.dp) + .alpha(0.4f) + ) + } + } + IconButton(onClick = { + showOptions = true + }) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null + ) + } + Spacer(modifier = Modifier.width(4.dp)) + } + Column( + modifier = Modifier + .weight(1f) + ) { + } + if (pushToTalk) { + Row( + modifier = Modifier + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + TextButton( + onClick = {}, + ) { + Icon( + painter = painterResource(R.drawable.ic_headphones_24dp), + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Galaxy Buds Live", + ) + } + } + Row( + modifier = Modifier + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Button( + onClick = {}, + colors = ButtonDefaults.buttonColors( + containerColor = pttBackground, + contentColor = pttText + ), + interactionSource = interactionSource, + modifier = Modifier + .weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_gesture_tap_button_24dp), + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Hold to talk", + ) + } + } + } + Row( + modifier = Modifier + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (!pushToTalk) { + Button( + onClick = {} + ) { + Icon( + painter = painterResource(R.drawable.ic_microphone_off_24dp), + contentDescription = null + ) + } + } + + Button( + onClick = {}, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + modifier = Modifier + .weight(1f) + ) { + Text( + text = "Leave call", + ) + } + + if (!pushToTalk) { + Button( + onClick = {} + ) { + Icon( + painter = painterResource(R.drawable.ic_headphones_24dp), + contentDescription = null + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/settings/SettingsScreen.kt b/app/src/main/java/chat/revolt/screens/settings/SettingsScreen.kt index 4c2acfa2..0297ce92 100644 --- a/app/src/main/java/chat/revolt/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/chat/revolt/screens/settings/SettingsScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DateRange @@ -27,7 +28,9 @@ import androidx.navigation.NavController import chat.revolt.BuildConfig import chat.revolt.R import chat.revolt.api.RevoltAPI +import chat.revolt.api.settings.FeatureFlags import chat.revolt.api.settings.GlobalState +import chat.revolt.api.settings.LabsAccessControlVariates import chat.revolt.components.generic.PageHeader import chat.revolt.components.generic.SheetClickable import chat.revolt.components.screens.settings.SelfUserOverview @@ -189,6 +192,25 @@ fun SettingsScreen( } } + if (FeatureFlags.labsAccessControl is LabsAccessControlVariates.Restricted && + (FeatureFlags.labsAccessControl as LabsAccessControlVariates.Restricted).predicate() + ) { + SheetClickable( + icon = { modifier -> + Icon( + imageVector = Icons.Default.ArrowForward, + contentDescription = null, + modifier = modifier + ) + }, + label = { textStyle -> + Text(text = "Labs", style = textStyle) + }, + ) { + navController.navigate("labs") + } + } + SheetClickable( icon = { modifier -> Icon( diff --git a/app/src/main/res/drawable/ic_gesture_tap_button_24dp.xml b/app/src/main/res/drawable/ic_gesture_tap_button_24dp.xml new file mode 100644 index 00000000..637e2161 --- /dev/null +++ b/app/src/main/res/drawable/ic_gesture_tap_button_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_headphones_24dp.xml b/app/src/main/res/drawable/ic_headphones_24dp.xml new file mode 100644 index 00000000..2d021944 --- /dev/null +++ b/app/src/main/res/drawable/ic_headphones_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_headphones_off_24dp.xml b/app/src/main/res/drawable/ic_headphones_off_24dp.xml new file mode 100644 index 00000000..6eccf017 --- /dev/null +++ b/app/src/main/res/drawable/ic_headphones_off_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_microphone_24dp.xml b/app/src/main/res/drawable/ic_microphone_24dp.xml new file mode 100644 index 00000000..969e7d9a --- /dev/null +++ b/app/src/main/res/drawable/ic_microphone_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_microphone_off_24dp.xml b/app/src/main/res/drawable/ic_microphone_off_24dp.xml new file mode 100644 index 00000000..6e644527 --- /dev/null +++ b/app/src/main/res/drawable/ic_microphone_off_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_phone_hangup_24dp.xml b/app/src/main/res/drawable/ic_phone_hangup_24dp.xml new file mode 100644 index 00000000..d01e47cd --- /dev/null +++ b/app/src/main/res/drawable/ic_phone_hangup_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file