diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt index 506c772d..b3c0f364 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt @@ -70,6 +70,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.ButtonDefaults import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -91,296 +92,298 @@ fun ActiveProfileScreen( ), ) - when(val activeProfileUIState = activeProfileViewModel.activeProfileUIState) { - ActiveProfileUIState.Error -> { - ErrorState() - } - is ActiveProfileUIState.Loaded -> { - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.profile) - ) - }, - navigationIcon = { - IconButton( - onClick = { - onNavigateBack.invoke() - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back" + ScreenStateTransition(activeProfileViewModel.activeProfileUIState) { uiState -> + when (val activeProfileUIState = uiState) { + ActiveProfileUIState.Error -> { + ErrorState() + } + is ActiveProfileUIState.Loaded -> { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.profile) ) - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding) - .readableContent() - ) { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), - horizontalAlignment = Alignment.CenterHorizontally - ) { - item { - Row( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) - ) { - ProfileAvatar( - profile = activeProfileUIState.localNostrEvent.profile, - publicKey = activeUserPublicKey - ) - - Column { - activeProfileUIState.localNostrEvent.profile?.humanReadableNameOrPubkey()?.let { humanReadableNameOrPubkey -> - Text( - text = humanReadableNameOrPubkey, - style = MaterialTheme.typography.titleLarge - ) - } - activeProfileUIState.localNostrEvent.profile?.about?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium - ) - } - - Spacer( - modifier = Modifier.height(MaterialTheme.spacing.space125) - ) - - Text( - text = activeUserPublicKey.hexToNpubHrp(), - style = MaterialTheme.typography.labelSmall - ) - } - } - } - - item { - Row( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125) - ) { - Spacer( - modifier = Modifier.weight(1f) - ) - - TextButton( + }, + navigationIcon = { + IconButton( onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Edit profile") - ) + onNavigateBack.invoke() } ) { Icon( - Icons.Default.Edit, - contentDescription = "Edit profile" - ) - - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - - Text( - stringResource(Res.string.edit_profile) + Icons.Default.ArrowBack, + contentDescription = "Back" ) } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding) + .readableContent() + ) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Row( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + ProfileAvatar( + profile = activeProfileUIState.localNostrEvent.profile, + publicKey = activeUserPublicKey + ) - Spacer( - modifier = Modifier.weight(1f) - ) + Column { + activeProfileUIState.localNostrEvent.profile?.humanReadableNameOrPubkey()?.let { humanReadableNameOrPubkey -> + Text( + text = humanReadableNameOrPubkey, + style = MaterialTheme.typography.titleLarge + ) + } + activeProfileUIState.localNostrEvent.profile?.about?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium + ) + } - // The list this sits in is otherwise TextButtons -- - // edit profile, key packages, change account, profile - // keys, network relays. Two of the seven were filled - // Buttons, which is M3's highest emphasis and is meant - // for one action per screen. Sharing is the useful one, - // so it keeps medium emphasis rather than maximum. - FilledTonalButton( + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space125) + ) + + Text( + text = activeUserPublicKey.hexToNpubHrp(), + style = MaterialTheme.typography.labelSmall + ) + } + } + } + + item { + Row( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125) + ) { + Spacer( + modifier = Modifier.weight(1f) + ) + + TextButton( + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Edit profile") + ) + } + ) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit profile" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text( + stringResource(Res.string.edit_profile) + ) + } + + Spacer( + modifier = Modifier.weight(1f) + ) + + // The list this sits in is otherwise TextButtons -- + // edit profile, key packages, change account, profile + // keys, network relays. Two of the seven were filled + // Buttons, which is M3's highest emphasis and is meant + // for one action per screen. Sharing is the useful one, + // so it keeps medium emphasis rather than maximum. + FilledTonalButton( + onClick = { + onNavigateToRoute.invoke( + ShareProfileRoute( + activeUserPublicKey = activeUserPublicKey, + nostrEventId = nostrEventId + ) + ) + }, + ) { + Icon( + Icons.Default.QrCode, + contentDescription = "QR code" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + Text( + stringResource(Res.string.share_profile) + ) + } + + Spacer( + modifier = Modifier.weight(1f) + ) + } + } + + item { + TextButton( onClick = { onNavigateToRoute.invoke( - ShareProfileRoute( + KeyPackageManagementRoute( activeUserPublicKey = activeUserPublicKey, nostrEventId = nostrEventId ) ) - }, + } ) { + Icon( - Icons.Default.QrCode, - contentDescription = "QR code" + Icons.Default.Bento, + contentDescription = "Key packages" ) Spacer( modifier = Modifier.width(MaterialTheme.spacing.space125) ) + Text( - stringResource(Res.string.share_profile) + stringResource(Res.string.key_package_management) ) } - - Spacer( - modifier = Modifier.weight(1f) - ) } - } - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - KeyPackageManagementRoute( - activeUserPublicKey = activeUserPublicKey, - nostrEventId = nostrEventId + + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Change account") ) + } + ) { + + Icon( + Icons.Default.ImportExport, + contentDescription = "Change profile" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text( + stringResource(Res.string.change_account) ) } - ) { - - Icon( - Icons.Default.Bento, - contentDescription = "Key packages" - ) - - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - - Text( - stringResource(Res.string.key_package_management) - ) } - } - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Change account") + item { + TextButton( + onClick = { + + onNavigateToRoute.invoke( + ImplementationPendingRoute("Profile keys") + ) + } + ) { + Icon( + Icons.Default.Key, + contentDescription = "Profile keys" ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.profile_keys)) } - ) { - - Icon( - Icons.Default.ImportExport, - contentDescription = "Change profile" - ) - - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - - Text( - stringResource(Res.string.change_account) - ) } - } + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Network relays") + ) - item { - TextButton( - onClick = { + } + ) { + Icon( + Icons.Default.Hub, + contentDescription = "Network relays" + ) - onNavigateToRoute.invoke( - ImplementationPendingRoute("Profile keys") + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text( + stringResource(Res.string.network_relays) ) } - ) { - Icon( - Icons.Default.Key, - contentDescription = "Profile keys" - ) - - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - - Text(stringResource(Res.string.profile_keys)) } - } - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Network relays") + item { + // Signing out was the *other* filled Button -- the most + // prominent control on the screen given to its most + // destructive action. It now matches how leaving and + // deleting a group are already treated in + // ChatRoomDetailScreen: a TextButton in the error colour. + TextButton( + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ), + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Sign out") + ) + } + ) { + Icon( + Icons.Default.Logout, + contentDescription = "Logout" ) - } - ) { - Icon( - Icons.Default.Hub, - contentDescription = "Network relays" - ) + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - - Text( - stringResource(Res.string.network_relays) - ) - } - } - - item { - // Signing out was the *other* filled Button -- the most - // prominent control on the screen given to its most - // destructive action. It now matches how leaving and - // deleting a group are already treated in - // ChatRoomDetailScreen: a TextButton in the error colour. - TextButton( - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ), - onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Sign out") + Text( + stringResource(Res.string.sign_out) ) } - ) { - Icon( - Icons.Default.Logout, - contentDescription = "Logout" - ) - - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - - Text( - stringResource(Res.string.sign_out) - ) } } } } } - } - ActiveProfileUIState.Loading -> { - LoadingDataIndicator() + ActiveProfileUIState.Loading -> { + LoadingDataIndicator() - LaunchedEffect(true) { - activeProfileViewModel.loadNostrFeed() + LaunchedEffect(true) { + activeProfileViewModel.loadNostrFeed() + } } - } - ActiveProfileUIState.NotFound -> { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(stringResource(Res.string.we_couldn_t_find_the_local_profile_please)) + ActiveProfileUIState.NotFound -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.we_couldn_t_find_the_local_profile_please)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt index f3019db9..794c09b0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt @@ -86,6 +86,7 @@ import mantra.composeapp.generated.resources.url import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -114,280 +115,282 @@ fun AddArtifactScreen( ) ) - when (val addArtifactUIState = addArtifactViewModel.addArtifactUIState) { - is AddArtifactUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(MaterialTheme.spacing.space600) - ) - Text( - text = addArtifactUIState.message, - ) - } - } - is AddArtifactUIState.Loaded -> { - val nameFieldState = rememberTextFieldState() - val urlFieldState = rememberTextFieldState() - val versionLabelFieldState = rememberTextFieldState("1.0") - - // The id of the group dialect this artifact is written in. Null until - // one is picked; dialects are defined from the group detail screen. - var selectedDialectId: String? by remember { mutableStateOf(null) } - - // The two things the form cannot ask for again. A dialect has to - // already exist and nothing here can create one; a shared key has to - // have been ceremonied and this is not where that happens. Either - // missing is a dead end rather than something to propose and be told - // about afterwards, and the button says so. - val canAddArtifact = selectedDialectId != null && addArtifactUIState.canSign - - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - - // imePadding: this screen has a text field, and without it the software - // keyboard covers whatever is being typed into. - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier.imePadding(), - topBar = { - TopAppBar( - title = { - addArtifactUIState.localChatRoom.RenderChatRoomTitleText() - }, - actions = { - - } + ScreenStateTransition(addArtifactViewModel.addArtifactUIState) { uiState -> + when (val addArtifactUIState = uiState) { + is AddArtifactUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) ) - }, - bottomBar = { - BottomAppBar( - actions = {}, - floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canAddArtifact) { - Modifier - } else { - // Looking unavailable is not being unavailable: - // without this a screen reader still announces - // a button it is happy to press. - Modifier.semantics { disabled() } - }, - containerColor = if (canAddArtifact) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canAddArtifact) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, - onClick = { - if (!canAddArtifact) return@ExtendedFloatingActionButton - - addArtifactViewModel.addArtifact( - localChatRoom = addArtifactUIState.localChatRoom, - nameField = nameFieldState, - urlField = urlFieldState, - versionLabelField = versionLabelFieldState, - dialectId = selectedDialectId, - onSuccess = { sessionId -> - // Onto the session rather than to the - // artifact. Nothing has been created yet - // -- the artifact appears when enough - // members sign -- so a detail screen for - // a row that does not exist would read as - // a failure. - onNavigateToRouteAndPopUpInclusive.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - ) - }, - onFailure = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Failed artifact") - ) - } - ) - - } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Propose artifact" - ) - Text(stringResource(Res.string.propose_artifact)) - } - } + Text( + text = addArtifactUIState.message, ) } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() - ) { - // TODO: Check that we have direct message relays for this user... - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(stringResource(Res.string.add_artifact_to_the_group_library)) + } + is AddArtifactUIState.Loaded -> { + val nameFieldState = rememberTextFieldState() + val urlFieldState = rememberTextFieldState() + val versionLabelFieldState = rememberTextFieldState("1.0") - if (!addArtifactUIState.canSign) { - Text( - text = "This group has no shared key, so it cannot sign an " + - "artifact into its library. Run a shared key ceremony first.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } + // The id of the group dialect this artifact is written in. Null until + // one is picked; dialects are defined from the group detail screen. + var selectedDialectId: String? by remember { mutableStateOf(null) } - OutlinedTextField( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor), - state = nameFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon( - Icons.Default.DriveFileRenameOutline, - contentDescription = "Name of the artifact" - ) - }, - label = { - Text( - text = stringResource(Res.string.name_of_artifact) - ) - }, - placeholder = { - Text( - text = stringResource(Res.string.eg_to_kill_a_mocking_bird) - ) + // The two things the form cannot ask for again. A dialect has to + // already exist and nothing here can create one; a shared key has to + // have been ceremonied and this is not where that happens. Either + // missing is a dead end rather than something to propose and be told + // about afterwards, and the button says so. + val canAddArtifact = selectedDialectId != null && addArtifactUIState.canSign + + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() + + // imePadding: this screen has a text field, and without it the software + // keyboard covers whatever is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + addArtifactUIState.localChatRoom.RenderChatRoomTitleText() }, + actions = { + + } ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canAddArtifact) { + Modifier + } else { + // Looking unavailable is not being unavailable: + // without this a screen reader still announces + // a button it is happy to press. + Modifier.semantics { disabled() } + }, + containerColor = if (canAddArtifact) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canAddArtifact) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!canAddArtifact) return@ExtendedFloatingActionButton - OutlinedTextField( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor), - state = urlFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon( - Icons.Default.Link, - contentDescription = "Link to the artifact" - ) - }, - label = { - Text( - text = stringResource(Res.string.url) - ) - }, - placeholder = { - Text( - text = stringResource(Res.string.eg_https_harper_com_2_kill_bird) - ) - }, - ) + addArtifactViewModel.addArtifact( + localChatRoom = addArtifactUIState.localChatRoom, + nameField = nameFieldState, + urlField = urlFieldState, + versionLabelField = versionLabelFieldState, + dialectId = selectedDialectId, + onSuccess = { sessionId -> + // Onto the session rather than to the + // artifact. Nothing has been created yet + // -- the artifact appears when enough + // members sign -- so a detail screen for + // a row that does not exist would read as + // a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed artifact") + ) + } + ) - OutlinedTextField( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor), - state = versionLabelFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon( - Icons.Default.LocalOffer, - contentDescription = "Verison label" - ) - }, - label = { - Text( - text = stringResource(Res.string.initial_version_label) - ) - }, - placeholder = { - Text( - text = stringResource(Res.string.eg_first_edition) - ) - }, - ) - - Text(stringResource(Res.string.source_dialect)) - - if (addArtifactUIState.dialects.isEmpty()) { - Text( - text = stringResource(Res.string.no_dialects_have_been_defined_in_this_group), - style = MaterialTheme.typography.bodySmall - ) - } else { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) - ) { - addArtifactUIState.dialects.forEach { dialect -> - FilterChip( - selected = selectedDialectId == dialect.id, - onClick = { selectedDialectId = dialect.id }, - label = { Text(dialect.name) } + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "Propose artifact" ) + Text(stringResource(Res.string.propose_artifact)) } } - } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) { + // TODO: Check that we have direct message relays for this user... + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.add_artifact_to_the_group_library)) - // TODO: Add Visibility + if (!addArtifactUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign an " + + "artifact into its library. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = nameFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.DriveFileRenameOutline, + contentDescription = "Name of the artifact" + ) + }, + label = { + Text( + text = stringResource(Res.string.name_of_artifact) + ) + }, + placeholder = { + Text( + text = stringResource(Res.string.eg_to_kill_a_mocking_bird) + ) + }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = urlFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.Link, + contentDescription = "Link to the artifact" + ) + }, + label = { + Text( + text = stringResource(Res.string.url) + ) + }, + placeholder = { + Text( + text = stringResource(Res.string.eg_https_harper_com_2_kill_bird) + ) + }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = versionLabelFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.LocalOffer, + contentDescription = "Verison label" + ) + }, + label = { + Text( + text = stringResource(Res.string.initial_version_label) + ) + }, + placeholder = { + Text( + text = stringResource(Res.string.eg_first_edition) + ) + }, + ) + + Text(stringResource(Res.string.source_dialect)) + + if (addArtifactUIState.dialects.isEmpty()) { + Text( + text = stringResource(Res.string.no_dialects_have_been_defined_in_this_group), + style = MaterialTheme.typography.bodySmall + ) + } else { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + addArtifactUIState.dialects.forEach { dialect -> + FilterChip( + selected = selectedDialectId == dialect.id, + onClick = { selectedDialectId = dialect.id }, + label = { Text(dialect.name) } + ) + } + } + } + + // TODO: Add Visibility + } } } } - } - AddArtifactUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - MaterialTheme.spacing.space250 - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { + AddArtifactUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { - Spacer( - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(Res.string.direct_message_detail), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(Res.string.direct_message_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) - Text( - text = stringResource(Res.string.direct_message_functionality_will_be_here), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) + Text( + text = stringResource(Res.string.direct_message_functionality_will_be_here), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) - LoadingDataIndicator( - fillScreen = false - ) + LoadingDataIndicator( + fillScreen = false + ) - Spacer( - modifier = Modifier.weight(2f) - ) + Spacer( + modifier = Modifier.weight(2f) + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt index 9cede369..614a4a1c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt @@ -67,6 +67,7 @@ import mantra.composeapp.generated.resources.add_chapter_to import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -96,206 +97,208 @@ fun AddChapterScreen( ) ) - when (val addChapterUIState = addChapterViewModel.addChapterUIState) { - is AddChapterUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) - Text(text = addChapterUIState.message) + ScreenStateTransition(addChapterViewModel.addChapterUIState) { uiState -> + when (val addChapterUIState = uiState) { + is AddChapterUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = addChapterUIState.message) + } } - } - is AddChapterUIState.Loaded -> { - val nameFieldState = rememberTextFieldState() - val originalTextFieldState = rememberTextFieldState() + is AddChapterUIState.Loaded -> { + val nameFieldState = rememberTextFieldState() + val originalTextFieldState = rememberTextFieldState() - // Live counts / paragraph (chunk) preview from the markdown text. - val originalText = originalTextFieldState.text.toString() - val wordCount = Markdown.wordCount(originalText) - val characterCount = Markdown.characterCount(originalText) - val paragraphCount = Markdown.splitParagraphs(originalText).size + // Live counts / paragraph (chunk) preview from the markdown text. + val originalText = originalTextFieldState.text.toString() + val wordCount = Markdown.wordCount(originalText) + val characterCount = Markdown.characterCount(originalText) + val paragraphCount = Markdown.splitParagraphs(originalText).size - // A chapter hangs off a version, and the group has to be able to - // sign; without both there is nothing this screen can propose. - val artifactVersion = addChapterUIState.artifactVersion + // A chapter hangs off a version, and the group has to be able to + // sign; without both there is nothing this screen can propose. + val artifactVersion = addChapterUIState.artifactVersion - // The chapter and a chunk per paragraph are signed in one session, - // and a session signs a bounded number of events. Past that the - // chapter has to be split in two, which is worth saying while there - // is still a cursor in the text rather than after a failed propose. - val tooManyChunks = paragraphCount > AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER + // The chapter and a chunk per paragraph are signed in one session, + // and a session signs a bounded number of events. Past that the + // chapter has to be split in two, which is worth saying while there + // is still a cursor in the text rather than after a failed propose. + val tooManyChunks = paragraphCount > AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER - val canProposeChapter = artifactVersion != null && - addChapterUIState.canSign && - paragraphCount > 0 && - !tooManyChunks + val canProposeChapter = artifactVersion != null && + addChapterUIState.canSign && + paragraphCount > 0 && + !tooManyChunks - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() - // imePadding: this screen has a text field, and without it the software - // keyboard covers whatever is being typed into. - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier.imePadding(), - topBar = { - TopAppBar( - title = { Text(stringResource(Res.string.add_chapter_to, addChapterUIState.artifact.name)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - ) - }, - bottomBar = { - BottomAppBar( - actions = {}, - floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canProposeChapter) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (canProposeChapter) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canProposeChapter) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, - onClick = { - if (artifactVersion == null || !canProposeChapter) { - return@ExtendedFloatingActionButton - } - - addChapterViewModel.addChapter( - localChatRoom = addChapterUIState.localChatRoom, - artifactVersion = artifactVersion, - nameField = nameFieldState, - originalTextField = originalTextFieldState, - onSuccess = { sessionId -> - // Onto the session rather than back to - // the artifact. Nothing has been created - // yet -- the chapter appears when enough - // members sign -- so landing on the list - // it is not in would read as a failure. - onNavigateToRouteAndPopUpInclusive.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - ) - }, - onFailure = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Failed chapter") - ) - } + // imePadding: this screen has a text field, and without it the software + // keyboard covers whatever is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.add_chapter_to, addChapterUIState.artifact.name)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" ) } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Propose chapter" - ) - Text(stringResource(Res.string.propose_chapter)) + }, + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canProposeChapter) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canProposeChapter) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canProposeChapter) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (artifactVersion == null || !canProposeChapter) { + return@ExtendedFloatingActionButton + } + + addChapterViewModel.addChapter( + localChatRoom = addChapterUIState.localChatRoom, + artifactVersion = artifactVersion, + nameField = nameFieldState, + originalTextField = originalTextFieldState, + onSuccess = { sessionId -> + // Onto the session rather than back to + // the artifact. Nothing has been created + // yet -- the chapter appears when enough + // members sign -- so landing on the list + // it is not in would read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed chapter") + ) + } + ) + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "Propose chapter" + ) + Text(stringResource(Res.string.propose_chapter)) + } } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) - ) { - if (!addChapterUIState.canSign) { - Text( - text = "This group has no shared key, so it cannot sign a " + - "chapter into existence. Run a shared key ceremony first.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } else if (artifactVersion == null) { - Text( - text = stringResource(Res.string.this_artifact_has_no_version_for_a_chapter), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } else if (tooManyChunks) { - Text( - text = "$paragraphCount chunks is more than the group can sign in " + - "one go. Split the chapter so that no part of it is over " + - "${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER} paragraphs.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error ) } - - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - state = nameFieldState, - leadingIcon = { - Icon( - Icons.Default.DriveFileRenameOutline, - contentDescription = "Name of the chapter" + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + if (!addChapterUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign a " + + "chapter into existence. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } else if (artifactVersion == null) { + Text( + text = stringResource(Res.string.this_artifact_has_no_version_for_a_chapter), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } else if (tooManyChunks) { + Text( + text = "$paragraphCount chunks is more than the group can sign in " + + "one go. Split the chapter so that no part of it is over " + + "${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER} paragraphs.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error ) - }, - label = { Text(stringResource(Res.string.chapter_name)) }, - placeholder = { Text(stringResource(Res.string.eg_chapter_1_the_beginning)) }, - ) - - OutlinedTextField( - modifier = Modifier.fillMaxWidth().weight(1f), - state = originalTextFieldState, - label = { Text(stringResource(Res.string.original_text_markdown)) }, - placeholder = { Text(stringResource(Res.string.paste_the_chapter_s_markdown_blank_lines)) }, - ) - - Text( - // The chunk count is what the group is asked to sign - // alongside the chapter, so it is a count of the work - // being proposed rather than a curiosity about the text. - text = "$wordCount words · $characterCount characters · " + - "$paragraphCount ${if (paragraphCount == 1) "chunk" else "chunks"} " + - "of ${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER}", - style = MaterialTheme.typography.labelMedium, - color = if (tooManyChunks) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.typography.labelMedium.color } - ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + state = nameFieldState, + leadingIcon = { + Icon( + Icons.Default.DriveFileRenameOutline, + contentDescription = "Name of the chapter" + ) + }, + label = { Text(stringResource(Res.string.chapter_name)) }, + placeholder = { Text(stringResource(Res.string.eg_chapter_1_the_beginning)) }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth().weight(1f), + state = originalTextFieldState, + label = { Text(stringResource(Res.string.original_text_markdown)) }, + placeholder = { Text(stringResource(Res.string.paste_the_chapter_s_markdown_blank_lines)) }, + ) + + Text( + // The chunk count is what the group is asked to sign + // alongside the chapter, so it is a count of the work + // being proposed rather than a curiosity about the text. + text = "$wordCount words · $characterCount characters · " + + "$paragraphCount ${if (paragraphCount == 1) "chunk" else "chunks"} " + + "of ${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER}", + style = MaterialTheme.typography.labelMedium, + color = if (tooManyChunks) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.typography.labelMedium.color + } + ) + } } } - } - AddChapterUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = stringResource(Res.string.add_chapter), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - LoadingDataIndicator(fillScreen = false) - Spacer(modifier = Modifier.weight(2f)) + AddChapterUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.add_chapter), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt index eabaa2a1..ebc75f23 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt @@ -70,6 +70,7 @@ import mantra.composeapp.generated.resources.propose_dialect import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -96,233 +97,235 @@ fun AddDialectScreen( ) ) - when (val addDialectUIState = addDialectViewModel.addDialectUIState) { - is AddDialectUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(MaterialTheme.spacing.space600) - ) - Text( - text = addDialectUIState.message, - ) - } - } - is AddDialectUIState.Loaded -> { - val nameFieldState = rememberTextFieldState() - val countryFieldState = rememberTextFieldState() - val languageFieldState = rememberTextFieldState() - - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - - // imePadding: this screen has a text field, and without it the software - // keyboard covers whatever is being typed into. - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier.imePadding(), - topBar = { - TopAppBar( - title = { - addDialectUIState.localChatRoom.RenderChatRoomTitleText() - }, - actions = { - - } + ScreenStateTransition(addDialectViewModel.addDialectUIState) { uiState -> + when (val addDialectUIState = uiState) { + is AddDialectUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) ) - }, - bottomBar = { - BottomAppBar( - actions = {}, - floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (addDialectUIState.canSign) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (addDialectUIState.canSign) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (addDialectUIState.canSign) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, - onClick = { - if (!addDialectUIState.canSign) return@ExtendedFloatingActionButton - - addDialectViewModel.addDialect( - localChatRoom = addDialectUIState.localChatRoom, - nameField = nameFieldState, - countryField = countryFieldState, - languageField = languageFieldState, - onSuccess = { sessionId -> - // Onto the session rather than back to - // the group. Nothing has been created - // yet -- the dialect appears when enough - // members sign -- so landing on the list - // it is not in would read as a failure. - onNavigateToRouteAndPopUpInclusive.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - ) - }, - onFailure = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Failed dialect") - ) - } - ) - } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Propose dialect" - ) - Text(stringResource(Res.string.propose_dialect)) - } - } + Text( + text = addDialectUIState.message, ) } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() - ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(stringResource(Res.string.add_a_dialect_the_group_can_translate_into)) + } + is AddDialectUIState.Loaded -> { + val nameFieldState = rememberTextFieldState() + val countryFieldState = rememberTextFieldState() + val languageFieldState = rememberTextFieldState() - if (!addDialectUIState.canSign) { - Text( - text = "This group has no shared key, so it cannot sign a " + - "dialect into existence. Run a shared key ceremony first.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() + + // imePadding: this screen has a text field, and without it the software + // keyboard covers whatever is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + addDialectUIState.localChatRoom.RenderChatRoomTitleText() + }, + actions = { + + } + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (addDialectUIState.canSign) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (addDialectUIState.canSign) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (addDialectUIState.canSign) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!addDialectUIState.canSign) return@ExtendedFloatingActionButton + + addDialectViewModel.addDialect( + localChatRoom = addDialectUIState.localChatRoom, + nameField = nameFieldState, + countryField = countryFieldState, + languageField = languageFieldState, + onSuccess = { sessionId -> + // Onto the session rather than back to + // the group. Nothing has been created + // yet -- the dialect appears when enough + // members sign -- so landing on the list + // it is not in would read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed dialect") + ) + } + ) + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "Propose dialect" + ) + Text(stringResource(Res.string.propose_dialect)) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.add_a_dialect_the_group_can_translate_into)) + + if (!addDialectUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign a " + + "dialect into existence. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = nameFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.Title, + contentDescription = "Name of the dialect" + ) + }, + label = { + Text( + text = stringResource(Res.string.dialect_name) + ) + }, + placeholder = { + Text( + text = stringResource(Res.string.eg_sesotho) + ) + }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = countryFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.Public, + contentDescription = "Country of the dialect" + ) + }, + label = { + Text( + text = stringResource(Res.string.country) + ) + }, + placeholder = { + Text( + text = stringResource(Res.string.eg_lesotho) + ) + }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = languageFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.Translate, + contentDescription = "Language of the dialect" + ) + }, + label = { + Text( + text = stringResource(Res.string.language) + ) + }, + placeholder = { + Text( + text = stringResource(Res.string.eg_st) + ) + }, ) } - - OutlinedTextField( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor), - state = nameFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon( - Icons.Default.Title, - contentDescription = "Name of the dialect" - ) - }, - label = { - Text( - text = stringResource(Res.string.dialect_name) - ) - }, - placeholder = { - Text( - text = stringResource(Res.string.eg_sesotho) - ) - }, - ) - - OutlinedTextField( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor), - state = countryFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon( - Icons.Default.Public, - contentDescription = "Country of the dialect" - ) - }, - label = { - Text( - text = stringResource(Res.string.country) - ) - }, - placeholder = { - Text( - text = stringResource(Res.string.eg_lesotho) - ) - }, - ) - - OutlinedTextField( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor), - state = languageFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon( - Icons.Default.Translate, - contentDescription = "Language of the dialect" - ) - }, - label = { - Text( - text = stringResource(Res.string.language) - ) - }, - placeholder = { - Text( - text = stringResource(Res.string.eg_st) - ) - }, - ) } } } - } - AddDialectUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - MaterialTheme.spacing.space250 - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { + AddDialectUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { - Spacer( - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(Res.string.add_dialect), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(Res.string.add_dialect), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) - LoadingDataIndicator( - fillScreen = false - ) + LoadingDataIndicator( + fillScreen = false + ) - Spacer( - modifier = Modifier.weight(2f) - ) + Spacer( + modifier = Modifier.weight(2f) + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt index 42142be6..584d7cc0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt @@ -49,6 +49,7 @@ import mantra.composeapp.generated.resources.to_join_the_chat_room import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -74,132 +75,134 @@ fun AddMemberToChatRoomConfirmationScreen( ) ) - when (val addMemberToChatRoomConfirmationUIState = addMemberToChatRoomConfirmationViewModel.addMemberToChatRoomConfirmationUIState) { - is AddMemberToChatRoomConfirmationUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(MaterialTheme.spacing.space600) - ) - Text( - text = addMemberToChatRoomConfirmationUIState.message, - textAlign = TextAlign.Center - ) - } - } - is AddMemberToChatRoomConfirmationUIState.Loaded -> { - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, -// topBar = { -// TopAppBar( -// title = { -// val title = if (addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject != null) { -// "Invite new member to ${addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject}" -// } else { -// "Invite new member to chat" -// } -// Text( -// text = title, -// ) -// }, -// actions = { -// } -// ) -// } - bottomBar = { - BottomAppBar( - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { - addMemberToChatRoomConfirmationViewModel.inviteToChatRoom( - localChatRoom = addMemberToChatRoomConfirmationUIState.localChatRoom, - peerPublicKey = addMemberToChatRoomConfirmationUIState.profile.publicKey, - peerKeyPackage = addMemberToChatRoomConfirmationUIState.marmotKeyPackage, - onInviteSent = onInviteSent - ) - } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Invite new member" - ) - - Text(stringResource(Res.string.invite_2, addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey())) - } - }, - actions = { - - } + ScreenStateTransition(addMemberToChatRoomConfirmationViewModel.addMemberToChatRoomConfirmationUIState) { uiState -> + when (val addMemberToChatRoomConfirmationUIState = uiState) { + is AddMemberToChatRoomConfirmationUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) + ) + Text( + text = addMemberToChatRoomConfirmationUIState.message, + textAlign = TextAlign.Center ) } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() - ) { + } + is AddMemberToChatRoomConfirmationUIState.Loaded -> { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + // topBar = { + // TopAppBar( + // title = { + // val title = if (addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject != null) { + // "Invite new member to ${addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject}" + // } else { + // "Invite new member to chat" + // } + // Text( + // text = title, + // ) + // }, + // actions = { + // } + // ) + // } + bottomBar = { + BottomAppBar( + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + addMemberToChatRoomConfirmationViewModel.inviteToChatRoom( + localChatRoom = addMemberToChatRoomConfirmationUIState.localChatRoom, + peerPublicKey = addMemberToChatRoomConfirmationUIState.profile.publicKey, + peerKeyPackage = addMemberToChatRoomConfirmationUIState.marmotKeyPackage, + onInviteSent = onInviteSent + ) + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "Invite new member" + ) + + Text(stringResource(Res.string.invite_2, addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey())) + } + }, + actions = { + + } + ) + } + ) { innerPadding -> Column( - modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() ) { - Spacer( - modifier = Modifier.weight(1f) - ) + Column( + modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + Spacer( + modifier = Modifier.weight(1f) + ) - Text(stringResource(Res.string.invite)) + Text(stringResource(Res.string.invite)) - Text( - text = addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey() - ) + Text( + text = addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey() + ) - Text( - text = stringResource(Res.string.to_join_the_chat_room, addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject ?: "") - ) + Text( + text = stringResource(Res.string.to_join_the_chat_room, addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject ?: "") + ) - Text( - text = stringResource(Res.string.once_invited_will_be_able_to_receive_and, addMemberToChatRoomConfirmationUIState.localChatRoom.localParticipants.size), - textAlign = TextAlign.Center - ) + Text( + text = stringResource(Res.string.once_invited_will_be_able_to_receive_and, addMemberToChatRoomConfirmationUIState.localChatRoom.localParticipants.size), + textAlign = TextAlign.Center + ) - Spacer( - modifier = Modifier.weight(3f) - ) + Spacer( + modifier = Modifier.weight(3f) + ) + } } } } - } - AddMemberToChatRoomConfirmationUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - MaterialTheme.spacing.space250 - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { + AddMemberToChatRoomConfirmationUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { - Spacer( - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(Res.string.direct_message_detail), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(Res.string.direct_message_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) - Text( - text = stringResource(Res.string.direct_message_functionality_will_be_here), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) + Text( + text = stringResource(Res.string.direct_message_functionality_will_be_here), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) - LoadingDataIndicator( - fillScreen = false - ) + LoadingDataIndicator( + fillScreen = false + ) - Spacer( - modifier = Modifier.weight(2f) - ) + Spacer( + modifier = Modifier.weight(2f) + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt index 9b83428a..ce5f36c6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt @@ -68,6 +68,7 @@ import mantra.composeapp.generated.resources.translate import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -97,209 +98,211 @@ fun AddTranslationArtifactVersionScreen( ) ) - when (val addTranslationUIState = addTranslationArtifactVersionViewModel.addTranslationArtifactVersionUIState) { - is AddTranslationArtifactVersionUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) - Text(text = addTranslationUIState.message) + ScreenStateTransition(addTranslationArtifactVersionViewModel.addTranslationArtifactVersionUIState) { uiState -> + when (val addTranslationUIState = uiState) { + is AddTranslationArtifactVersionUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = addTranslationUIState.message) + } } - } - is AddTranslationArtifactVersionUIState.Loaded -> { - // A translation is into a dialect the group already signed into - // existence. Nothing is chosen to begin with: picking the first one - // for somebody would be choosing the language of the work. - var selectedDialectId: String? by remember { mutableStateOf(null) } - val selectedDialect = addTranslationUIState.dialects - .firstOrNull { dialect -> dialect.id == selectedDialectId } + is AddTranslationArtifactVersionUIState.Loaded -> { + // A translation is into a dialect the group already signed into + // existence. Nothing is chosen to begin with: picking the first one + // for somebody would be choosing the language of the work. + var selectedDialectId: String? by remember { mutableStateOf(null) } + val selectedDialect = addTranslationUIState.dialects + .firstOrNull { dialect -> dialect.id == selectedDialectId } - // A translation hangs off a version, and the group has to be able to - // sign; without both there is nothing this screen can propose. - val artifactVersion = addTranslationUIState.artifactVersion - val chapterCount = addTranslationUIState.chapters.size + // A translation hangs off a version, and the group has to be able to + // sign; without both there is nothing this screen can propose. + val artifactVersion = addTranslationUIState.artifactVersion + val chapterCount = addTranslationUIState.chapters.size - // The translation and a chapter apiece are signed together, and a - // session signs a bounded number of events -- so a long artifact is - // proposed in more than one, and the admins answer once per - // session. That is a number worth seeing before the tap. - val sessionCount = 1 + ( - (chapterCount - AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION) - .coerceAtLeast(0) + FrostSigningManager.MAX_BATCH_SIZE - 1 - ) / FrostSigningManager.MAX_BATCH_SIZE + // The translation and a chapter apiece are signed together, and a + // session signs a bounded number of events -- so a long artifact is + // proposed in more than one, and the admins answer once per + // session. That is a number worth seeing before the tap. + val sessionCount = 1 + ( + (chapterCount - AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION) + .coerceAtLeast(0) + FrostSigningManager.MAX_BATCH_SIZE - 1 + ) / FrostSigningManager.MAX_BATCH_SIZE - val canProposeTranslation = artifactVersion != null && - selectedDialect != null && - addTranslationUIState.canSign + val canProposeTranslation = artifactVersion != null && + selectedDialect != null && + addTranslationUIState.canSign - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { Text(stringResource(Res.string.translate, addTranslationUIState.artifact.name)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - ) - }, - bottomBar = { - BottomAppBar( - actions = {}, - floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canProposeTranslation) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (canProposeTranslation) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canProposeTranslation) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, - onClick = { - if (artifactVersion == null || selectedDialect == null || !canProposeTranslation) { - return@ExtendedFloatingActionButton - } - - addTranslationArtifactVersionViewModel.addTranslation( - localChatRoom = addTranslationUIState.localChatRoom, - artifact = addTranslationUIState.artifact, - artifactVersion = artifactVersion, - dialect = selectedDialect, - chapters = addTranslationUIState.chapters, - onSuccess = { sessionId -> - // Onto the session rather than back to - // the artifact. Nothing has been created - // yet -- the translation appears when - // enough members sign -- so landing on - // the list it is not in would read as a - // failure. - onNavigateToRouteAndPopUpInclusive.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - ) - }, - onFailure = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Failed translation") - ) - } + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.translate, addTranslationUIState.artifact.name)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" ) } - ) { - Icon( - Icons.Default.Translate, - contentDescription = "Propose translation" - ) - Text(stringResource(Res.string.propose_translation)) + }, + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canProposeTranslation) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canProposeTranslation) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canProposeTranslation) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (artifactVersion == null || selectedDialect == null || !canProposeTranslation) { + return@ExtendedFloatingActionButton + } + + addTranslationArtifactVersionViewModel.addTranslation( + localChatRoom = addTranslationUIState.localChatRoom, + artifact = addTranslationUIState.artifact, + artifactVersion = artifactVersion, + dialect = selectedDialect, + chapters = addTranslationUIState.chapters, + onSuccess = { sessionId -> + // Onto the session rather than back to + // the artifact. Nothing has been created + // yet -- the translation appears when + // enough members sign -- so landing on + // the list it is not in would read as a + // failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed translation") + ) + } + ) + } + ) { + Icon( + Icons.Default.Translate, + contentDescription = "Propose translation" + ) + Text(stringResource(Res.string.propose_translation)) + } } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) - ) { - if (!addTranslationUIState.canSign) { - Text( - text = "This group has no shared key, so it cannot sign a " + - "translation into existence. Run a shared key ceremony first.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } else if (artifactVersion == null) { - Text( - text = stringResource(Res.string.this_artifact_has_no_version_for_a), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error ) } - - Text(stringResource(Res.string.translate_into_which_dialect)) - - if (addTranslationUIState.dialects.isEmpty()) { - // The only way out of this screen, and it is not on it: - // a dialect is the group's too, and asking for one is - // its own quorum from the room's own detail screen. - Text( - text = "This group has no dialects yet. Add one from the group's " + - "details before translating anything into it.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { - addTranslationUIState.dialects.forEach { dialect -> - FilterChip( - selected = selectedDialectId == dialect.id, - onClick = { selectedDialectId = dialect.id }, - label = { Text(dialect.name) } + if (!addTranslationUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign a " + + "translation into existence. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } else if (artifactVersion == null) { + Text( + text = stringResource(Res.string.this_artifact_has_no_version_for_a), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error ) } - } - Text( - // What the group is being asked to sign, counted before - // the tap rather than described after it: the chapters - // are the rest of the batch. The empty case is worth - // its own sentence, so that a translation of an artifact - // nobody has written into yet does not read as broken. - text = if (chapterCount == 0) { - "The artifact has no chapters yet, so the group would sign the " + - "translation on its own. Chapters signed afterwards are put " + - "into it as they are added." - } else { - "The group signs the translation and " + - "$chapterCount ${if (chapterCount == 1) "chapter" else "chapters"}" + - (if (sessionCount == 1) "" else ", in $sessionCount sessions to sign") + - ". Chunks are translated one at a time afterwards." - }, - style = MaterialTheme.typography.labelMedium, - ) + Text(stringResource(Res.string.translate_into_which_dialect)) + + if (addTranslationUIState.dialects.isEmpty()) { + // The only way out of this screen, and it is not on it: + // a dialect is the group's too, and asking for one is + // its own quorum from the room's own detail screen. + Text( + text = "This group has no dialects yet. Add one from the group's " + + "details before translating anything into it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + addTranslationUIState.dialects.forEach { dialect -> + FilterChip( + selected = selectedDialectId == dialect.id, + onClick = { selectedDialectId = dialect.id }, + label = { Text(dialect.name) } + ) + } + } + + Text( + // What the group is being asked to sign, counted before + // the tap rather than described after it: the chapters + // are the rest of the batch. The empty case is worth + // its own sentence, so that a translation of an artifact + // nobody has written into yet does not read as broken. + text = if (chapterCount == 0) { + "The artifact has no chapters yet, so the group would sign the " + + "translation on its own. Chapters signed afterwards are put " + + "into it as they are added." + } else { + "The group signs the translation and " + + "$chapterCount ${if (chapterCount == 1) "chapter" else "chapters"}" + + (if (sessionCount == 1) "" else ", in $sessionCount sessions to sign") + + ". Chunks are translated one at a time afterwards." + }, + style = MaterialTheme.typography.labelMedium, + ) + } } } - } - AddTranslationArtifactVersionUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = stringResource(Res.string.add_translation), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - LoadingDataIndicator(fillScreen = false) - Spacer(modifier = Modifier.weight(2f)) + AddTranslationArtifactVersionUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.add_translation), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt index 4a3561d0..1c015783 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt @@ -67,6 +67,7 @@ import mantra.composeapp.generated.resources.words_characters import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -91,231 +92,233 @@ fun ArtifactDetailScreen( ) ) - when (val artifactDetailUIState = artifactDetailViewModel.artifactDetailUIState) { - is ArtifactDetailUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) - Text(text = artifactDetailUIState.message) - } - } - - is ArtifactDetailUIState.Loaded -> { - val artifact = artifactDetailUIState.artifact - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { Text(artifact.name) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - ) - } - ) { innerPadding -> - LazyColumn( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ScreenStateTransition(artifactDetailViewModel.artifactDetailUIState) { uiState -> + when (val artifactDetailUIState = uiState) { + is ArtifactDetailUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - // Chapters - item { - Text( - text = stringResource(Res.string.chapters), - style = MaterialTheme.typography.labelMedium - ) - } - if (artifactDetailUIState.chapters.isEmpty()) { - item { Text(stringResource(Res.string.no_chapters_yet)) } - } else { - items( - items = artifactDetailUIState.chapters, - key = { chapter -> chapter.id } - ) { chapter -> - Card( - onClick = { - onNavigateToRoute.invoke( - ChapterDetailRoute( - activeUserPublicKey = activeUserPublicKey, - chapterId = chapter.id, - chatRoomId = chatRoomId, - relayHint = relayHint - ) + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = artifactDetailUIState.message) + } + } + + is ArtifactDetailUIState.Loaded -> { + val artifact = artifactDetailUIState.artifact + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(artifact.name) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" ) } - ) { - ListItem( - leadingContent = { - Icon( - Icons.Default.Article, - contentDescription = "Chapter" + }, + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + // Chapters + item { + Text( + text = stringResource(Res.string.chapters), + style = MaterialTheme.typography.labelMedium + ) + } + if (artifactDetailUIState.chapters.isEmpty()) { + item { Text(stringResource(Res.string.no_chapters_yet)) } + } else { + items( + items = artifactDetailUIState.chapters, + key = { chapter -> chapter.id } + ) { chapter -> + Card( + onClick = { + onNavigateToRoute.invoke( + ChapterDetailRoute( + activeUserPublicKey = activeUserPublicKey, + chapterId = chapter.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) ) - }, - overlineContent = { Text(stringResource(Res.string.chapter, chapter.index)) }, - headlineContent = { Text(chapter.name) }, - supportingContent = { - Text(stringResource(Res.string.words_characters, chapter.wordCount, chapter.characterCount)) } - ) - } - } - } - - item { - TextButton( - enabled = artifactDetailUIState.versions.isNotEmpty(), - onClick = { - onNavigateToRoute.invoke( - AddChapterRoute( - activeUserPublicKey = activeUserPublicKey, - artifactId = artifact.id, - chatRoomId = chatRoomId, - relayHint = relayHint + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.Article, + contentDescription = "Chapter" + ) + }, + overlineContent = { Text(stringResource(Res.string.chapter, chapter.index)) }, + headlineContent = { Text(chapter.name) }, + supportingContent = { + Text(stringResource(Res.string.words_characters, chapter.wordCount, chapter.characterCount)) + } ) - ) + } } - ) { - Icon( - Icons.Default.PostAdd, - contentDescription = "Add chapter" - ) - Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) - Text(stringResource(Res.string.add_chapter)) } - } - item { HorizontalDivider() } - - // Translations - item { - Text( - text = stringResource(Res.string.translations), - style = MaterialTheme.typography.labelMedium - ) - } - if (artifactDetailUIState.translations.isEmpty()) { - item { Text(stringResource(Res.string.no_translations_yet)) } - } else { - items( - items = artifactDetailUIState.translations, - key = { translation -> translation.id } - ) { translation -> - Card( + item { + TextButton( + enabled = artifactDetailUIState.versions.isNotEmpty(), onClick = { onNavigateToRoute.invoke( - TranslationArtifactVersionDetailRoute( + AddChapterRoute( activeUserPublicKey = activeUserPublicKey, - translationArtifactVersionId = translation.id, + artifactId = artifact.id, chatRoomId = chatRoomId, relayHint = relayHint ) ) } ) { - ListItem( - leadingContent = { - Icon( - Icons.Default.Translate, - contentDescription = "Translation" - ) - }, - headlineContent = { Text(translation.name) }, - supportingContent = { Text(translation.visibility) } + Icon( + Icons.Default.PostAdd, + contentDescription = "Add chapter" ) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(stringResource(Res.string.add_chapter)) } } - } - item { - TextButton( - enabled = artifactDetailUIState.versions.isNotEmpty(), - onClick = { - onNavigateToRoute.invoke( - AddTranslationRoute( - activeUserPublicKey = activeUserPublicKey, - artifactId = artifact.id, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - ) { - Icon( - Icons.Default.Translate, - contentDescription = "Add translation" + item { HorizontalDivider() } + + // Translations + item { + Text( + text = stringResource(Res.string.translations), + style = MaterialTheme.typography.labelMedium ) - Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) - Text(stringResource(Res.string.add_translation)) } - } - - item { HorizontalDivider() } - - // Details - item { - Text( - text = stringResource(Res.string.details), - style = MaterialTheme.typography.labelMedium - ) - } - item { - Card { - DetailRow(label = "Name", value = artifact.name) - DetailRow(label = "Url", value = artifact.url) - DetailRow(label = "Visibility", value = artifact.visibility) - DetailRow(label = "License", value = artifact.license) - DetailRow(label = "Dialect", value = artifact.dialectId) - DetailRow(label = "Author", value = artifact.publicKey) - DetailRow(label = "Created", value = artifact.createdAt.toString()) + if (artifactDetailUIState.translations.isEmpty()) { + item { Text(stringResource(Res.string.no_translations_yet)) } + } else { + items( + items = artifactDetailUIState.translations, + key = { translation -> translation.id } + ) { translation -> + Card( + onClick = { + onNavigateToRoute.invoke( + TranslationArtifactVersionDetailRoute( + activeUserPublicKey = activeUserPublicKey, + translationArtifactVersionId = translation.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.Translate, + contentDescription = "Translation" + ) + }, + headlineContent = { Text(translation.name) }, + supportingContent = { Text(translation.visibility) } + ) + } + } } - } - // Versions - item { - Text( - text = stringResource(Res.string.versions), - style = MaterialTheme.typography.labelMedium - ) - } - if (artifactDetailUIState.versions.isEmpty()) { - item { Text(stringResource(Res.string.no_versions)) } - } else { - items( - items = artifactDetailUIState.versions, - key = { version -> version.id } - ) { version -> - Card { - ListItem( - headlineContent = { Text(version.versionLabel) }, - overlineContent = { Text(stringResource(Res.string.version)) } + item { + TextButton( + enabled = artifactDetailUIState.versions.isNotEmpty(), + onClick = { + onNavigateToRoute.invoke( + AddTranslationRoute( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifact.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.Translate, + contentDescription = "Add translation" ) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(stringResource(Res.string.add_translation)) + } + } + + item { HorizontalDivider() } + + // Details + item { + Text( + text = stringResource(Res.string.details), + style = MaterialTheme.typography.labelMedium + ) + } + item { + Card { + DetailRow(label = "Name", value = artifact.name) + DetailRow(label = "Url", value = artifact.url) + DetailRow(label = "Visibility", value = artifact.visibility) + DetailRow(label = "License", value = artifact.license) + DetailRow(label = "Dialect", value = artifact.dialectId) + DetailRow(label = "Author", value = artifact.publicKey) + DetailRow(label = "Created", value = artifact.createdAt.toString()) + } + } + + // Versions + item { + Text( + text = stringResource(Res.string.versions), + style = MaterialTheme.typography.labelMedium + ) + } + if (artifactDetailUIState.versions.isEmpty()) { + item { Text(stringResource(Res.string.no_versions)) } + } else { + items( + items = artifactDetailUIState.versions, + key = { version -> version.id } + ) { version -> + Card { + ListItem( + headlineContent = { Text(version.versionLabel) }, + overlineContent = { Text(stringResource(Res.string.version)) } + ) + } } } } } } - } - ArtifactDetailUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = stringResource(Res.string.artifact_detail), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - LoadingDataIndicator(fillScreen = false) - Spacer(modifier = Modifier.weight(2f)) + ArtifactDetailUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.artifact_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt index 1ae3f510..3e325fc3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt @@ -51,6 +51,7 @@ import mantra.composeapp.generated.resources.words_characters import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -74,114 +75,116 @@ fun ChapterDetailScreen( ) ) - when (val chapterDetailUIState = chapterDetailViewModel.chapterDetailUIState) { - is ChapterDetailUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) - Text(text = chapterDetailUIState.message) - } - } - - is ChapterDetailUIState.Loaded -> { - val chapter = chapterDetailUIState.chapter - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { Text(chapter.name) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - ) - } - ) { innerPadding -> - LazyColumn( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ScreenStateTransition(chapterDetailViewModel.chapterDetailUIState) { uiState -> + when (val chapterDetailUIState = uiState) { + is ChapterDetailUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - item { - Text( - text = stringResource(Res.string.chapter_words_characters, chapter.index, chapter.wordCount, chapter.characterCount), - style = MaterialTheme.typography.labelMedium - ) - } + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = chapterDetailUIState.message) + } + } - // Original text (markdown, shown as-is). - item { - Text( - text = stringResource(Res.string.original_text), - style = MaterialTheme.typography.labelMedium + is ChapterDetailUIState.Loaded -> { + val chapter = chapterDetailUIState.chapter + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(chapter.name) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, ) } - item { - Card { + ) { innerPadding -> + LazyColumn( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + item { Text( - modifier = Modifier.padding(MaterialTheme.spacing.containerPadding), - text = chapter.originalText, - style = MaterialTheme.typography.bodyMedium + text = stringResource(Res.string.chapter_words_characters, chapter.index, chapter.wordCount, chapter.characterCount), + style = MaterialTheme.typography.labelMedium ) } - } - item { HorizontalDivider() } - - // Chunks - item { - Text( - text = stringResource(Res.string.chunks, chapterDetailUIState.chunks.size), - style = MaterialTheme.typography.labelMedium - ) - } - if (chapterDetailUIState.chunks.isEmpty()) { - item { Text(stringResource(Res.string.no_chunks)) } - } else { - items( - items = chapterDetailUIState.chunks, - key = { chunk -> chunk.id } - ) { chunk -> + // Original text (markdown, shown as-is). + item { + Text( + text = stringResource(Res.string.original_text), + style = MaterialTheme.typography.labelMedium + ) + } + item { Card { - ListItem( - leadingContent = { - Icon( - Icons.Default.Segment, - contentDescription = "Chunk" - ) - }, - overlineContent = { Text(stringResource(Res.string.chunk, chunk.index)) }, - headlineContent = { Text(chunk.text) }, - supportingContent = { - Text(stringResource(Res.string.words_characters, chunk.wordCount, chunk.characterCount)) - } + Text( + modifier = Modifier.padding(MaterialTheme.spacing.containerPadding), + text = chapter.originalText, + style = MaterialTheme.typography.bodyMedium ) } } + + item { HorizontalDivider() } + + // Chunks + item { + Text( + text = stringResource(Res.string.chunks, chapterDetailUIState.chunks.size), + style = MaterialTheme.typography.labelMedium + ) + } + if (chapterDetailUIState.chunks.isEmpty()) { + item { Text(stringResource(Res.string.no_chunks)) } + } else { + items( + items = chapterDetailUIState.chunks, + key = { chunk -> chunk.id } + ) { chunk -> + Card { + ListItem( + leadingContent = { + Icon( + Icons.Default.Segment, + contentDescription = "Chunk" + ) + }, + overlineContent = { Text(stringResource(Res.string.chunk, chunk.index)) }, + headlineContent = { Text(chunk.text) }, + supportingContent = { + Text(stringResource(Res.string.words_characters, chunk.wordCount, chunk.characterCount)) + } + ) + } + } + } } } } - } - ChapterDetailUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = stringResource(Res.string.chapter_detail), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - LoadingDataIndicator(fillScreen = false) - Spacer(modifier = Modifier.weight(2f)) + ChapterDetailUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.chapter_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 75b6d7b2..66d07185 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -95,6 +95,7 @@ import mantra.composeapp.generated.resources.recovered_of_still_unreadable import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -121,262 +122,297 @@ fun ChatRoomDetailScreen( ) ) - when (val chatRoomDetailUIState = chatRoomDetailViewModel.chatRoomDetailUIState) { - is ChatRoomDetailUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(MaterialTheme.spacing.space600) - ) - Text( - text = chatRoomDetailUIState.message, - ) - } - } - is ChatRoomDetailUIState.Loaded -> { - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { - chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText() - }, + ScreenStateTransition(chatRoomDetailViewModel.chatRoomDetailUIState) { uiState -> + when (val chatRoomDetailUIState = uiState) { + is ChatRoomDetailUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) + ) + Text( + text = chatRoomDetailUIState.message, ) } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() - ) { - LazyColumn( - modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + } + is ChatRoomDetailUIState.Loaded -> { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText() + }, + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() ) { - item { - chatRoomDetailUIState.localChatRoom.chatRoom.description?.let { + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + item { + chatRoomDetailUIState.localChatRoom.chatRoom.description?.let { + Text( + text = it, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge + ) + } + } + + item { + HorizontalDivider() + } + + item { + // Artifacts Text( - text = it, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge + text = stringResource(Res.string.library), + style = MaterialTheme.typography.labelMedium ) } - } - item { - HorizontalDivider() - } - - item { - // Artifacts - Text( - text = stringResource(Res.string.library), - style = MaterialTheme.typography.labelMedium - ) - } - - if (chatRoomDetailUIState.artifacts.isEmpty()) { - item { - Text(stringResource(Res.string.no_artifacts_exists_in_this_groups_library)) + if (chatRoomDetailUIState.artifacts.isEmpty()) { + item { + Text(stringResource(Res.string.no_artifacts_exists_in_this_groups_library)) + } + } else { + items( + items = chatRoomDetailUIState.artifacts, + key = { artifact -> artifact.id } + ) { artifact -> + Card( + onClick = { + onNavigateToRoute.invoke( + ArtifactDetailRoute( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifact.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.LibraryBooks, + contentDescription = "Artifact" + ) + }, + trailingContent = { + Icon( + Icons.Default.ChevronRight, + contentDescription = "View artifact" + ) + }, + headlineContent = { + Text(text = artifact.name) + }, + supportingContent = { + Text(text = artifact.url) + } + ) + } + } } - } else { - items( - items = chatRoomDetailUIState.artifacts, - key = { artifact -> artifact.id } - ) { artifact -> - Card( + + item { + TextButton( onClick = { + // Navigate to the invite user screen... onNavigateToRoute.invoke( - ArtifactDetailRoute( - activeUserPublicKey = activeUserPublicKey, - artifactId = artifact.id, + AddArtifactRoute( chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, relayHint = relayHint ) ) } ) { - ListItem( - leadingContent = { - Icon( - Icons.Default.LibraryBooks, - contentDescription = "Artifact" - ) - }, - trailingContent = { - Icon( - Icons.Default.ChevronRight, - contentDescription = "View artifact" - ) - }, - headlineContent = { - Text(text = artifact.name) - }, - supportingContent = { - Text(text = artifact.url) - } + + Icon( + Icons.Default.LibraryBooks, + contentDescription = "Add new artifact" ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.add_artifact_to_library)) } } - } - item { - TextButton( - onClick = { - // Navigate to the invite user screen... - onNavigateToRoute.invoke( - AddArtifactRoute( - chatRoomId = chatRoomId, - activeUserPublicKey = activeUserPublicKey, - relayHint = relayHint - ) - ) - } - ) { - - Icon( - Icons.Default.LibraryBooks, - contentDescription = "Add new artifact" - ) - - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - - Text(stringResource(Res.string.add_artifact_to_library)) - } - } - - item { - HorizontalDivider() - } - - item { - // Dialects - Text( - text = stringResource(Res.string.dialects), - style = MaterialTheme.typography.labelMedium - ) - } - - if (chatRoomDetailUIState.dialects.isEmpty()) { item { - Text(stringResource(Res.string.no_dialects_have_been_defined_in_this_group_2)) + HorizontalDivider() } - } else { - items( - items = chatRoomDetailUIState.dialects, - key = { dialect -> dialect.id } - ) { dialect -> - Card { - ListItem( - leadingContent = { - Icon( - Icons.Default.Translate, - contentDescription = "Dialect" - ) - }, - headlineContent = { - Text(text = dialect.name) - }, - supportingContent = { - Text(text = "${dialect.language} \u00b7 ${dialect.country}") - } - ) - } - } - } - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - AddDialectRoute( - chatRoomId = chatRoomId, - activeUserPublicKey = activeUserPublicKey, - relayHint = relayHint + item { + // Dialects + Text( + text = stringResource(Res.string.dialects), + style = MaterialTheme.typography.labelMedium + ) + } + + if (chatRoomDetailUIState.dialects.isEmpty()) { + item { + Text(stringResource(Res.string.no_dialects_have_been_defined_in_this_group_2)) + } + } else { + items( + items = chatRoomDetailUIState.dialects, + key = { dialect -> dialect.id } + ) { dialect -> + Card { + ListItem( + leadingContent = { + Icon( + Icons.Default.Translate, + contentDescription = "Dialect" + ) + }, + headlineContent = { + Text(text = dialect.name) + }, + supportingContent = { + Text(text = "${dialect.language} \u00b7 ${dialect.country}") + } ) - ) + } } - ) { - - Icon( - Icons.Default.Translate, - contentDescription = "Add new dialect" - ) - - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - - Text(stringResource(Res.string.add_dialect)) } - } - item { - HorizontalDivider() - } - - // TODO: Add projects... - - item { - // Artifacts - Text( - text = stringResource(Res.string.projects), - style = MaterialTheme.typography.labelMedium - ) - } - - item { - Text(stringResource(Res.string.no_projects_exists_in_this_group)) - } - - item { - TextButton( - onClick = { - // Navigate to the invite user screen... - onNavigateToRoute.invoke( - ImplementationPendingRoute( - "Add new project" - ) - ) - } - ) { - - Icon( - Icons.Default.Schema, - contentDescription = "create new projec" - ) - - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - - Text(stringResource(Res.string.create_project)) - } - } - - item { - HorizontalDivider() - } - - item { - Text( - text = stringResource(Res.string.members_2), - style = MaterialTheme.typography.labelMedium - ) - } - - // A shared threshold key only means something where every - // member is an equal admin, which is the NIP-17 (robust) case. - // MLS rooms have one admin and no group key to share. - if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState == null) { item { TextButton( onClick = { onNavigateToRoute.invoke( - DkgRitualRoute( + AddDialectRoute( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint + ) + ) + } + ) { + + Icon( + Icons.Default.Translate, + contentDescription = "Add new dialect" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.add_dialect)) + } + } + + item { + HorizontalDivider() + } + + // TODO: Add projects... + + item { + // Artifacts + Text( + text = stringResource(Res.string.projects), + style = MaterialTheme.typography.labelMedium + ) + } + + item { + Text(stringResource(Res.string.no_projects_exists_in_this_group)) + } + + item { + TextButton( + onClick = { + // Navigate to the invite user screen... + onNavigateToRoute.invoke( + ImplementationPendingRoute( + "Add new project" + ) + ) + } + ) { + + Icon( + Icons.Default.Schema, + contentDescription = "create new projec" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.create_project)) + } + } + + item { + HorizontalDivider() + } + + item { + Text( + text = stringResource(Res.string.members_2), + style = MaterialTheme.typography.labelMedium + ) + } + + // A shared threshold key only means something where every + // member is an equal admin, which is the NIP-17 (robust) case. + // MLS rooms have one admin and no group key to share. + if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState == null) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + DkgRitualRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + ) + } + ) { + Icon( + Icons.Default.Key, + contentDescription = "Shared key" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.shared_key)) + } + } + } + + // Every room, not only the one that holds the key. A + // ceremony needs a NIP-17 group, but a signing message is + // a marmot inner event -- see `FrostSigningManager.broadcast` + // -- so the room a group actually proposes in is the MLS + // one, which is the last place this should be missing from. + // + // The transcript carries a proposal past as it happens; + // this is where a member goes to find one that has scrolled + // away, or to see what the group has signed. + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + ProposalListRoute( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId ) @@ -384,263 +420,230 @@ fun ChatRoomDetailScreen( } ) { Icon( - Icons.Default.Key, - contentDescription = "Shared key" + Icons.Default.Draw, + contentDescription = "Proposals" ) Spacer( modifier = Modifier.width(MaterialTheme.spacing.space125) ) - Text(stringResource(Res.string.shared_key)) + Text(stringResource(Res.string.proposals)) } } - } - // Every room, not only the one that holds the key. A - // ceremony needs a NIP-17 group, but a signing message is - // a marmot inner event -- see `FrostSigningManager.broadcast` - // -- so the room a group actually proposes in is the MLS - // one, which is the last place this should be missing from. - // - // The transcript carries a proposal past as it happens; - // this is where a member goes to find one that has scrolled - // away, or to see what the group has signed. - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - ProposalListRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId - ) - ) - } - ) { - Icon( - Icons.Default.Draw, - contentDescription = "Proposals" - ) - - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - - Text(stringResource(Res.string.proposals)) - } - } - - item { - TextButton( - onClick = { - // Navigate to the invite user screen... - onNavigateToRoute.invoke( - SearchMemberToAddToChatRoomRoute( - chatRoomId = chatRoomId, - activeUserPublicKey = activeUserPublicKey, - relayHint = relayHint - ) - ) - } - ) { - - Icon( - Icons.Default.PersonAdd, - contentDescription = "Invite new member" - ) - - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - - Text(stringResource(Res.string.invite_new_member)) - } - } - - items( - items = chatRoomDetailUIState.localChatRoom.localParticipants, - key = { localParticipant -> localParticipant.participant.participantPublicKey} - ) { localParticipant -> - - Card( - onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute( - "Profile detail" - ) - ) - } - ) { - ListItem( - leadingContent = { - ProfileAvatar( - publicKey = localParticipant.participant.participantPublicKey, - profile = localParticipant.profile - ) - }, - trailingContent = { - Icon( - Icons.Default.ChevronRight, - contentDescription = "View profile" - ) - }, - headlineContent = { - Text( - text = localParticipant.profile?.humanReadableNameOrPubkey() ?: localParticipant.participant.participantPublicKey - ) - }, - supportingContent = { - localParticipant.profile?.about?.let { - Text( - text = it + item { + TextButton( + onClick = { + // Navigate to the invite user screen... + onNavigateToRoute.invoke( + SearchMemberToAddToChatRoomRoute( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint ) - } + ) } + ) { - ) + Icon( + Icons.Default.PersonAdd, + contentDescription = "Invite new member" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.invite_new_member)) + } } - } + items( + items = chatRoomDetailUIState.localChatRoom.localParticipants, + key = { localParticipant -> localParticipant.participant.participantPublicKey} + ) { localParticipant -> + Card( + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute( + "Profile detail" + ) + ) + } + ) { + ListItem( + leadingContent = { + ProfileAvatar( + publicKey = localParticipant.participant.participantPublicKey, + profile = localParticipant.profile + ) + }, + trailingContent = { + Icon( + Icons.Default.ChevronRight, + contentDescription = "View profile" + ) + }, + headlineContent = { + Text( + text = localParticipant.profile?.humanReadableNameOrPubkey() ?: localParticipant.participant.participantPublicKey + ) + }, + supportingContent = { + localParticipant.profile?.about?.let { + Text( + text = it + ) + } + } - item { - HorizontalDivider() - } - - item { - - TextButton( - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.secondary - ), - enabled = chatRoomDetailUIState.localChatRoom.chatRoom.leftGroupAt == null, // Disable after we leave group... - onClick = { - chatRoomDetailViewModel.leaveGroup( - localChatRoom = chatRoomDetailUIState.localChatRoom, - onPopBackToRoute = onPopBackToRoute ) } - ) { - Icon( - Icons.Default.Unsubscribe, - contentDescription = "Leave group" - ) - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - - Text(stringResource(Res.string.leave_group)) } - // What it actually does: sets leftGroupAt and posts a line to - // the room. Said here because the button fires immediately -- - // there is no confirmation step to say it in. - Text( - text = "Posts a line to the room saying you left, and " + - "lets you delete it from this device afterwards.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding( - start = MaterialTheme.spacing.space600, - end = MaterialTheme.spacing.containerPadding, - bottom = MaterialTheme.spacing.compactPadding, - ) - ) - } - item { - TextButton( - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ), - enabled = chatRoomDetailUIState.localChatRoom.chatRoom.leftGroupAt != null, - onClick = { - chatRoomDetailViewModel.softDeleteGroup( - localChatRoom = chatRoomDetailUIState.localChatRoom, - onPopBackToRoute = onPopBackToRoute - ) - } - ) { - Icon( - Icons.Default.DeleteForever, - contentDescription = "Delete group" - ) - - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - - Text(stringResource(Res.string.delete_group)) - } - - // A soft delete: it sets deletedAt on the local row. Saying - // "delete" without saying where would leave somebody believing - // the messages are gone from the relays and from the other - // members, which is the opposite of true. - Text( - text = "Removes the room from this device. The messages " + - "stay on the relays and with the other members.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding( - start = MaterialTheme.spacing.space600, - end = MaterialTheme.spacing.containerPadding, - bottom = MaterialTheme.spacing.compactPadding, - ) - ) - } - - // Only MLS rooms have group events to read again. A NIP-17 - // room's messages are gift wraps, which carry no ordering - // for anything to go wrong with. - if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState != null) { item { HorizontalDivider() } item { - ReindexMarmotGroupEventsButton( - reindexState = chatRoomDetailViewModel.reindexState, - onReindex = chatRoomDetailViewModel::reindexMarmotGroupEvents + + TextButton( + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.secondary + ), + enabled = chatRoomDetailUIState.localChatRoom.chatRoom.leftGroupAt == null, // Disable after we leave group... + onClick = { + chatRoomDetailViewModel.leaveGroup( + localChatRoom = chatRoomDetailUIState.localChatRoom, + onPopBackToRoute = onPopBackToRoute + ) + } + ) { + Icon( + Icons.Default.Unsubscribe, + contentDescription = "Leave group" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.leave_group)) + } + + // What it actually does: sets leftGroupAt and posts a line to + // the room. Said here because the button fires immediately -- + // there is no confirmation step to say it in. + Text( + text = "Posts a line to the room saying you left, and " + + "lets you delete it from this device afterwards.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + start = MaterialTheme.spacing.space600, + end = MaterialTheme.spacing.containerPadding, + bottom = MaterialTheme.spacing.compactPadding, + ) ) } + + item { + TextButton( + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ), + enabled = chatRoomDetailUIState.localChatRoom.chatRoom.leftGroupAt != null, + onClick = { + chatRoomDetailViewModel.softDeleteGroup( + localChatRoom = chatRoomDetailUIState.localChatRoom, + onPopBackToRoute = onPopBackToRoute + ) + } + ) { + Icon( + Icons.Default.DeleteForever, + contentDescription = "Delete group" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.delete_group)) + } + + // A soft delete: it sets deletedAt on the local row. Saying + // "delete" without saying where would leave somebody believing + // the messages are gone from the relays and from the other + // members, which is the opposite of true. + Text( + text = "Removes the room from this device. The messages " + + "stay on the relays and with the other members.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + start = MaterialTheme.spacing.space600, + end = MaterialTheme.spacing.containerPadding, + bottom = MaterialTheme.spacing.compactPadding, + ) + ) + } + + // Only MLS rooms have group events to read again. A NIP-17 + // room's messages are gift wraps, which carry no ordering + // for anything to go wrong with. + if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState != null) { + item { + HorizontalDivider() + } + + item { + ReindexMarmotGroupEventsButton( + reindexState = chatRoomDetailViewModel.reindexState, + onReindex = chatRoomDetailViewModel::reindexMarmotGroupEvents + ) + } + } } } } } - } - ChatRoomDetailUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - MaterialTheme.spacing.space250 - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { + ChatRoomDetailUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { - Spacer( - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(Res.string.direct_message_detail), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(Res.string.direct_message_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) - Text( - text = stringResource(Res.string.direct_message_functionality_will_be_here), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) + Text( + text = stringResource(Res.string.direct_message_functionality_will_be_here), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) - LoadingDataIndicator( - fillScreen = false - ) + LoadingDataIndicator( + fillScreen = false + ) - Spacer( - modifier = Modifier.weight(2f) - ) + Spacer( + modifier = Modifier.weight(2f) + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index 0d84085b..eb238df1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -70,6 +70,7 @@ import mantra.composeapp.generated.resources.private_to import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -95,355 +96,357 @@ fun ChatRoomMessagingScreen( ) ) - when (val chatRoomDetailUIState = chatRoomMessagingViewModel.chatRoomMessagingUIState) { - is ChatRoomMessagingUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(MaterialTheme.spacing.space600) - ) - Text( - text = chatRoomDetailUIState.message, - ) - } - } - is ChatRoomMessagingUIState.Loaded -> { - val textFieldState = rememberTextFieldState() - - val chatMessageListViewModel: press.mantra.compose.ui.view.model.ChatMessageListViewModel = viewModel( - factory = press.mantra.compose.ui.view.model.ChatMessageListViewModel.factory( - localChatRoom = chatRoomDetailUIState.localChatRoom, - nostrRepository = nostrRepository, - chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository - ) - ) - - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { - chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText() - }, - actions = { - IconButton( - onClick = { - // Navigate to the invite user screen... - onNavigateToRoute.invoke( - ChatRoomDetailRoute( - chatRoomId = chatRoomId, - activeUserPublicKey = activeUserPublicKey, - relayHint = relayHint - ) - ) - } - ) { - Icon( - Icons.Default.MoreVert, - contentDescription = "View details..." - ) - } - } + ScreenStateTransition(chatRoomMessagingViewModel.chatRoomMessagingUIState) { uiState -> + when (val chatRoomDetailUIState = uiState) { + is ChatRoomMessagingUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) + ) + Text( + text = chatRoomDetailUIState.message, ) } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() - ) { - Column( - modifier = Modifier.weight(1f).fillMaxWidth() - ) { - key(true) { - ChatTranscript( - viewModel = chatMessageListViewModel, - onOpenSharedKey = { - onNavigateToRoute.invoke( - DkgRitualRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId + } + is ChatRoomMessagingUIState.Loaded -> { + val textFieldState = rememberTextFieldState() + + val chatMessageListViewModel: press.mantra.compose.ui.view.model.ChatMessageListViewModel = viewModel( + factory = press.mantra.compose.ui.view.model.ChatMessageListViewModel.factory( + localChatRoom = chatRoomDetailUIState.localChatRoom, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + ) + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText() + }, + actions = { + IconButton( + onClick = { + // Navigate to the invite user screen... + onNavigateToRoute.invoke( + ChatRoomDetailRoute( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint + ) ) + } + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = "View details..." ) - }, - onOpenSigning = { sessionId -> - // Two reasons to open the queue instead of the one - // proposal. A line written before chat rows named - // their session cannot say which proposal it is - // about, and the room may have several -- the list - // is the honest answer there, showing all of them - // with their own state rather than guessing at one. - // - // The other is that the proposal they tapped is - // not the only one waiting on them, in which case - // it is not the whole of what is being asked and a - // screen showing only it would say it was. - onNavigateToRoute.invoke( - if (sessionId == null || - chatMessageListViewModel.hidesOtherDecisions(sessionId) - ) { + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) { + Column( + modifier = Modifier.weight(1f).fillMaxWidth() + ) { + key(true) { + ChatTranscript( + viewModel = chatMessageListViewModel, + onOpenSharedKey = { + onNavigateToRoute.invoke( + DkgRitualRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + ) + }, + onOpenSigning = { sessionId -> + // Two reasons to open the queue instead of the one + // proposal. A line written before chat rows named + // their session cannot say which proposal it is + // about, and the room may have several -- the list + // is the honest answer there, showing all of them + // with their own state rather than guessing at one. + // + // The other is that the proposal they tapped is + // not the only one waiting on them, in which case + // it is not the whole of what is being asked and a + // screen showing only it would say it was. + onNavigateToRoute.invoke( + if (sessionId == null || + chatMessageListViewModel.hidesOtherDecisions(sessionId) + ) { + ProposalListRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + } else { + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + } + ) + }, + onOpenProposals = { + onNavigateToRoute.invoke( ProposalListRoute( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId ) - } else { - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - } - ) - }, - onOpenProposals = { - onNavigateToRoute.invoke( - ProposalListRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId ) - ) - } - ) - } - } - - key(true) { - chatMessageListViewModel.initiate() - } - - // TODO: Check that we have direct message relays for this user... - - // The composer handles its own bottom inset, which is why this - // screen is the one text-field screen without `imePadding()` on its - // Scaffold. Adding it here would pad twice while the keyboard is up: - // the ime inset already covers the navigation bar area this row is - // separately reserving. Getting the combination right wants a device - // with a keyboard open, not a compiler. - Column( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor) - .navigationBarsPadding() - ) { - // An armed composer has to be impossible to miss. The whole risk - // of this feature is somebody sending to the room what they meant - // for one person, or the reverse, and by the time either is on the - // wire it cannot be taken back. Hence three signals at once: the - // chip, the placeholder, and the tinted field. - val directMessageRecipient = chatMessageListViewModel.directMessageRecipient - - if (directMessageRecipient != null) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.primaryContainer) - .padding(horizontal = MaterialTheme.spacing.containerPadding, vertical = MaterialTheme.spacing.space75), - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Lock, - contentDescription = Decorative, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer + } ) - - Text( - modifier = Modifier.weight(1f), - text = stringResource(Res.string.private_to, chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)), - color = MaterialTheme.colorScheme.onPrimaryContainer, - style = MaterialTheme.typography.labelMedium - ) - - // The one way back to the room, and it has to be one tap. - IconButton( - onClick = { chatMessageListViewModel.cancelDirectMessage() } - ) { - Icon( - Icons.Default.Close, - contentDescription = "Send to the whole group instead", - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - } } } - Box( - modifier = Modifier.fillMaxWidth() - ) { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - state = textFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent, - focusedContainerColor = if (directMessageRecipient != null) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - } else { - Color.Transparent - }, - unfocusedContainerColor = if (directMessageRecipient != null) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - } else { - Color.Transparent - } - ), - placeholder = { - Text( - text = if (directMessageRecipient != null) { - stringResource(Res.string.private_message_to, chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)) - } else { - stringResource(Res.string.say_what_now) - } - ) - }, - trailingIcon = { - if (textFieldState.text.isEmpty()) { - IconButton( - onClick = { - - } - ) { - Icon( - Icons.Default.Mic, - contentDescription = "Voice" - ) - } - - } else { - IconButton( - onClick = { - chatMessageListViewModel.sendMessage( - textFieldState = textFieldState - ) - } - ) { - Icon( - Icons.AutoMirrored.Filled.Send, - contentDescription = "Send" - ) - } - } - } - ) + key(true) { + chatMessageListViewModel.initiate() } -// Row( -// modifier = Modifier.padding(4.dp), -// horizontalArrangement = Arrangement.spacedBy(4.dp) -// ) { -// IconButton( -// onClick = { -// -// } -// ) { -// Icon( -// Icons.Default.InsertEmoticon, -// contentDescription = "Add emoji" -// ) -// } -// -// IconButton( -// onClick = { -// -// } -// ) { -// Icon( -// Icons.Default.AlternateEmail, -// contentDescription = "Mention profile" -// ) -// } -// -// IconButton( -// onClick = { -// -// } -// ) { -// Icon( -// Icons.Default.Collections, -// contentDescription = "Attach media" -// ) -// } -// -// IconButton( -// onClick = { -// -// } -// ) { -// Icon( -// Icons.Default.LocationOn, -// contentDescription = "Send location" -// ) -// } -// } + // TODO: Check that we have direct message relays for this user... + + // The composer handles its own bottom inset, which is why this + // screen is the one text-field screen without `imePadding()` on its + // Scaffold. Adding it here would pad twice while the keyboard is up: + // the ime inset already covers the navigation bar area this row is + // separately reserving. Getting the combination right wants a device + // with a keyboard open, not a compiler. + Column( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor) + .navigationBarsPadding() + ) { + // An armed composer has to be impossible to miss. The whole risk + // of this feature is somebody sending to the room what they meant + // for one person, or the reverse, and by the time either is on the + // wire it cannot be taken back. Hence three signals at once: the + // chip, the placeholder, and the tinted field. + val directMessageRecipient = chatMessageListViewModel.directMessageRecipient + + if (directMessageRecipient != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(horizontal = MaterialTheme.spacing.containerPadding, vertical = MaterialTheme.spacing.space75), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = Decorative, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + + Text( + modifier = Modifier.weight(1f), + text = stringResource(Res.string.private_to, chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)), + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.labelMedium + ) + + // The one way back to the room, and it has to be one tap. + IconButton( + onClick = { chatMessageListViewModel.cancelDirectMessage() } + ) { + Icon( + Icons.Default.Close, + contentDescription = "Send to the whole group instead", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + + Box( + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + state = textFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent, + focusedContainerColor = if (directMessageRecipient != null) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + Color.Transparent + }, + unfocusedContainerColor = if (directMessageRecipient != null) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + Color.Transparent + } + ), + placeholder = { + Text( + text = if (directMessageRecipient != null) { + stringResource(Res.string.private_message_to, chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)) + } else { + stringResource(Res.string.say_what_now) + } + ) + }, + trailingIcon = { + if (textFieldState.text.isEmpty()) { + IconButton( + onClick = { + + } + ) { + Icon( + Icons.Default.Mic, + contentDescription = "Voice" + ) + } + + } else { + IconButton( + onClick = { + chatMessageListViewModel.sendMessage( + textFieldState = textFieldState + ) + } + ) { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = "Send" + ) + } + } + } + ) + } + + // Row( + // modifier = Modifier.padding(4.dp), + // horizontalArrangement = Arrangement.spacedBy(4.dp) + // ) { + // IconButton( + // onClick = { + // + // } + // ) { + // Icon( + // Icons.Default.InsertEmoticon, + // contentDescription = "Add emoji" + // ) + // } + // + // IconButton( + // onClick = { + // + // } + // ) { + // Icon( + // Icons.Default.AlternateEmail, + // contentDescription = "Mention profile" + // ) + // } + // + // IconButton( + // onClick = { + // + // } + // ) { + // Icon( + // Icons.Default.Collections, + // contentDescription = "Attach media" + // ) + // } + // + // IconButton( + // onClick = { + // + // } + // ) { + // Icon( + // Icons.Default.LocationOn, + // contentDescription = "Send location" + // ) + // } + // } + } } } } - } - ChatRoomMessagingUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - MaterialTheme.spacing.space250 - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { + ChatRoomMessagingUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { - Spacer( - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(Res.string.direct_message_detail), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - - Text( - text = stringResource(Res.string.direct_message_functionality_will_be_here), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) - - LoadingDataIndicator( - fillScreen = false - ) - - Spacer( - modifier = Modifier.weight(2f) - ) - } - } - ChatRoomMessagingUIState.InitiatingNewChat -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - MaterialTheme.spacing.space250 - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { - Spacer( - modifier = Modifier.weight(1f) - ) - - Text( - text = stringResource(Res.string.creating_new_chat), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) - - LoadingDataIndicator( - fillScreen = false - ) - - Spacer( - modifier = Modifier.weight(2f) - ) - - LaunchedEffect(true) { - chatRoomMessagingViewModel.initiateNewChat( - onNavigateToRouteAndPopUpInclusive + Spacer( + modifier = Modifier.weight(1f) ) + Text( + text = stringResource(Res.string.direct_message_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(Res.string.direct_message_functionality_will_be_here), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator( + fillScreen = false + ) + + Spacer( + modifier = Modifier.weight(2f) + ) + } + } + ChatRoomMessagingUIState.InitiatingNewChat -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer( + modifier = Modifier.weight(1f) + ) + + Text( + text = stringResource(Res.string.creating_new_chat), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator( + fillScreen = false + ) + + Spacer( + modifier = Modifier.weight(2f) + ) + + LaunchedEffect(true) { + chatRoomMessagingViewModel.initiateNewChat( + onNavigateToRouteAndPopUpInclusive + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index c4097836..60e58240 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -102,6 +102,7 @@ import mantra.composeapp.generated.resources.shared_key_for import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition /** * The shared-key ceremony: a ChillDKG ritual run across the group's NIP-17 @@ -134,177 +135,179 @@ fun DkgRitualScreen( ) ) - when (val dkgRitualUIState = dkgRitualViewModel.dkgRitualUIState) { - is DkgRitualUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) - Text(text = dkgRitualUIState.message, textAlign = TextAlign.Center) + ScreenStateTransition(dkgRitualViewModel.dkgRitualUIState) { uiState -> + when (val dkgRitualUIState = uiState) { + is DkgRitualUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = dkgRitualUIState.message, textAlign = TextAlign.Center) + } } - } - is DkgRitualUIState.Loaded -> { - val session = dkgRitualUIState.session - val participantCount = dkgRitualUIState.participantCount - val canStartRitual = dkgRitualViewModel.canStartRitual() - val isActionPending = dkgRitualViewModel.isActionPending.value + is DkgRitualUIState.Loaded -> { + val session = dkgRitualUIState.session + val participantCount = dkgRitualUIState.participantCount + val canStartRitual = dkgRitualViewModel.canStartRitual() + val isActionPending = dkgRitualViewModel.isActionPending.value - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.shared_key_for, dkgRitualUIState.localChatRoom.chatRoom.subject ?: "this group"), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - ) - }, - bottomBar = { - val pending = dkgRitualUIState.pendingApproval - - // The ceremony is stopped, waiting on this member, and will stay - // stopped until they act -- so this outranks everything else the - // bar could offer. - if (pending != null) { - BottomAppBar( - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { - onNavigateToRoute( - when (pending) { - DkgApprovalStep.HOST_KEY -> DkgJoinApprovalRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId - ) - DkgApprovalStep.ROUND_1 -> DkgRound1ApprovalRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId - ) - DkgApprovalStep.ROUND_2 -> DkgRound2ApprovalRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId - ) - } - ) - } - ) { - Icon(Icons.Default.Key, contentDescription = Decorative) - Text( - text = when (pending) { - DkgApprovalStep.HOST_KEY -> stringResource(Res.string.review_and_join) - DkgApprovalStep.ROUND_1 -> stringResource(Res.string.review_and_contribute) - DkgApprovalStep.ROUND_2 -> stringResource(Res.string.review_and_confirm) - } - ) - } - }, - actions = {} - ) - - return@Scaffold - } - - // Any member can open a ritual — the coordinator has no say in - // the key — but only when there isn't one already running. - if (canStartRitual) { - BottomAppBar( - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { dkgRitualViewModel.startRitual() } - ) { - if (isActionPending) { - CircularProgressIndicator(modifier = Modifier.size(20.dp)) - } else { - Icon(Icons.Default.Key, contentDescription = "Start") - } - - Text( - text = if (session == null) stringResource(Res.string.start_key_ceremony) else stringResource(Res.string.try_again) - ) - } - }, - actions = { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text( - modifier = Modifier.padding(start = MaterialTheme.spacing.space200), - text = stringResource(Res.string.of, dkgRitualViewModel.threshold.value, participantCount), - style = MaterialTheme.typography.labelLarge + text = stringResource(Res.string.shared_key_for, dkgRitualUIState.localChatRoom.chatRoom.subject ?: "this group"), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } ) - } - } - ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .readableContent() - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(MaterialTheme.spacing.space125), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) - ) { - if (session == null) { - Text( - text = stringResource(Res.string.the_group_can_hold_one_key_together_split_so), - style = MaterialTheme.typography.bodyMedium - ) + }, + bottomBar = { + val pending = dkgRitualUIState.pendingApproval - Text( - text = stringResource(Res.string.everyone_has_to_be_online_at_the_same_time, participantCount), - style = MaterialTheme.typography.bodyMedium - ) - - if (canStartRitual) { - QuorumStepper( - threshold = dkgRitualViewModel.threshold.value, - participantCount = participantCount, - quorumRange = dkgRitualViewModel.quorumRange(), - onThresholdChange = dkgRitualViewModel::setThreshold + // The ceremony is stopped, waiting on this member, and will stay + // stopped until they act -- so this outranks everything else the + // bar could offer. + if (pending != null) { + BottomAppBar( + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + onNavigateToRoute( + when (pending) { + DkgApprovalStep.HOST_KEY -> DkgJoinApprovalRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + DkgApprovalStep.ROUND_1 -> DkgRound1ApprovalRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + DkgApprovalStep.ROUND_2 -> DkgRound2ApprovalRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + } + ) + } + ) { + Icon(Icons.Default.Key, contentDescription = Decorative) + Text( + text = when (pending) { + DkgApprovalStep.HOST_KEY -> stringResource(Res.string.review_and_join) + DkgApprovalStep.ROUND_1 -> stringResource(Res.string.review_and_contribute) + DkgApprovalStep.ROUND_2 -> stringResource(Res.string.review_and_confirm) + } + ) + } + }, + actions = {} ) - } else { - Text( - text = "A shared key needs at least " + - "${ChillDkgRitualManager.MINIMUM_PARTICIPANTS} members to split it between.", - style = MaterialTheme.typography.labelLarge + + return@Scaffold + } + + // Any member can open a ritual — the coordinator has no say in + // the key — but only when there isn't one already running. + if (canStartRitual) { + BottomAppBar( + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { dkgRitualViewModel.startRitual() } + ) { + if (isActionPending) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } else { + Icon(Icons.Default.Key, contentDescription = "Start") + } + + Text( + text = if (session == null) stringResource(Res.string.start_key_ceremony) else stringResource(Res.string.try_again) + ) + } + }, + actions = { + Text( + modifier = Modifier.padding(start = MaterialTheme.spacing.space200), + text = stringResource(Res.string.of, dkgRitualViewModel.threshold.value, participantCount), + style = MaterialTheme.typography.labelLarge + ) + } ) } - } else { - RitualProgress( - session = session, - participantCount = participantCount, - activeUserPublicKey = activeUserPublicKey, - members = dkgRitualUIState.ritualMembers, - hostKeyParticipants = dkgRitualUIState.hostKeyParticipants, - round1Participants = dkgRitualUIState.round1Participants, - round2Participants = dkgRitualUIState.round2Participants, - isActionPending = isActionPending, - adminGroupBlockedOn = dkgRitualUIState.adminGroupBlockedOn, - onCreateAdminGroup = { - dkgRitualViewModel.createAdminGroup(onNavigateToRoute) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(MaterialTheme.spacing.space125), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + if (session == null) { + Text( + text = stringResource(Res.string.the_group_can_hold_one_key_together_split_so), + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = stringResource(Res.string.everyone_has_to_be_online_at_the_same_time, participantCount), + style = MaterialTheme.typography.bodyMedium + ) + + if (canStartRitual) { + QuorumStepper( + threshold = dkgRitualViewModel.threshold.value, + participantCount = participantCount, + quorumRange = dkgRitualViewModel.quorumRange(), + onThresholdChange = dkgRitualViewModel::setThreshold + ) + } else { + Text( + text = "A shared key needs at least " + + "${ChillDkgRitualManager.MINIMUM_PARTICIPANTS} members to split it between.", + style = MaterialTheme.typography.labelLarge + ) } - ) + } else { + RitualProgress( + session = session, + participantCount = participantCount, + activeUserPublicKey = activeUserPublicKey, + members = dkgRitualUIState.ritualMembers, + hostKeyParticipants = dkgRitualUIState.hostKeyParticipants, + round1Participants = dkgRitualUIState.round1Participants, + round2Participants = dkgRitualUIState.round2Participants, + isActionPending = isActionPending, + adminGroupBlockedOn = dkgRitualUIState.adminGroupBlockedOn, + onCreateAdminGroup = { + dkgRitualViewModel.createAdminGroup(onNavigateToRoute) + } + ) + } } } } - } - DkgRitualUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = stringResource(Res.string.shared_key), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - LoadingDataIndicator(fillScreen = false) - Spacer(modifier = Modifier.weight(2f)) + DkgRitualUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.shared_key), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt index 55794fdf..fe5e6d43 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt @@ -74,6 +74,7 @@ import press.mantra.compose.ui.theme.breakpoint import press.mantra.compose.ui.theme.listPaneWidthFor import press.mantra.compose.ui.theme.readableContent import mantra.composeapp.generated.resources.pick_a_conversation_to_read_it_here +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -126,213 +127,215 @@ fun HomeScreen( ), ) - when(val homeScreenUIState = homeScreenViewModel.homeScreenUIState) { - HomeScreenUIState.Error -> { - Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), - verticalArrangement = Arrangement.Center, - ) { - ErrorState() - } - } - - } - is HomeScreenUIState.Loaded -> { - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier, - topBar = { - // No colour override. It was `containerColor = primaryContainer` with - // `titleContentColor = primary`, which in the light scheme is #000000 - // on #1B1B1B -- 1.22:1, a black title on a near-black bar. The - // default is `surface`/`onSurface` and needs no help. - TopAppBar( - title = { - // "Mantra". The launcher label, the desktop window title, the - // landing screen and the package name all say so; this bar was - // the last place still saying "Torch". `UserAgent.APP_NAME` is - // the other, and is left alone -- it goes on the wire to relay - // operators, so it is a network identity question rather than - // a content one. - Text(stringResource(Res.string.mantra)) - }, - // No navigation icon and no actions. The avatar in the leading - // slot and the search icon in the trailing one were the app's only - // two peer surfaces, and they are now items in the navigation - // component -- a bar on a phone, a rail on a wide window. Leaving - // them here as well would be two routes to one destination, which - // is the thing M3's "swap only functionally equivalent components" - // caution is about. - ) - - }, - floatingActionButton = { - // Only while there is one pane. With two, the scaffold's FAB slot is - // the bottom-right of the *window*, which is on top of the - // transcript's send button; M3 puts a list-detail layout's primary - // action in the list pane, and so does the branch below. - if (listPaneWidth == null) NewChatButton { showBottomSheet = true } - } - ) { innerPadding -> - @Composable - fun ChatRoomListPane(modifier: Modifier) = Column( - modifier = modifier - ) { - PrimaryScrollableTabRow( - modifier = Modifier.padding(MaterialTheme.spacing.space125), - edgePadding = 10.dp, - selectedTabIndex = homeScreenViewModel.pagerState.currentPage, + ScreenStateTransition(homeScreenViewModel.homeScreenUIState) { uiState -> + when (val homeScreenUIState = uiState) { + HomeScreenUIState.Error -> { + Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), + verticalArrangement = Arrangement.Center, ) { - HomeScreenType.entries.forEachIndexed { index, destination -> - Tab( - selected = homeScreenViewModel.pagerState.currentPage == index, - onClick = { - scope.launch { - homeScreenViewModel.pagerState.animateScrollToPage(destination.ordinal) + ErrorState() + } + } + + } + is HomeScreenUIState.Loaded -> { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier, + topBar = { + // No colour override. It was `containerColor = primaryContainer` with + // `titleContentColor = primary`, which in the light scheme is #000000 + // on #1B1B1B -- 1.22:1, a black title on a near-black bar. The + // default is `surface`/`onSurface` and needs no help. + TopAppBar( + title = { + // "Mantra". The launcher label, the desktop window title, the + // landing screen and the package name all say so; this bar was + // the last place still saying "Torch". `UserAgent.APP_NAME` is + // the other, and is left alone -- it goes on the wire to relay + // operators, so it is a network identity question rather than + // a content one. + Text(stringResource(Res.string.mantra)) + }, + // No navigation icon and no actions. The avatar in the leading + // slot and the search icon in the trailing one were the app's only + // two peer surfaces, and they are now items in the navigation + // component -- a bar on a phone, a rail on a wide window. Leaving + // them here as well would be two routes to one destination, which + // is the thing M3's "swap only functionally equivalent components" + // caution is about. + ) + + }, + floatingActionButton = { + // Only while there is one pane. With two, the scaffold's FAB slot is + // the bottom-right of the *window*, which is on top of the + // transcript's send button; M3 puts a list-detail layout's primary + // action in the list pane, and so does the branch below. + if (listPaneWidth == null) NewChatButton { showBottomSheet = true } + } + ) { innerPadding -> + @Composable + fun ChatRoomListPane(modifier: Modifier) = Column( + modifier = modifier + ) { + PrimaryScrollableTabRow( + modifier = Modifier.padding(MaterialTheme.spacing.space125), + edgePadding = 10.dp, + selectedTabIndex = homeScreenViewModel.pagerState.currentPage, + ) { + HomeScreenType.entries.forEachIndexed { index, destination -> + Tab( + selected = homeScreenViewModel.pagerState.currentPage == index, + onClick = { + scope.launch { + homeScreenViewModel.pagerState.animateScrollToPage(destination.ordinal) + } + }, + text = { + Text( + text = destination.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } + + Column( + modifier = Modifier.weight(1f) + ) { + HorizontalPager( + state = homeScreenViewModel.pagerState, + ) { pageIndex -> + when (val homeScreenTypeType = HomeScreenType.entries[pageIndex]) { + HomeScreenType.Messages -> { + val chatRoomListViewModel: ChatRoomListViewModel = viewModel( + key = homeScreenTypeType.name, + factory = ChatRoomListViewModel.factory( + initialChatRoomListUIState = press.mantra.compose.ui.view.state.ChatRoomListUIState.Loading, + publicKey = homeScreenUIState.profileWithFollowing.profile.publicKey, + nostrRepository = nostrRepository, + chatRepository = chatRepository + ), + ) + + key(true) { + chatRoomListViewModel.RenderFeed( + onNavigateToDirectMessageDetail = onOpenChatRoom + ) + } + + key(true) { + chatRoomListViewModel.initiate() + } } - }, - text = { - Text( - text = destination.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) } + } + } + } + + if (listPaneWidth == null) { + // Compact and medium: the list is the screen, and a room is a route. + // Byte for byte the layout this screen has always had. + ChatRoomListPane( + Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) + } else { + // Expanded and up: the list keeps a fixed width and the room fills the + // rest. No `readableContent()` on the pair -- capping the two panes + // together to one column's measure is the opposite of what a second + // pane is for. Each pane holds its own content to a measure instead, + // and `ChatRoomMessagingScreen` already does. + Row(modifier = Modifier.padding(innerPadding).fillMaxSize()) { + Box( + modifier = Modifier + .width(listPaneWidth) + .fillMaxHeight() + // The pane's width is the one thing about this layout that + // is a number rather than a rule, and the only way to check + // a number is to measure it in a composition. + .testTag(ChatListPaneTag) + ) { + ChatRoomListPane(Modifier.fillMaxSize()) + + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(MaterialTheme.spacing.containerPadding) + ) { + NewChatButton { showBottomSheet = true } + } + } + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.paneGap)) + + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + val chatRoomId = selectedChatRoomId + if (chatRoomId == null) { + EmptyState( + message = stringResource(Res.string.pick_a_conversation_to_read_it_here) + ) + } else { + // Keyed on the room, so switching rooms rebuilds the + // screen's view models rather than feeding a new id to + // ones already subscribed to another room's relay. + key(chatRoomId) { + ChatRoomMessagingScreen( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = selectedRelayHint, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository, + // "Replace what is on screen" is a route push on a + // phone and a change of selection here. It fires when + // a conversation that did not exist yet has just been + // created and has an id at last. + onNavigateToRouteAndPopUpInclusive = onOpenChatRoom, + onNavigateToRoute = onNavigateToRoute, + ) + } + } + } + } + } + + if (showBottomSheet) { + NewChatBottomSheetDialog( + scope = scope, + sheetState = sheetState, + onSetShowBottomSheetUpdate = { toggle -> + showBottomSheet = toggle + }, + onNavigateToRoute = onNavigateToRoute, + onNavigateToChatRoomCreation = onNavigateToChatRoomCreation, + onStartDirectMessageChatWithNpub = { + openNpubDialog.value = true + } + ) + } + + when { + openNpubDialog.value -> { + StartDirectMessageToNpubOrNip05Dialog( + activeUserPublicKey = activeUserPublicKey, + scope = scope, + toggleOpenDialogSetting = { + openNpubDialog.value = false + }, + onNavigateToRoute = onNavigateToRoute ) } } - - Column( - modifier = Modifier.weight(1f) - ) { - HorizontalPager( - state = homeScreenViewModel.pagerState, - ) { pageIndex -> - when (val homeScreenTypeType = HomeScreenType.entries[pageIndex]) { - HomeScreenType.Messages -> { - val chatRoomListViewModel: ChatRoomListViewModel = viewModel( - key = homeScreenTypeType.name, - factory = ChatRoomListViewModel.factory( - initialChatRoomListUIState = press.mantra.compose.ui.view.state.ChatRoomListUIState.Loading, - publicKey = homeScreenUIState.profileWithFollowing.profile.publicKey, - nostrRepository = nostrRepository, - chatRepository = chatRepository - ), - ) - - key(true) { - chatRoomListViewModel.RenderFeed( - onNavigateToDirectMessageDetail = onOpenChatRoom - ) - } - - key(true) { - chatRoomListViewModel.initiate() - } - } - } - } - } - } - - if (listPaneWidth == null) { - // Compact and medium: the list is the screen, and a room is a route. - // Byte for byte the layout this screen has always had. - ChatRoomListPane( - Modifier.padding(innerPadding).readableContent().fillMaxSize() - ) - } else { - // Expanded and up: the list keeps a fixed width and the room fills the - // rest. No `readableContent()` on the pair -- capping the two panes - // together to one column's measure is the opposite of what a second - // pane is for. Each pane holds its own content to a measure instead, - // and `ChatRoomMessagingScreen` already does. - Row(modifier = Modifier.padding(innerPadding).fillMaxSize()) { - Box( - modifier = Modifier - .width(listPaneWidth) - .fillMaxHeight() - // The pane's width is the one thing about this layout that - // is a number rather than a rule, and the only way to check - // a number is to measure it in a composition. - .testTag(ChatListPaneTag) - ) { - ChatRoomListPane(Modifier.fillMaxSize()) - - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(MaterialTheme.spacing.containerPadding) - ) { - NewChatButton { showBottomSheet = true } - } - } - - Spacer(modifier = Modifier.width(MaterialTheme.spacing.paneGap)) - - Box(modifier = Modifier.weight(1f).fillMaxHeight()) { - val chatRoomId = selectedChatRoomId - if (chatRoomId == null) { - EmptyState( - message = stringResource(Res.string.pick_a_conversation_to_read_it_here) - ) - } else { - // Keyed on the room, so switching rooms rebuilds the - // screen's view models rather than feeding a new id to - // ones already subscribed to another room's relay. - key(chatRoomId) { - ChatRoomMessagingScreen( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - relayHint = selectedRelayHint, - nostrRepository = nostrRepository, - chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository, - // "Replace what is on screen" is a route push on a - // phone and a change of selection here. It fires when - // a conversation that did not exist yet has just been - // created and has an id at last. - onNavigateToRouteAndPopUpInclusive = onOpenChatRoom, - onNavigateToRoute = onNavigateToRoute, - ) - } - } - } - } - } - - if (showBottomSheet) { - NewChatBottomSheetDialog( - scope = scope, - sheetState = sheetState, - onSetShowBottomSheetUpdate = { toggle -> - showBottomSheet = toggle - }, - onNavigateToRoute = onNavigateToRoute, - onNavigateToChatRoomCreation = onNavigateToChatRoomCreation, - onStartDirectMessageChatWithNpub = { - openNpubDialog.value = true - } - ) - } - - when { - openNpubDialog.value -> { - StartDirectMessageToNpubOrNip05Dialog( - activeUserPublicKey = activeUserPublicKey, - scope = scope, - toggleOpenDialogSetting = { - openNpubDialog.value = false - }, - onNavigateToRoute = onNavigateToRoute - ) - } } } - } - HomeScreenUIState.Loading -> { - LoadingDataIndicator() + HomeScreenUIState.Loading -> { + LoadingDataIndicator() + } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt index 502e150c..ba5606d3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt @@ -59,6 +59,7 @@ import press.mantra.compose.ui.composable.widgets.rememberNotifier import mantra.composeapp.generated.resources.key_package_published import mantra.composeapp.generated.resources.key_package_rotated import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -86,155 +87,157 @@ fun KeyPackageManagementScreen( ), ) - when(val keyPackageManagementUIState = keyPackageManagementViewModel.keyPackageManagementUIState) { - KeyPackageManagementUIState.Error -> { - ErrorState() - } - is KeyPackageManagementUIState.Loaded -> { - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.key_package_management) - ) - }, - navigationIcon = { - IconButton( - onClick = { - onNavigateBack.invoke() - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back" - ) - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding) - .readableContent() - ) { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), - horizontalAlignment = Alignment.CenterHorizontally - ) { - item { - Button( - onClick = { - keyPackageManagementViewModel.publishNewKeyPackage { - notify(publishedMessage) - } - } - ) { - Icon( - Icons.Default.LockReset, - contentDescription = "Publish new key package" - ) - - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - + ScreenStateTransition(keyPackageManagementViewModel.keyPackageManagementUIState) { uiState -> + when (val keyPackageManagementUIState = uiState) { + KeyPackageManagementUIState.Error -> { + ErrorState() + } + is KeyPackageManagementUIState.Loaded -> { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text( - text = stringResource(Res.string.publish_new_key_package) + text = stringResource(Res.string.key_package_management) + ) + }, + navigationIcon = { + IconButton( + onClick = { + onNavigateBack.invoke() + } + ) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding) + .readableContent() + ) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Button( + onClick = { + keyPackageManagementViewModel.publishNewKeyPackage { + notify(publishedMessage) + } + } + ) { + Icon( + Icons.Default.LockReset, + contentDescription = "Publish new key package" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text( + text = stringResource(Res.string.publish_new_key_package) + ) + } + } + item { + Text( + stringResource(Res.string.key_packages, keyPackageManagementUIState.keyPackageBundles.size) ) } - } - item { - Text( - stringResource(Res.string.key_packages, keyPackageManagementUIState.keyPackageBundles.size) - ) - } - items( - items = keyPackageManagementUIState.keyPackageBundles - ) { marmotKeyPackageBundle -> + items( + items = keyPackageManagementUIState.keyPackageBundles + ) { marmotKeyPackageBundle -> - Card { - ListItem( - leadingContent = { - if (marmotKeyPackageBundle.rotated) { - Icon( - Icons.Default.Unpublished, - contentDescription = "Key package rotated" - ) - } else if (marmotKeyPackageBundle.consumed) { - Icon( - Icons.Default.DoneAll, - contentDescription = "Consumed key package event" - ) - } else { - // TODO: Get broadcast status of key package... - Icon( - Icons.Default.VerifiedUser, - contentDescription = "Key package published" - ) - } - }, - headlineContent = { - Text( - text = marmotKeyPackageBundle.id, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis - ) - }, - supportingContent = { - Row( - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = marmotKeyPackageBundle.createdAt.toFormattedTimeAndDateString(), - style = MaterialTheme.typography.labelSmall - ) - - Spacer( - modifier = Modifier.weight(1f) - ) - } - - }, - trailingContent = { - if (marmotKeyPackageBundle.rotated.not() && marmotKeyPackageBundle.consumed.not()) { - IconButton( - onClick = { - keyPackageManagementViewModel.rotateKeyPackage( - marmotKeyPackageBundle - ) { - notify(rotatedMessage) - } - } - ) { + Card { + ListItem( + leadingContent = { + if (marmotKeyPackageBundle.rotated) { Icon( - Icons.Default.LockReset, - contentDescription = "Rotate key" + Icons.Default.Unpublished, + contentDescription = "Key package rotated" + ) + } else if (marmotKeyPackageBundle.consumed) { + Icon( + Icons.Default.DoneAll, + contentDescription = "Consumed key package event" + ) + } else { + // TODO: Get broadcast status of key package... + Icon( + Icons.Default.VerifiedUser, + contentDescription = "Key package published" ) } - } + }, + headlineContent = { + Text( + text = marmotKeyPackageBundle.id, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis + ) + }, + supportingContent = { + Row( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = marmotKeyPackageBundle.createdAt.toFormattedTimeAndDateString(), + style = MaterialTheme.typography.labelSmall + ) - } - ) + Spacer( + modifier = Modifier.weight(1f) + ) + } + + }, + trailingContent = { + if (marmotKeyPackageBundle.rotated.not() && marmotKeyPackageBundle.consumed.not()) { + IconButton( + onClick = { + keyPackageManagementViewModel.rotateKeyPackage( + marmotKeyPackageBundle + ) { + notify(rotatedMessage) + } + } + ) { + Icon( + Icons.Default.LockReset, + contentDescription = "Rotate key" + ) + } + } + + } + ) + } } } } } } - } - KeyPackageManagementUIState.Loading -> { - LoadingDataIndicator() - } - KeyPackageManagementUIState.NotFound -> { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(stringResource(Res.string.we_couldn_t_find_the_local_profile_please)) + KeyPackageManagementUIState.Loading -> { + LoadingDataIndicator() + } + KeyPackageManagementUIState.NotFound -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.we_couldn_t_find_the_local_profile_please)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt index 5855b278..387250b4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt @@ -42,6 +42,7 @@ import press.mantra.compose.ui.composable.widgets.ErrorState import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -66,78 +67,80 @@ fun NostrEventDetailScreen( ), ) - when(val feedListUIState = nostrEventDetailViewModel.nostrEventDetailUIState) { - NostrEventDetailUIState.Error -> { - ErrorState() - } - is NostrEventDetailUIState.Loaded -> { + ScreenStateTransition(nostrEventDetailViewModel.nostrEventDetailUIState) { uiState -> + when (val feedListUIState = uiState) { + NostrEventDetailUIState.Error -> { + ErrorState() + } + is NostrEventDetailUIState.Loaded -> { - when(feedListUIState.localNostrEvent.nostrEvent.kind) { - MetadataEvent.KIND -> { - MetadataEventDetail( - activeUserPublicKey = activeUserPublicKey, - localNostrEvent = feedListUIState.localNostrEvent, - onNavigateBack = onNavigateBack, - onNavigateToEvent = onNavigateToEvent, - onNavigateToChatRoom = onNavigateToDirectMessage, - onNavigateToEditProfile = onNavigateToEditProfile, - nostrRepository = nostrRepository - ) - } - TextNoteEvent.KIND -> { - TextNoteEventDetail( - feedListUIState.localNostrEvent, - onNavigateBack = onNavigateBack, - onNavigateToWriteAReply = onNavigateToWriteAReply, - onNavigateToNostrEvent = onNavigateToQuoteNostrEvent, - nostrRepository = nostrRepository - ) - } - RepostEvent.KIND -> { - // TODO: RePost... - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(feedListUIState.localNostrEvent.nostrEvent.content) - - Text(stringResource(Res.string.post_functionality_coming_soon)) + when(feedListUIState.localNostrEvent.nostrEvent.kind) { + MetadataEvent.KIND -> { + MetadataEventDetail( + activeUserPublicKey = activeUserPublicKey, + localNostrEvent = feedListUIState.localNostrEvent, + onNavigateBack = onNavigateBack, + onNavigateToEvent = onNavigateToEvent, + onNavigateToChatRoom = onNavigateToDirectMessage, + onNavigateToEditProfile = onNavigateToEditProfile, + nostrRepository = nostrRepository + ) } - } - else -> { - Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> + TextNoteEvent.KIND -> { + TextNoteEventDetail( + feedListUIState.localNostrEvent, + onNavigateBack = onNavigateBack, + onNavigateToWriteAReply = onNavigateToWriteAReply, + onNavigateToNostrEvent = onNavigateToQuoteNostrEvent, + nostrRepository = nostrRepository + ) + } + RepostEvent.KIND -> { + // TODO: RePost... Column( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(feedListUIState.localNostrEvent.nostrEvent.content) + Text(feedListUIState.localNostrEvent.nostrEvent.content) - Text(stringResource(Res.string.functionality_coming_soon)) + Text(stringResource(Res.string.post_functionality_coming_soon)) + } + } + else -> { + Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(feedListUIState.localNostrEvent.nostrEvent.content) + + Text(stringResource(Res.string.functionality_coming_soon)) + } } } } } } - } - NostrEventDetailUIState.Loading -> { - LoadingDataIndicator() + NostrEventDetailUIState.Loading -> { + LoadingDataIndicator() - LaunchedEffect(true) { - nostrEventDetailViewModel.loadNostrFeed() + LaunchedEffect(true) { + nostrEventDetailViewModel.loadNostrFeed() + } } - } - NostrEventDetailUIState.NotFound -> { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(stringResource(Res.string.we_couldn_t_find_your_nostr_event_please_try)) + NostrEventDetailUIState.NotFound -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.we_couldn_t_find_your_nostr_event_please_try)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt index 58494ef7..a8ab57a6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt @@ -49,6 +49,7 @@ import mantra.composeapp.generated.resources.search_message_functionality_will_b import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -72,104 +73,106 @@ fun SearchMemberToAddToChatRoomScreen( ) ) - when (val searchMemberToAddToChatRoomUIState = searchMemberToAddToChatRoomViewModel.searchMemberToAddToChatRoomUIState) { - is SearchMemberToAddToChatRoomUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(MaterialTheme.spacing.space600) - ) - Text( - text = searchMemberToAddToChatRoomUIState.message, - ) - } - } - is SearchMemberToAddToChatRoomUIState.Loaded -> { - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { - val title = if (searchMemberToAddToChatRoomUIState.localChatRoom.chatRoom.subject != null) { - "Invite new member to ${searchMemberToAddToChatRoomUIState.localChatRoom.chatRoom.subject}" - } else { - "Invite new member to chat" - } - Text( - text = title, - ) - } + ScreenStateTransition(searchMemberToAddToChatRoomViewModel.searchMemberToAddToChatRoomUIState) { uiState -> + when (val searchMemberToAddToChatRoomUIState = uiState) { + is SearchMemberToAddToChatRoomUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) + ) + Text( + text = searchMemberToAddToChatRoomUIState.message, ) } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() - ) { + } + is SearchMemberToAddToChatRoomUIState.Loaded -> { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + val title = if (searchMemberToAddToChatRoomUIState.localChatRoom.chatRoom.subject != null) { + "Invite new member to ${searchMemberToAddToChatRoomUIState.localChatRoom.chatRoom.subject}" + } else { + "Invite new member to chat" + } + Text( + text = title, + ) + } + ) + } + ) { innerPadding -> Column( - modifier = Modifier.weight(1f).fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() ) { + Column( + modifier = Modifier.weight(1f).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { - if (searchMemberToAddToChatRoomUIState.contacts.isEmpty()) { - Spacer( - modifier = Modifier.height(MaterialTheme.spacing.space250) - ) - Text( - modifier = Modifier.padding(MaterialTheme.spacing.space250), - text = stringResource(Res.string.currently_no_contacts_please_search_and_chat), - textAlign = TextAlign.Center - ) + if (searchMemberToAddToChatRoomUIState.contacts.isEmpty()) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space250) + ) + Text( + modifier = Modifier.padding(MaterialTheme.spacing.space250), + text = stringResource(Res.string.currently_no_contacts_please_search_and_chat), + textAlign = TextAlign.Center + ) - Spacer( - modifier = Modifier.height(MaterialTheme.spacing.space250) - ) - } else { - LazyColumn( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space50), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), - reverseLayout = true - ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space250) + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space50), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + reverseLayout = true + ) { - items( - items = searchMemberToAddToChatRoomUIState.contacts, - key = { it.publicKey } - ) { profile -> - Card( - onClick = { - onNavigateToRoute.invoke( - AddMemberToChatRoomConfirmationRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - profilePublicKey = profile.publicKey, - relayHint = relayHint, + items( + items = searchMemberToAddToChatRoomUIState.contacts, + key = { it.publicKey } + ) { profile -> + Card( + onClick = { + onNavigateToRoute.invoke( + AddMemberToChatRoomConfirmationRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + profilePublicKey = profile.publicKey, + relayHint = relayHint, + ) ) + } + ) { + ListItem( + leadingContent = { + ProfileAvatar( + profile = profile + ) + }, + headlineContent = { + Text( + profile.humanReadableNameOrPubkey() + ) + }, + supportingContent = { + profile.about?.let { + Text( + text = it, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + } + } ) } - ) { - ListItem( - leadingContent = { - ProfileAvatar( - profile = profile - ) - }, - headlineContent = { - Text( - profile.humanReadableNameOrPubkey() - ) - }, - supportingContent = { - profile.about?.let { - Text( - text = it, - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) - } - } - ) } } } @@ -177,38 +180,38 @@ fun SearchMemberToAddToChatRoomScreen( } } } - } - SearchMemberToAddToChatRoomUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - MaterialTheme.spacing.space250 - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { + SearchMemberToAddToChatRoomUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { - Spacer( - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(Res.string.search_member_functionality), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(Res.string.search_member_functionality), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) - Text( - text = stringResource(Res.string.search_message_functionality_will_be_here), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) + Text( + text = stringResource(Res.string.search_message_functionality_will_be_here), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) - LoadingDataIndicator( - fillScreen = false - ) + LoadingDataIndicator( + fillScreen = false + ) - Spacer( - modifier = Modifier.weight(2f) - ) + Spacer( + modifier = Modifier.weight(2f) + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt index 34b17012..f360d5b3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt @@ -56,6 +56,7 @@ import mantra.composeapp.generated.resources.selected import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition /** * Second step of group creation: pick who is in the group. @@ -84,188 +85,190 @@ fun SelectChatRoomMembersScreen( ) ) - when (val selectChatRoomMembersUIState = selectChatRoomMembersViewModel.selectChatRoomMembersUIState) { - is SelectChatRoomMembersUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(MaterialTheme.spacing.space600) - ) - Text( - text = selectChatRoomMembersUIState.message, - textAlign = TextAlign.Center - ) - } - } - is SelectChatRoomMembersUIState.Loaded -> { - val selectedCount = selectChatRoomMembersViewModel.selectedPublicKeys.size - - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.add_people_to, name), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + ScreenStateTransition(selectChatRoomMembersViewModel.selectChatRoomMembersUIState) { uiState -> + when (val selectChatRoomMembersUIState = uiState) { + is SelectChatRoomMembersUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) ) - }, - bottomBar = { - BottomAppBar( - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { - selectChatRoomMembersViewModel.selectChatRoomType( - onNavigateToRoute = onNavigateToRoute + Text( + text = selectChatRoomMembersUIState.message, + textAlign = TextAlign.Center + ) + } + } + is SelectChatRoomMembersUIState.Loaded -> { + val selectedCount = selectChatRoomMembersViewModel.selectedPublicKeys.size + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.add_people_to, name), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) + }, + bottomBar = { + BottomAppBar( + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + selectChatRoomMembersViewModel.selectChatRoomType( + onNavigateToRoute = onNavigateToRoute + ) + } + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = "Next" + ) + + Text( + text = if (selectedCount > 0) { + stringResource(Res.string.next_with, selectedCount) + } else { + stringResource(Res.string.next) + } ) } + }, + actions = { + Text( + modifier = Modifier.padding(start = MaterialTheme.spacing.space200), + text = if (selectedCount > 0) { + stringResource(Res.string.selected, selectedCount) + } else { + stringResource(Res.string.no_one_selected_yet) + }, + style = MaterialTheme.typography.labelLarge + ) + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) { + if (selectChatRoomMembersUIState.profiles.isEmpty()) { + Column( + modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { - Icon( - Icons.AutoMirrored.Filled.NavigateNext, - contentDescription = "Next" + Spacer( + modifier = Modifier.weight(1f) ) Text( - text = if (selectedCount > 0) { - stringResource(Res.string.next_with, selectedCount) - } else { - stringResource(Res.string.next) - } + text = stringResource(Res.string.no_one_to_add_yet), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(Res.string.search_for_people_and_chat_with_them_first), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(Res.string.you_can_still_carry_on_and_invite_people), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + + Spacer( + modifier = Modifier.weight(2f) ) } - }, - actions = { - Text( - modifier = Modifier.padding(start = MaterialTheme.spacing.space200), - text = if (selectedCount > 0) { - stringResource(Res.string.selected, selectedCount) - } else { - stringResource(Res.string.no_one_selected_yet) - }, - style = MaterialTheme.typography.labelLarge - ) - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() - ) { - if (selectChatRoomMembersUIState.profiles.isEmpty()) { - Column( - modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) - ) { - Spacer( - modifier = Modifier.weight(1f) - ) + } else { + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space50), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + items( + items = selectChatRoomMembersUIState.profiles, + key = { it.publicKey } + ) { profile -> + val isSelected = selectChatRoomMembersViewModel.isSelected(profile.publicKey) - Text( - text = stringResource(Res.string.no_one_to_add_yet), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - - Text( - text = stringResource(Res.string.search_for_people_and_chat_with_them_first), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) - - Text( - text = stringResource(Res.string.you_can_still_carry_on_and_invite_people), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) - - Spacer( - modifier = Modifier.weight(2f) - ) - } - } else { - LazyColumn( - modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space50), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) - ) { - items( - items = selectChatRoomMembersUIState.profiles, - key = { it.publicKey } - ) { profile -> - val isSelected = selectChatRoomMembersViewModel.isSelected(profile.publicKey) - - Card( - onClick = { - selectChatRoomMembersViewModel.toggleMember(profile.publicKey) - } - ) { - ListItem( - leadingContent = { - ProfileAvatar( - profile = profile - ) - }, - headlineContent = { - Text( - profile.humanReadableNameOrPubkey() - ) - }, - supportingContent = { - profile.about?.let { + Card( + onClick = { + selectChatRoomMembersViewModel.toggleMember(profile.publicKey) + } + ) { + ListItem( + leadingContent = { + ProfileAvatar( + profile = profile + ) + }, + headlineContent = { Text( - text = it, - overflow = TextOverflow.Ellipsis, - maxLines = 1 + profile.humanReadableNameOrPubkey() + ) + }, + supportingContent = { + profile.about?.let { + Text( + text = it, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + } + }, + trailingContent = { + Checkbox( + checked = isSelected, + onCheckedChange = { + selectChatRoomMembersViewModel.toggleMember(profile.publicKey) + } ) } - }, - trailingContent = { - Checkbox( - checked = isSelected, - onCheckedChange = { - selectChatRoomMembersViewModel.toggleMember(profile.publicKey) - } - ) - } - ) + ) + } } } } } } } - } - SelectChatRoomMembersUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - MaterialTheme.spacing.space250 - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { + SelectChatRoomMembersUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { - Spacer( - modifier = Modifier.weight(1f) - ) + Spacer( + modifier = Modifier.weight(1f) + ) - Text( - text = stringResource(Res.string.add_people_to, name), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) + Text( + text = stringResource(Res.string.add_people_to, name), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) - LoadingDataIndicator( - fillScreen = false - ) + LoadingDataIndicator( + fillScreen = false + ) - Spacer( - modifier = Modifier.weight(2f) - ) + Spacer( + modifier = Modifier.weight(2f) + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt index eb847160..28733bb5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -73,6 +73,7 @@ import mantra.composeapp.generated.resources.you_and_others import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition /** * Last step of group creation: convenient (one admin) or robust (everyone @@ -106,208 +107,210 @@ fun SelectChatRoomTypeScreen( ) ) - when (val selectChatRoomTypeUIState = selectChatRoomTypeViewModel.selectChatRoomTypeUIState) { - is SelectChatRoomTypeUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(MaterialTheme.spacing.space600) - ) - Text( - text = selectChatRoomTypeUIState.message, - textAlign = TextAlign.Center - ) - } - } - is SelectChatRoomTypeUIState.Loaded -> { - val isActionPending = selectChatRoomTypeViewModel.isActionPending.value - val selectedChatRoomType = selectChatRoomTypeViewModel.selectedChatRoomType.value - val membersNotAdded = selectChatRoomTypeViewModel.membersNotAdded - val keyCeremonyNotStarted = selectChatRoomTypeViewModel.keyCeremonyNotStarted.value - val adminCount = selectChatRoomTypeViewModel.adminCount - val isRobustAvailable = selectChatRoomTypeViewModel.isRobustAvailable - val isRobustSelected = selectedChatRoomType == ChatRoomType.ROBUST - - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.how_should_be_run, name), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + ScreenStateTransition(selectChatRoomTypeViewModel.selectChatRoomTypeUIState) { uiState -> + when (val selectChatRoomTypeUIState = uiState) { + is SelectChatRoomTypeUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) ) - }, - bottomBar = { - BottomAppBar( - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { - selectChatRoomTypeViewModel.createChatRoom( - onNavigateToRoute = onNavigateToRoute - ) - } - ) { - if (isActionPending) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp) - ) - } else { - Icon( - Icons.Default.Check, - contentDescription = "Create chat" - ) - } + Text( + text = selectChatRoomTypeUIState.message, + textAlign = TextAlign.Center + ) + } + } + is SelectChatRoomTypeUIState.Loaded -> { + val isActionPending = selectChatRoomTypeViewModel.isActionPending.value + val selectedChatRoomType = selectChatRoomTypeViewModel.selectedChatRoomType.value + val membersNotAdded = selectChatRoomTypeViewModel.membersNotAdded + val keyCeremonyNotStarted = selectChatRoomTypeViewModel.keyCeremonyNotStarted.value + val adminCount = selectChatRoomTypeViewModel.adminCount + val isRobustAvailable = selectChatRoomTypeViewModel.isRobustAvailable + val isRobustSelected = selectedChatRoomType == ChatRoomType.ROBUST + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text( - text = if (selectChatRoomTypeViewModel.createdChatRoomId.value != null) { - stringResource(Res.string.open_chat) - } else { - stringResource(Res.string.create_chat) - } + text = stringResource(Res.string.how_should_be_run, name), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } - }, - actions = { - Text( - modifier = Modifier.padding(start = MaterialTheme.spacing.space200), - text = when (selectChatRoomTypeUIState.members.size) { - 0 -> stringResource(Res.string.just_you_for_now) - 1 -> stringResource(Res.string.you_and_1_other) - else -> stringResource(Res.string.you_and_others, selectChatRoomTypeUIState.members.size) - }, - style = MaterialTheme.typography.labelLarge - ) - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .readableContent() - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(MaterialTheme.spacing.space125), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) - ) { - if (membersNotAdded.isNotEmpty()) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ) - ) { - Text( - modifier = Modifier.padding(MaterialTheme.spacing.space200), - text = stringResource(Res.string.was_created_but_couldn_t_be_added_yet_invite, name, membersNotAdded.joinToString { selectChatRoomTypeViewModel.displayNameFor(it) }), - style = MaterialTheme.typography.bodyMedium - ) - } + ) + }, + bottomBar = { + BottomAppBar( + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + selectChatRoomTypeViewModel.createChatRoom( + onNavigateToRoute = onNavigateToRoute + ) + } + ) { + if (isActionPending) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp) + ) + } else { + Icon( + Icons.Default.Check, + contentDescription = "Create chat" + ) + } + + Text( + text = if (selectChatRoomTypeViewModel.createdChatRoomId.value != null) { + stringResource(Res.string.open_chat) + } else { + stringResource(Res.string.create_chat) + } + ) + } + }, + actions = { + Text( + modifier = Modifier.padding(start = MaterialTheme.spacing.space200), + text = when (selectChatRoomTypeUIState.members.size) { + 0 -> stringResource(Res.string.just_you_for_now) + 1 -> stringResource(Res.string.you_and_1_other) + else -> stringResource(Res.string.you_and_others, selectChatRoomTypeUIState.members.size) + }, + style = MaterialTheme.typography.labelLarge + ) + } + ) } - - if (keyCeremonyNotStarted) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ) - ) { - Text( - modifier = Modifier.padding(MaterialTheme.spacing.space200), - text = stringResource(Res.string.was_created_but_its_shared_key_ceremony, name), - style = MaterialTheme.typography.bodyMedium - ) - } - } - - Text( - text = stringResource(Res.string.this_decides_who_can_change_the_group_later), - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.fillMaxWidth().padding(horizontal = MaterialTheme.spacing.space125) - ) - - ChatRoomTypeCard( - icon = Icons.Default.Bolt, - title = "Convenient", - summary = "You are the only admin.", - detail = "You can add or remove people and change the group's name or description on your own, without waiting for anybody.", - footnote = "Nothing about the group can change unless you do it — and nobody else can carry it on if you lose your keys.", - isSelected = selectedChatRoomType == ChatRoomType.CONVENIENT, - onClick = { - selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.CONVENIENT) - } - ) - - ChatRoomTypeCard( - icon = Icons.Default.Groups, - title = "Robust", - summary = "Every member is an admin.", - detail = if (isRobustAvailable) { - "All $adminCount of you administer the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of you before it takes effect." - } else { - "Everyone administers the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of the admins before it takes effect." - }, - // Says what tapping create actually does, because it is the one - // thing here that asks something of everybody else: the group's - // first act is a ceremony each member has to approve their way - // through before there is a key. - footnote = "No single admin can change the group alone, and the group outlives any one of you. Creating the group starts a key ceremony every member takes part in.", - isSelected = isRobustSelected, - isEnabled = isRobustAvailable, - disabledReason = selectChatRoomTypeViewModel.robustUnavailableReason, - onClick = { - selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.ROBUST) - } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(MaterialTheme.spacing.space125), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { - // Ask for the quorum only once robust is the choice — before that - // there is no decision to make. - if (isRobustSelected) { - QuorumPicker( - quorum = selectChatRoomTypeViewModel.quorum.value, - adminCount = adminCount, - quorumRange = selectChatRoomTypeViewModel.quorumRange, - explanation = selectChatRoomTypeViewModel.quorumExplanation(), - onQuorumChange = selectChatRoomTypeViewModel::setQuorum - ) + if (membersNotAdded.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Text( + modifier = Modifier.padding(MaterialTheme.spacing.space200), + text = stringResource(Res.string.was_created_but_couldn_t_be_added_yet_invite, name, membersNotAdded.joinToString { selectChatRoomTypeViewModel.displayNameFor(it) }), + style = MaterialTheme.typography.bodyMedium + ) + } + } + + if (keyCeremonyNotStarted) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Text( + modifier = Modifier.padding(MaterialTheme.spacing.space200), + text = stringResource(Res.string.was_created_but_its_shared_key_ceremony, name), + style = MaterialTheme.typography.bodyMedium + ) + } + } + + Text( + text = stringResource(Res.string.this_decides_who_can_change_the_group_later), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.fillMaxWidth().padding(horizontal = MaterialTheme.spacing.space125) + ) + + ChatRoomTypeCard( + icon = Icons.Default.Bolt, + title = "Convenient", + summary = "You are the only admin.", + detail = "You can add or remove people and change the group's name or description on your own, without waiting for anybody.", + footnote = "Nothing about the group can change unless you do it — and nobody else can carry it on if you lose your keys.", + isSelected = selectedChatRoomType == ChatRoomType.CONVENIENT, + onClick = { + selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.CONVENIENT) + } + ) + + ChatRoomTypeCard( + icon = Icons.Default.Groups, + title = "Robust", + summary = "Every member is an admin.", + detail = if (isRobustAvailable) { + "All $adminCount of you administer the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of you before it takes effect." + } else { + "Everyone administers the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of the admins before it takes effect." + }, + // Says what tapping create actually does, because it is the one + // thing here that asks something of everybody else: the group's + // first act is a ceremony each member has to approve their way + // through before there is a key. + footnote = "No single admin can change the group alone, and the group outlives any one of you. Creating the group starts a key ceremony every member takes part in.", + isSelected = isRobustSelected, + isEnabled = isRobustAvailable, + disabledReason = selectChatRoomTypeViewModel.robustUnavailableReason, + onClick = { + selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.ROBUST) + } + ) { + // Ask for the quorum only once robust is the choice — before that + // there is no decision to make. + if (isRobustSelected) { + QuorumPicker( + quorum = selectChatRoomTypeViewModel.quorum.value, + adminCount = adminCount, + quorumRange = selectChatRoomTypeViewModel.quorumRange, + explanation = selectChatRoomTypeViewModel.quorumExplanation(), + onQuorumChange = selectChatRoomTypeViewModel::setQuorum + ) + } } } } } - } - SelectChatRoomTypeUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - MaterialTheme.spacing.space250 - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { + SelectChatRoomTypeUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { - Spacer( - modifier = Modifier.weight(1f) - ) + Spacer( + modifier = Modifier.weight(1f) + ) - Text( - text = stringResource(Res.string.how_should_be_run, name), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) + Text( + text = stringResource(Res.string.how_should_be_run, name), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) - LoadingDataIndicator( - fillScreen = false - ) + LoadingDataIndicator( + fillScreen = false + ) - Spacer( - modifier = Modifier.weight(2f) - ) + Spacer( + modifier = Modifier.weight(2f) + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt index c2da2b5a..472bd6c8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt @@ -55,6 +55,7 @@ import press.mantra.compose.ui.composable.widgets.ErrorState import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -76,149 +77,151 @@ fun ShareProfileScreen( ), ) - when(val shareProfileUIState = shareProfileViewModel.shareProfileUIState) { - ShareProfileUIState.Error -> { - ErrorState() - } - is ShareProfileUIState.Loaded -> { - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.profile) - ) - }, - navigationIcon = { - IconButton( - onClick = { - onNavigateBack.invoke() - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back" + ScreenStateTransition(shareProfileViewModel.shareProfileUIState) { uiState -> + when (val shareProfileUIState = uiState) { + ShareProfileUIState.Error -> { + ErrorState() + } + is ShareProfileUIState.Loaded -> { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.profile) ) - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding) - .readableContent() - ) { - LazyColumn( - modifier = Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), - horizontalAlignment = Alignment.CenterHorizontally - ) { - item { - Row( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) - ) { - ProfileAvatar( - profile = shareProfileUIState.localNostrEvent.profile, - publicKey = activeUserPublicKey - ) - - Column { - shareProfileUIState.localNostrEvent.profile?.humanReadableNameOrPubkey()?.let { humanReadableNameOrPubkey -> - Text( - text = humanReadableNameOrPubkey, - style = MaterialTheme.typography.titleLarge - ) - } - shareProfileUIState.localNostrEvent.profile?.about?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - } - - item { - Row( - modifier = Modifier.padding(MaterialTheme.spacing.space500) - ) { - QRCodeView( - activeUserPublicKey.hexToNpubHrp() - ) - } - } - - item { - Row( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250) - ) { - val clipboardManager = LocalClipboardManager.current - - TextButton( + }, + navigationIcon = { + IconButton( onClick = { - - clipboardManager.setText( - buildAnnotatedString { - append(activeUserPublicKey.hexToNpubHrp()) - } - ) + onNavigateBack.invoke() } ) { Icon( - Icons.Default.ContentCopy, - contentDescription = "Copy npub" + Icons.Default.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding) + .readableContent() + ) { + LazyColumn( + modifier = Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Row( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + ProfileAvatar( + profile = shareProfileUIState.localNostrEvent.profile, + publicKey = activeUserPublicKey ) - Spacer( - modifier = Modifier.width(MaterialTheme.spacing.space125) - ) - Text( - text = activeUserPublicKey.hexToNpubHrp(), - style = MaterialTheme.typography.labelSmall + Column { + shareProfileUIState.localNostrEvent.profile?.humanReadableNameOrPubkey()?.let { humanReadableNameOrPubkey -> + Text( + text = humanReadableNameOrPubkey, + style = MaterialTheme.typography.titleLarge + ) + } + shareProfileUIState.localNostrEvent.profile?.about?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + + item { + Row( + modifier = Modifier.padding(MaterialTheme.spacing.space500) + ) { + QRCodeView( + activeUserPublicKey.hexToNpubHrp() ) } } - } - } + item { + Row( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250) + ) { + val clipboardManager = LocalClipboardManager.current - Row( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), - horizontalArrangement = Arrangement.Center - ) { - Button( - onClick = { - shareProfileViewModel.rebroadcastProfile( - localNostrEvent = shareProfileUIState.localNostrEvent + TextButton( + onClick = { + + clipboardManager.setText( + buildAnnotatedString { + append(activeUserPublicKey.hexToNpubHrp()) + } + ) + } + ) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy npub" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + Text( + text = activeUserPublicKey.hexToNpubHrp(), + style = MaterialTheme.typography.labelSmall + ) + } + } + + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalArrangement = Arrangement.Center + ) { + Button( + onClick = { + shareProfileViewModel.rebroadcastProfile( + localNostrEvent = shareProfileUIState.localNostrEvent + ) + } + ) { + Text( + stringResource(Res.string.re_broadcast) ) } - ) { - Text( - stringResource(Res.string.re_broadcast) - ) } } } } - } - ShareProfileUIState.Loading -> { - LoadingDataIndicator() + ShareProfileUIState.Loading -> { + LoadingDataIndicator() - LaunchedEffect(true) { - shareProfileViewModel.loadNostrFeed() + LaunchedEffect(true) { + shareProfileViewModel.loadNostrFeed() + } } - } - ShareProfileUIState.NotFound -> { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(stringResource(Res.string.we_couldn_t_find_the_local_profile_please)) + ShareProfileUIState.NotFound -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.we_couldn_t_find_the_local_profile_please)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt index 0aa6b47f..a2a6274d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt @@ -65,6 +65,7 @@ import mantra.composeapp.generated.resources.translation import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -96,163 +97,165 @@ fun TranslateChunkScreen( ) ) - when (val translateChunkUIState = translateChunkViewModel.translateChunkUIState) { - is TranslateChunkUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) - Text(text = translateChunkUIState.message) + ScreenStateTransition(translateChunkViewModel.translateChunkUIState) { uiState -> + when (val translateChunkUIState = uiState) { + is TranslateChunkUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = translateChunkUIState.message) + } } - } - is TranslateChunkUIState.Loaded -> { - val translationFieldState = rememberTextFieldState(translateChunkUIState.existingTranslationText) + is TranslateChunkUIState.Loaded -> { + val translationFieldState = rememberTextFieldState(translateChunkUIState.existingTranslationText) - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() - // imePadding: this screen has a text field, and without it the software - // keyboard covers whatever is being typed into. - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - modifier = Modifier.imePadding(), - topBar = { - TopAppBar( - title = { Text(stringResource(Res.string.translate_chunk)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - ) - }, - bottomBar = { - BottomAppBar( - actions = {}, - floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (translateChunkUIState.canSign) { - Modifier - } else { - // Looking unavailable is not being unavailable: - // without this a screen reader still announces - // a button it is happy to press. - Modifier.semantics { disabled() } - }, - containerColor = if (translateChunkUIState.canSign) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (translateChunkUIState.canSign) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, - onClick = { - if (!translateChunkUIState.canSign) return@ExtendedFloatingActionButton - - translateChunkViewModel.proposeTranslation( - localChatRoom = translateChunkUIState.localChatRoom, - originalChunk = translateChunkUIState.originalChunk, - translationField = translationFieldState, - onSuccess = { sessionId -> - // Onto the session rather than back to - // the chapter table. Nothing has been - // translated yet -- the chunk is - // translated when enough members sign -- - // so a table still showing it untranslated - // would read as a failure. - onNavigateToRouteAndPopUpInclusive.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - ) - }, - onFailure = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Failed translation") - ) - } + // imePadding: this screen has a text field, and without it the software + // keyboard covers whatever is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.translate_chunk)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" ) } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Propose translation" - ) - Text(stringResource(Res.string.propose_translation)) + }, + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (translateChunkUIState.canSign) { + Modifier + } else { + // Looking unavailable is not being unavailable: + // without this a screen reader still announces + // a button it is happy to press. + Modifier.semantics { disabled() } + }, + containerColor = if (translateChunkUIState.canSign) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (translateChunkUIState.canSign) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!translateChunkUIState.canSign) return@ExtendedFloatingActionButton + + translateChunkViewModel.proposeTranslation( + localChatRoom = translateChunkUIState.localChatRoom, + originalChunk = translateChunkUIState.originalChunk, + translationField = translationFieldState, + onSuccess = { sessionId -> + // Onto the session rather than back to + // the chapter table. Nothing has been + // translated yet -- the chunk is + // translated when enough members sign -- + // so a table still showing it untranslated + // would read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed translation") + ) + } + ) + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "Propose translation" + ) + Text(stringResource(Res.string.propose_translation)) + } } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + if (!translateChunkUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign a " + + "translation. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .readableContent() - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(MaterialTheme.spacing.space250), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) - ) { - if (!translateChunkUIState.canSign) { + Text( - text = "This group has no shared key, so it cannot sign a " + - "translation. Run a shared key ceremony first.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error + text = stringResource(Res.string.original), + style = MaterialTheme.typography.labelMedium + ) + Card { + Text( + modifier = Modifier.padding(MaterialTheme.spacing.containerPadding), + text = translateChunkUIState.originalChunk.text, + style = MaterialTheme.typography.bodyMedium + ) + } + + Text( + text = stringResource(Res.string.translation), + style = MaterialTheme.typography.labelMedium + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + state = translationFieldState, + label = { Text(stringResource(Res.string.translated_text)) }, + placeholder = { Text(stringResource(Res.string.enter_the_translation_for_this_chunk)) }, ) } - - Text( - text = stringResource(Res.string.original), - style = MaterialTheme.typography.labelMedium - ) - Card { - Text( - modifier = Modifier.padding(MaterialTheme.spacing.containerPadding), - text = translateChunkUIState.originalChunk.text, - style = MaterialTheme.typography.bodyMedium - ) - } - - Text( - text = stringResource(Res.string.translation), - style = MaterialTheme.typography.labelMedium - ) - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - state = translationFieldState, - label = { Text(stringResource(Res.string.translated_text)) }, - placeholder = { Text(stringResource(Res.string.enter_the_translation_for_this_chunk)) }, - ) } } - } - TranslateChunkUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = stringResource(Res.string.translate_chunk), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - LoadingDataIndicator(fillScreen = false) - Spacer(modifier = Modifier.weight(2f)) + TranslateChunkUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.translate_chunk), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt index ad365130..2df57884 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt @@ -60,6 +60,7 @@ import mantra.composeapp.generated.resources.chunks_translated import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -88,188 +89,190 @@ fun TranslationArtifactVersionDetailScreen( ) ) - when (val translationDetailUIState = translationDetailViewModel.translationDetailUIState) { - is TranslationArtifactVersionDetailUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) - Text(text = translationDetailUIState.message) - } - } - - is TranslationArtifactVersionDetailUIState.Loaded -> { - val translation = translationDetailUIState.translation - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { Text(translation.name) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - ) - } - ) { innerPadding -> - LazyColumn( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ScreenStateTransition(translationDetailViewModel.translationDetailUIState) { uiState -> + when (val translationDetailUIState = uiState) { + is TranslationArtifactVersionDetailUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - // Chapters the source version has and this translation - // does not. Above the list rather than after it: a chapter - // that is not here is invisible from a list of the ones - // that are. - val localChatRoom = translationDetailUIState.localChatRoom - val missingChapters = translationDetailUIState.missingChapters + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = translationDetailUIState.message) + } + } - if (missingChapters.isNotEmpty()) { - item { - Card { - ListItem( - leadingContent = { - Icon( - Icons.Default.PlaylistAdd, - contentDescription = "Chapters missing from this translation" - ) - }, - headlineContent = { - Text( - "${missingChapters.size} " + - (if (missingChapters.size == 1) "chapter is" else "chapters are") + - " not in this translation yet" - ) - }, - supportingContent = { - Text( - if (!translationDetailUIState.canSign) { - stringResource(Res.string.this_group_has_no_shared_key_so_it_cannot) - } else { - "They were signed into the artifact after this " + - "translation. Ask the group to add them so they " + - "can be translated." - } - ) - }, - trailingContent = { - TextButton( - enabled = localChatRoom != null && - translationDetailUIState.canSign && - !translationDetailViewModel.isActionPending.value, - onClick = { - if (localChatRoom == null) return@TextButton + is TranslationArtifactVersionDetailUIState.Loaded -> { + val translation = translationDetailUIState.translation + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(translation.name) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + // Chapters the source version has and this translation + // does not. Above the list rather than after it: a chapter + // that is not here is invisible from a list of the ones + // that are. + val localChatRoom = translationDetailUIState.localChatRoom + val missingChapters = translationDetailUIState.missingChapters - translationDetailViewModel.catchUpMissingChapters( - localChatRoom = localChatRoom, - missingChapters = missingChapters, - onSuccess = { sessionId -> - onNavigateToRoute.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId + if (missingChapters.isNotEmpty()) { + item { + Card { + ListItem( + leadingContent = { + Icon( + Icons.Default.PlaylistAdd, + contentDescription = "Chapters missing from this translation" + ) + }, + headlineContent = { + Text( + "${missingChapters.size} " + + (if (missingChapters.size == 1) "chapter is" else "chapters are") + + " not in this translation yet" + ) + }, + supportingContent = { + Text( + if (!translationDetailUIState.canSign) { + stringResource(Res.string.this_group_has_no_shared_key_so_it_cannot) + } else { + "They were signed into the artifact after this " + + "translation. Ask the group to add them so they " + + "can be translated." + } + ) + }, + trailingContent = { + TextButton( + enabled = localChatRoom != null && + translationDetailUIState.canSign && + !translationDetailViewModel.isActionPending.value, + onClick = { + if (localChatRoom == null) return@TextButton + + translationDetailViewModel.catchUpMissingChapters( + localChatRoom = localChatRoom, + missingChapters = missingChapters, + onSuccess = { sessionId -> + onNavigateToRoute.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) ) - ) - }, - onFailure = {} - ) + }, + onFailure = {} + ) + } + ) { + Text(stringResource(Res.string.propose)) } - ) { - Text(stringResource(Res.string.propose)) } + ) + } + } + + item { HorizontalDivider() } + } + + // Chapters with translation progress + item { + Text( + text = stringResource(Res.string.chapters), + style = MaterialTheme.typography.labelMedium + ) + } + if (translationDetailUIState.chapters.isEmpty()) { + item { Text(stringResource(Res.string.no_chapters)) } + } else { + items( + items = translationDetailUIState.chapters, + key = { progress -> progress.chapter.id } + ) { progress -> + Card( + onClick = { + onNavigateToRoute.invoke( + TranslationChapterRoute( + activeUserPublicKey = activeUserPublicKey, + translationChapterId = progress.chapter.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) } - ) + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.Article, + contentDescription = "Chapter" + ) + }, + headlineContent = { Text(stringResource(Res.string.chapter, progress.chapter.index)) }, + supportingContent = { + Text(stringResource(Res.string.chunks_translated, progress.translatedChunks, progress.totalChunks)) + } + ) + } } } item { HorizontalDivider() } - } - // Chapters with translation progress - item { - Text( - text = stringResource(Res.string.chapters), - style = MaterialTheme.typography.labelMedium - ) - } - if (translationDetailUIState.chapters.isEmpty()) { - item { Text(stringResource(Res.string.no_chapters)) } - } else { - items( - items = translationDetailUIState.chapters, - key = { progress -> progress.chapter.id } - ) { progress -> - Card( - onClick = { - onNavigateToRoute.invoke( - TranslationChapterRoute( - activeUserPublicKey = activeUserPublicKey, - translationChapterId = progress.chapter.id, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - ) { - ListItem( - leadingContent = { - Icon( - Icons.Default.Article, - contentDescription = "Chapter" - ) - }, - headlineContent = { Text(stringResource(Res.string.chapter, progress.chapter.index)) }, - supportingContent = { - Text(stringResource(Res.string.chunks_translated, progress.translatedChunks, progress.totalChunks)) - } - ) - } + // Details + item { + Text( + text = stringResource(Res.string.details), + style = MaterialTheme.typography.labelMedium + ) } - } - - item { HorizontalDivider() } - - // Details - item { - Text( - text = stringResource(Res.string.details), - style = MaterialTheme.typography.labelMedium - ) - } - item { - Card { - DetailRow(label = "Name", value = translation.name) - DetailRow(label = "Dialect", value = translation.dialectId) - DetailRow(label = "Visibility", value = translation.visibility) - DetailRow(label = "License", value = translation.license) - DetailRow(label = "Source version", value = translation.artifactVersionId) - DetailRow(label = "Author", value = translation.publicKey) - DetailRow(label = "Created", value = translation.createdAt.toString()) + item { + Card { + DetailRow(label = "Name", value = translation.name) + DetailRow(label = "Dialect", value = translation.dialectId) + DetailRow(label = "Visibility", value = translation.visibility) + DetailRow(label = "License", value = translation.license) + DetailRow(label = "Source version", value = translation.artifactVersionId) + DetailRow(label = "Author", value = translation.publicKey) + DetailRow(label = "Created", value = translation.createdAt.toString()) + } } } } } - } - TranslationArtifactVersionDetailUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = stringResource(Res.string.translation_detail), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - LoadingDataIndicator(fillScreen = false) - Spacer(modifier = Modifier.weight(2f)) + TranslationArtifactVersionDetailUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.translation_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt index 3fbf458f..2424a05d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt @@ -55,6 +55,7 @@ import mantra.composeapp.generated.resources.this_chapter_has_no_chunks import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -79,105 +80,107 @@ fun TranslationChapterScreen( ) ) - when (val translationChapterUIState = translationChapterViewModel.translationChapterUIState) { - is TranslationChapterUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) - Text(text = translationChapterUIState.message) - } - } - - is TranslationChapterUIState.Loaded -> { - Scaffold( - snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, - topBar = { - TopAppBar( - title = { Text(stringResource(Res.string.chapter_translation)) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - ) - } - ) { innerPadding -> - LazyColumn( - modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ScreenStateTransition(translationChapterViewModel.translationChapterUIState) { uiState -> + when (val translationChapterUIState = uiState) { + is TranslationChapterUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - // Header row: dialect names. - item { - TableRow( - left = { - Text( - text = translationChapterUIState.originalDialectName, - fontWeight = FontWeight.Bold - ) - }, - right = { - Text( - text = translationChapterUIState.translationDialectName, - fontWeight = FontWeight.Bold - ) - }, - ) - HorizontalDivider() - } + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = translationChapterUIState.message) + } + } - if (translationChapterUIState.rows.isEmpty()) { - item { - Box( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding), - contentAlignment = Alignment.Center - ) { - Text(stringResource(Res.string.this_chapter_has_no_chunks)) - } - } - } else { - items( - items = translationChapterUIState.rows, - key = { pair -> pair.originalChunk.id } - ) { pair -> - ChunkTranslationRow( - pair = pair, - onClick = { - onNavigateToRoute.invoke( - TranslateChunkRoute( - activeUserPublicKey = activeUserPublicKey, - translationChapterId = translationChapterId, - chunkId = pair.originalChunk.id, - chatRoomId = chatRoomId, - relayHint = relayHint - ) + is TranslationChapterUIState.Loaded -> { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.chapter_translation)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" ) } + }, + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) { + // Header row: dialect names. + item { + TableRow( + left = { + Text( + text = translationChapterUIState.originalDialectName, + fontWeight = FontWeight.Bold + ) + }, + right = { + Text( + text = translationChapterUIState.translationDialectName, + fontWeight = FontWeight.Bold + ) + }, ) HorizontalDivider() } + + if (translationChapterUIState.rows.isEmpty()) { + item { + Box( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding), + contentAlignment = Alignment.Center + ) { + Text(stringResource(Res.string.this_chapter_has_no_chunks)) + } + } + } else { + items( + items = translationChapterUIState.rows, + key = { pair -> pair.originalChunk.id } + ) { pair -> + ChunkTranslationRow( + pair = pair, + onClick = { + onNavigateToRoute.invoke( + TranslateChunkRoute( + activeUserPublicKey = activeUserPublicKey, + translationChapterId = translationChapterId, + chunkId = pair.originalChunk.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) + HorizontalDivider() + } + } } } } - } - TranslationChapterUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = stringResource(Res.string.chapter_translation), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - LoadingDataIndicator(fillScreen = false) - Spacer(modifier = Modifier.weight(2f)) + TranslationChapterUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.chapter_translation), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/ScreenState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/ScreenState.kt index 7dd74acc..0e74302d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/ScreenState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/ScreenState.kt @@ -1,5 +1,11 @@ package press.mantra.compose.ui.composable.widgets +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -7,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.Inbox +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -20,6 +27,7 @@ import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.something_went_wrong import mantra.composeapp.generated.resources.try_again import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.ui.theme.reducedMotion import press.mantra.compose.ui.theme.spacing /** @@ -125,3 +133,68 @@ private fun StateMessage( action?.invoke() } } + +/** + * Fades between a screen's states instead of cutting. + * + * Every screen in this app is a `when` over a UI state -- loading, error, empty, loaded -- + * and every one of those transitions was an unannounced cut: the spinner is there in one + * frame and the content is there in the next, with nothing saying they are the same screen + * answering the same question. + * + * M3's fade-through, which is the transition for content that replaces other content + * without being spatially related to it: the outgoing state fades out, the incoming one + * fades in and grows the last few percent into place. `SizeTransform` is off, so a tall + * loaded state does not stretch a short spinner on its way in. + * + * **The content key is the state's class, not the state.** Without it a screen would + * re-run the whole transition every time its loaded data changed -- a message arriving, a + * list growing -- which is a fade over content that did not change state at all. With it, + * the animation runs when the state does and the data flows through untouched. + * + * **`AnimatedContent` is a layout node**, so it must wrap a `when` that is a composable's + * whole body. Where the `when` sits inside a `Column` whose branches use + * `Modifier.weight`, wrapping it would take the branches out of `ColumnScope`; those + * screens are left alone and the reason is here rather than repeated at each of them. + * + * @param state the screen's current UI state. + * @param label named for the animation inspector; the screen's own name is the useful one. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ScreenStateTransition( + state: T, + modifier: Modifier = Modifier, + label: String = "screen state", + content: @Composable (T) -> Unit, +) { + val reduced = MaterialTheme.reducedMotion + val effects = MaterialTheme.motionScheme.defaultEffectsSpec() + val spatial = MaterialTheme.motionScheme.defaultSpatialSpec() + + AnimatedContent( + targetState = state, + modifier = modifier, + contentKey = { it::class }, + transitionSpec = { + if (reduced) { + // Still a transition, still not a cut -- what goes is the movement. The + // scale is the only spatial part of a fade-through. + fadeIn(effects) togetherWith fadeOut(effects) using SizeTransform(clip = false) + } else { + (fadeIn(effects) + scaleIn(spatial, initialScale = EnteringScale)) + .togetherWith(fadeOut(effects)) + .using(SizeTransform(clip = false)) + } + }, + label = label, + ) { targetState -> content(targetState) } +} + +/** + * How small the arriving state starts. + * + * M3's fade-through grows the incoming content from 92%. Any less reads as a zoom, which + * says the two states are the same thing seen closer rather than one replacing the other. + */ +private const val EnteringScale = 0.92f diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/ScreenStateTransitionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/ScreenStateTransitionJvmTest.kt new file mode 100644 index 00000000..c00c6add --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/ScreenStateTransitionJvmTest.kt @@ -0,0 +1,86 @@ +package press.mantra.compose.ui.composable.widgets + +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.ui.theme.TorchTheme +import kotlin.test.Test + +/** Stand-ins for a screen's UI state: two classes, and data that changes within one. */ +private sealed interface Fixture { + data object Loading : Fixture + data class Loaded(val text: String) : Fixture +} + +/** + * That a screen's state change is a transition rather than a cut, and that ordinary data + * changes are not. + * + * Both halves matter and the second is the one that is easy to get wrong. `AnimatedContent` + * keyed on the state *value* would re-run the whole fade every time a loaded list grew by + * one row -- a screen that flickers whenever a message arrives -- and it would look + * entirely correct in any screenshot. Keying on the state's class is what separates "the + * screen is now answering a different question" from "the answer got longer". + * + * The clock is held still so a frame in the middle of a transition can be inspected. That + * frame is the whole test: during a crossfade both states are composed, and during a cut + * only ever one is. + */ +@OptIn(ExperimentalTestApi::class) +class ScreenStateTransitionJvmTest { + + @Test + fun `both states are on screen while one replaces the other`() = + runDesktopComposeUiTest(400, 600) { + var state by mutableStateOf(Fixture.Loading) + mainClock.autoAdvance = false + setContent { TorchTheme { ScreenStateTransition(state) { Render(it) } } } + + mainClock.advanceTimeBy(16) + onNodeWithText("loading").assertIsDisplayed() + + runOnUiThread { state = Fixture.Loaded("first") } + mainClock.advanceTimeBy(48) + + // The frame that distinguishes a transition from a cut. + onNodeWithText("loading").assertExists() + onNodeWithText("first").assertExists() + + mainClock.autoAdvance = true + waitForIdle() + onNodeWithText("loading").assertDoesNotExist() + onNodeWithText("first").assertIsDisplayed() + } + + @Test + fun `data changing inside one state does not animate`() = runDesktopComposeUiTest(400, 600) { + var state by mutableStateOf(Fixture.Loaded("first")) + mainClock.autoAdvance = false + setContent { TorchTheme { ScreenStateTransition(state) { Render(it) } } } + + mainClock.advanceTimeBy(16) + onNodeWithText("first").assertIsDisplayed() + + runOnUiThread { state = Fixture.Loaded("second") } + mainClock.advanceTimeBy(48) + + // Same class, so `contentKey` is unchanged and nothing crossfades: the old text is + // gone at once rather than lingering through a fade nobody asked for. + onNodeWithText("first").assertDoesNotExist() + onNodeWithText("second").assertIsDisplayed() + + mainClock.autoAdvance = true + waitForIdle() + } + + @androidx.compose.runtime.Composable + private fun Render(state: Fixture) = when (state) { + Fixture.Loading -> Text("loading") + is Fixture.Loaded -> Text(state.text) + } +}