diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index af1ac8cbc1..7fa93a3982 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -36,6 +36,7 @@ import com.flipcash.app.currency.RegionSelectionScreen import com.flipcash.app.deposit.DepositFlowScreen import com.flipcash.app.invite.InviteContactScreen import com.flipcash.app.messenger.ChatFlowScreen +import com.flipcash.app.messenger.ReportFlowScreen import com.flipcash.app.discovery.TokenDiscoveryScreen import com.flipcash.app.internal.ui.navigation.decorators.rememberNavMessagingEntryDecorator import com.flipcash.app.lab.LabsScreen @@ -119,6 +120,9 @@ fun appEntryProvider( annotatedEntry { key -> NewGroupFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } + annotatedEntry { key -> + ReportFlowScreen(route = key, resultStateRegistry = resultStateRegistry) + } // Tokens annotatedEntry(testTag = "token_info_screen") { key -> diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavMessagingEntryDecorator.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavMessagingEntryDecorator.kt index aac3befcfc..5329c1d339 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavMessagingEntryDecorator.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavMessagingEntryDecorator.kt @@ -25,8 +25,12 @@ fun NavMessagingEntryDecorator( val analytics = rememberAnalytics() Box { entry.Content() - val isTopEntry = entry.contentKey == backStack.lastOrNull()?.toString() - if (isTopEntry && entry.metadata[NavMetadataKeys.IsSheet.key] != true) { + val hosts = hostsBars( + isTopEntry = entry.contentKey == backStack.lastOrNull()?.toString(), + isSheet = entry.metadata[NavMetadataKeys.IsSheet.key] == true, + coversScreen = entry.metadata[NavMetadataKeys.IsFullscreenSheet.key] == true, + ) + if (hosts) { BottomBarContainer(barManager.barMessages) { message -> trace( message = "bottom bar message shown [${message.type.name}]", @@ -71,3 +75,16 @@ private fun NavKey.screenName(): String? { val name = this::class.java.name return name.removePrefix("$prefix$").replace('$', '.').takeIf { it != name } } + +/** + * Whether an entry is the one that puts the app's bars on screen. + * + * A sheet that covers the screen carries its own bars, exactly as a route does: the bar has the + * whole window to sit in, and its scrim has nothing left to leave showing. A partial sheet has + * neither — a bar inside one is clipped to the sheet, and one behind it is covered — so a partial + * sheet leaves bars to the route beneath it. That is why a bar raised from inside a partial sheet + * is only seen once the sheet has closed, and why a flow that wants to be acknowledged before it + * closes has to cover the screen. + */ +internal fun hostsBars(isTopEntry: Boolean, isSheet: Boolean, coversScreen: Boolean): Boolean = + isTopEntry && (!isSheet || coversScreen) diff --git a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/decorators/BarHostingTest.kt b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/decorators/BarHostingTest.kt new file mode 100644 index 0000000000..9db3248dd3 --- /dev/null +++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/decorators/BarHostingTest.kt @@ -0,0 +1,37 @@ +package com.flipcash.app.internal.ui.navigation.decorators + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Which entry puts the app's bars on screen. + * + * The rule is easy to get wrong in the direction that costs a whole feature: get it wrong for a + * sheet and a bar raised from inside that sheet is never seen, because it renders behind the sheet + * or is clipped to it. Report asks for a confirmation before it closes, so it needs the answer to + * be yes while it is still open. + */ +class BarHostingTest { + + @Test + fun `a route hosts its own bars`() { + assertTrue(hostsBars(isTopEntry = true, isSheet = false, coversScreen = false)) + } + + @Test + fun `a sheet that covers the screen hosts its own bars`() { + assertTrue(hostsBars(isTopEntry = true, isSheet = true, coversScreen = true)) + } + + @Test + fun `a partial sheet leaves bars to the route beneath it`() { + assertFalse(hostsBars(isTopEntry = true, isSheet = true, coversScreen = false)) + } + + @Test + fun `an entry that is not on top never hosts bars`() { + assertFalse(hostsBars(isTopEntry = false, isSheet = false, coversScreen = false)) + assertFalse(hostsBars(isTopEntry = false, isSheet = true, coversScreen = true)) + } +} diff --git a/apps/flipcash/core/build.gradle.kts b/apps/flipcash/core/build.gradle.kts index 71896ec05e..6092a52a90 100644 --- a/apps/flipcash/core/build.gradle.kts +++ b/apps/flipcash/core/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { api(project(":services:flipcash-compose")) + api(project(":libs:reporting")) implementation(project(":libs:messaging")) api(project(":libs:permissions:public")) implementation(project(":libs:vibrator:public")) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 2f2f3f1219..a9488c8ad9 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -4,6 +4,8 @@ import android.os.Parcelable import androidx.navigation3.runtime.NavKey import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.ReportSubject +import com.flipcash.app.core.reporting.ReportStep import com.flipcash.app.core.chat.NewGroupStep import com.flipcash.app.core.deposit.DepositResult import com.flipcash.app.core.deposit.DepositStep @@ -360,6 +362,33 @@ sealed interface AppRoute : NavKey, Parcelable { override val initialStack: List get() = listOf(NewGroupStep.Form) } + + /** + * Reporting a person, a group, or a single message. + * + * A top-level route rather than a step of [Chat], although every way in is a chat surface. + * Reporting a person is about the person, not the conversation it was opened from, and the + * flow outlives the surface: it is reachable from a profile, a group profile, and the + * selection bar, and sharing one route means those three cannot drift apart. It also keeps + * a half-written report out of the chat's view model. + * + * A [com.getcode.navigation.FullscreenSheet] without `WrapContentSheet`: it rests at a + * fixed height and stays there while the steps change. The wrap-content version of this + * deadlocked — such a sheet derives its drag anchors from the height its content reports + * and reads them again while placing that content, so content that resizes itself never + * settles. Fullscreen rather than the usual expanded detent because both steps are worked + * through rather than glanced at, and the second is a text field with the keyboard up. + */ + @Serializable + @Parcelize + data class Report(val subject: ReportSubject) : + Messaging, + FlowRoute, + com.getcode.navigation.Sheet, + com.getcode.navigation.FullscreenSheet { + override val initialStack: List + get() = listOf(ReportStep.ReasonSelection) + } } @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ReportSubject.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ReportSubject.kt new file mode 100644 index 0000000000..3a11b14a5d --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ReportSubject.kt @@ -0,0 +1,37 @@ +package com.flipcash.app.core.chat + +import android.os.Parcelable +import com.flipcash.services.models.chat.ChatId +import com.getcode.opencode.model.core.ID +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * What a report is about, already resolved to the ids the request will be keyed by. + * + * Mirrors [com.flipcash.services.models.ReportTarget] rather than reusing it: that one belongs to + * the service layer and is not a navigation type, and this has to survive process death on the + * back stack. + * + * It carries ids rather than naming a surface ("the chat this was opened on") because reporting is + * its own flow now — a top-level route — so there is no shared chat view model behind it to ask. + * Each surface resolves the ids it already holds when it opens the flow. + * + * The user case names the person, not the DM: a tip DM's id is derived on the client, so naming + * the chat would name something the server has never been told about, and a report made from a + * group member's profile is about the person either way. + */ +@Serializable +sealed interface ReportSubject : Parcelable { + @Parcelize + @Serializable + data class User(val userId: ID) : ReportSubject + + @Parcelize + @Serializable + data class Chat(val chatId: ChatId) : ReportSubject + + @Parcelize + @Serializable + data class Message(val chatId: ChatId, val messageId: Long) : ReportSubject +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/reporting/ReportStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/reporting/ReportStep.kt new file mode 100644 index 0000000000..3e09d6d291 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/reporting/ReportStep.kt @@ -0,0 +1,34 @@ +package com.flipcash.app.core.reporting + +import android.os.Parcelable +import com.flipcash.reporting.ReportReason +import com.getcode.navigation.NonDraggableRoute +import com.getcode.navigation.flow.FlowStep +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * Picking a reason, and — for [ReportReason.Other] alone — saying what happened. + * + * [NonDraggableRoute] at the interface level, as `CurrencyCreatorStep` does: the enclosing route is + * a sheet, and a sheet that can be dragged away is the wrong affordance over a half-written report. + * Leaving is offered by the app bar, which [com.getcode.navigation.flow.FlowHost] turns from a + * close into a back arrow once there is a step to go back to. + * + * Steps rather than one screen that swaps its own contents. The earlier version did the latter + * inside a wrap-content sheet and deadlocked the main thread: such a sheet takes its drag anchors + * from the height its content reports and reads those anchors again while placing that content, so + * content that resizes itself keeps changing the number it was measured against. A step boundary + * makes the height change once, between two fixed layouts. + */ +@Serializable +sealed interface ReportStep : FlowStep, Parcelable, NonDraggableRoute { + @Parcelize + @Serializable + data object ReasonSelection : ReportStep + + /** Reached only from [ReportReason.Other]; every other reason submits from the list. */ + @Parcelize + @Serializable + data class Details(val reason: ReportReason) : ReportStep +} diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 92cb2f7e00..a3cd5afd1e 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1165,6 +1165,31 @@ Something Went Wrong We were unable to unblock the user. Please try again + Report + Tell us what’s wrong. Your report is private — we won’t tell them who reported + Spam + Scam or Fraud + Harassment or Bullying + Sexual Content + Violence or Threats + Something Else + Repeated unwanted messages, promotions, or links + Attempts to take money or account details by deception + Abuse, insults, or repeated unwanted contact + Explicit content, or unwanted sexual messages + Threats of harm, or content showing violence + None of these fit — tell us in your own words + Tell us what’s wrong + Your report is private — we won’t tell them who reported + Tell us more + Submit Report + Expand + Collapse + Report Sent + Thanks. We’ll take a look + Something Went Wrong + We were unable to send your report. Please try again + Send Your First Tip diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 606acb52d5..20760fb700 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":libs:vibrator:bindings")) implementation(project(":libs:messaging")) + implementation(project(":libs:reporting")) implementation(project(":services:flipcash")) implementation(project(":services:opencode")) implementation(project(":libs:datetime")) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 046c1158cc..fb6b7c5f21 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -64,8 +64,8 @@ fun ChatFlowScreen( // Popping with the IME still up drags the screen behind it out from under the keyboard. onExit = { _, _ -> keyboard.hideIfVisible { navigator.pop() } }, entryProvider = chatEntryProvider(route.identifier, route.openKeyboard), - // ChatStep.AmountEntry, ChatStep.InitPayment, ChatStep.InviteToGroup and ChatStep.MuteChat - // are Sheets, so the + // ChatStep.AmountEntry, ChatStep.InitPayment, ChatStep.InviteToGroup and + // ChatStep.MuteChat are Sheets, so the // flow needs the sheet strategy to draw them as such; without it the step would fall // through to SinglePane and cover the thread. Amount entry // returns its result inside the flow (resultBackNavigator), so the strategy's own @@ -98,6 +98,7 @@ private fun chatEntryProvider( annotatedEntry { FlowMuteChatSheet() } + annotatedEntry { step -> FlowChatProfileScreen(step.contact) } @@ -274,6 +275,7 @@ private fun FlowMuteChatSheet() { ) } + @Composable private fun FlowChatProfileScreen(participant: ChatParticipant) { val viewModel = flowSharedViewModel() diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ReportFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ReportFlowScreen.kt new file mode 100644 index 0000000000..d505631b32 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ReportFlowScreen.kt @@ -0,0 +1,162 @@ +package com.flipcash.app.messenger + +import android.os.Parcelable +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.scene.SinglePaneSceneStrategy +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ReportSubject +import com.flipcash.app.core.reporting.ReportStep +import com.flipcash.app.messenger.internal.ReportViewModel +import com.flipcash.app.messenger.internal.screens.ReasonSelectionContent +import com.flipcash.app.messenger.internal.screens.ReportDetailsContent +import com.flipcash.reporting.ReportReason +import com.getcode.navigation.annotatedEntry +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.scenes.LocalSheetNavigator +import com.getcode.navigation.flow.FlowHost +import com.getcode.navigation.flow.FlowNavigator +import com.getcode.navigation.flow.flowSharedViewModel +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.navigation.flow.rememberInitialStack +import com.getcode.navigation.results.NavResultStateRegistry +import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy +import com.getcode.ui.utils.rememberKeyboardController + +/** + * Reporting a person, a group, or a message. + * + * Two steps, and most reports only see the first: picking a reason files it. Only + * [ReportReason.Other] has a second screen, because only it has nothing to go on without one. + * + * Steps rather than one screen that grows: the earlier version swapped its own contents inside a + * wrap-content sheet and deadlocked the main thread. Such a sheet takes its drag anchors from the + * height its content reports and reads those anchors again while placing that content, so content + * that resizes itself keeps changing the number it was measured against, and the loop outruns the + * frame clock that would have ended it. Here the route is a fixed-height sheet and each step is a + * fixed layout, so the height changes once, between them. + */ +@Composable +fun ReportFlowScreen( + route: AppRoute.Messaging.Report, + resultStateRegistry: NavResultStateRegistry, +) { + val navigator = LocalCodeNavigator.current + // Read before FlowHost overrides the locals below: non-null only because a sheet scene is what + // provides it, which makes its presence the answer to "is this flow being shown in a sheet". + val sheetNavigator = LocalSheetNavigator.current + val keyboard = rememberKeyboardController() + + FlowHost( + initialStack = route.rememberInitialStack(), + resultStateRegistry = resultStateRegistry, + // The details step owns the keyboard, so put it away before the chat behind this is + // uncovered — the same rule the chat and new-group flows follow. + // + // Popping outright would take the sheet off in one frame. Handing the sheet navigator a + // dismiss instead lets the scene animate it down and pop the entry itself once that + // finishes, so this must not also pop — that pair of pops is what used to land us back on + // the chat list. + // + // FlowHost has an animated exit of its own but will not use it here: it gates on + // `isSheetRoot`, which is `backStack.size <= 1`, and report opens over a chat. A sheet is + // no less a sheet for having something underneath it, so that gate is wrong for every + // flow opened this way — fixing it there is a wider change than this one. + onExit = { _, _ -> + keyboard.hideIfVisible { + if (sheetNavigator != null) { + // Empty lambda: animate out, nothing to do afterwards. + sheetNavigator.pendingSheetDismiss = {} + } else { + navigator.pop() + } + } + }, + entryProvider = reportEntryProvider(route.subject), + sceneStrategies = listOf( + ModalBottomSheetSceneStrategy(navigator.resultStore) { null }, + SinglePaneSceneStrategy(), + ), + ) +} + +@Composable +private fun reportEntryProvider( + subject: ReportSubject, +): (NavKey) -> NavEntry = entryProvider { + annotatedEntry { FlowReasonSelection(subject) } + annotatedEntry { step -> FlowReportDetails(subject, step.reason) } +} + +@Composable +private fun FlowReasonSelection(subject: ReportSubject) { + val viewModel = flowSharedViewModel() + val flowNavigator = rememberFlowNavigator() + val progress by viewModel.state.collectAsStateWithLifecycle() + + ExitOnceConfirmed(viewModel, flowNavigator) + + ReasonSelectionContent( + progress = progress, + onChoose = { reason -> + if (reason == ReportReason.Other) { + flowNavigator.navigateTo(ReportStep.Details(reason)) + } else { + // Every other reason is the whole report, so the pick is the submit. Asking for + // more after someone has already said what is wrong would be asking twice. + viewModel.submit(subject, reason, details = null) + } + }, + // `back` on its own: at the flow's root it exits the flow itself, so pairing it with an + // `exitCanceled()` for the false return would leave twice and pop the chat with us. + onNavigateUp = { flowNavigator.back() }, + ) +} + +@Composable +private fun FlowReportDetails(subject: ReportSubject, reason: ReportReason) { + val viewModel = flowSharedViewModel() + val flowNavigator = rememberFlowNavigator() + // Remembered against the step, so going back to the reasons and returning keeps what was + // typed — leaving by the back arrow is usually a second thought about the reason, not about + // the words. + val details = rememberTextFieldState() + val progress by viewModel.state.collectAsStateWithLifecycle() + + ExitOnceConfirmed(viewModel, flowNavigator) + + ReportDetailsContent( + state = details, + progress = progress, + onSubmit = { typed -> viewModel.submit(subject, reason, typed) }, + // Not the flow's root, so this steps back to the reasons rather than leaving. + onNavigateUp = { flowNavigator.back() }, + ) +} + +/** + * Closes the flow once the report has been acknowledged. + * + * What closes the flow is the confirmation being dismissed, not the submit being called. Reporting + * someone is not a throwaway action, and the flow used to vanish the instant the button was + * pressed, which left the confirmation to land on whatever happened to be behind it and said + * nothing at all when the send had failed. + * + * Collected per step rather than once around the host, because [FlowNavigator] is only in scope + * inside one — and the step that submitted is the one still composed while its confirmation is up. + */ +@Composable +private fun ExitOnceConfirmed( + viewModel: ReportViewModel, + flowNavigator: FlowNavigator, +) { + LaunchedEffect(viewModel) { + viewModel.confirmed.collect { flowNavigator.exitCanceled() } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 23502ab942..f48f6dc9bf 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -26,27 +26,12 @@ import com.flipcash.app.core.tokens.brandedName import com.flipcash.app.core.tokens.isReserve import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.core.util.Linkify -import com.flipcash.shared.chat.MessageCapability -import com.flipcash.shared.chat.MessagePolicy -import com.flipcash.shared.chat.withinWindows -import com.flipcash.shared.chat.applying -import com.flipcash.shared.chat.resolveCapabilities -import com.flipcash.shared.chat.models.ChatListItem -import com.flipcash.shared.chat.models.SenderIdentity -import com.flipcash.shared.chat.models.ChatQuote -import com.flipcash.shared.chat.models.ChatQuoteSnippet -import com.flipcash.shared.chat.models.LinkCard -import com.flipcash.shared.chat.models.LinkCardResolution -import com.flipcash.shared.chat.models.ReceiptStatus -import com.flipcash.shared.chat.models.SeparatorConfig -import com.flipcash.shared.chat.ui.detectUrls -import com.flipcash.shared.chat.ui.linkableText import com.flipcash.app.funding.PurchaseMethodController -import com.flipcash.app.session.CashLinkClaims -import com.flipcash.app.session.SettledClaim import com.flipcash.app.messenger.internal.link.ClaimReplyTargets import com.flipcash.app.messenger.internal.link.LinkCardClassifier import com.flipcash.app.messenger.internal.link.LinkCardResolver +import com.flipcash.app.session.CashLinkClaims +import com.flipcash.app.session.SettledClaim import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.messenger.R @@ -54,8 +39,6 @@ import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.TipAction import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile -import com.flipcash.shared.chat.GroupAccess -import com.flipcash.shared.chat.groupAccess import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType @@ -73,36 +56,58 @@ import com.flipcash.shared.chat.ActiveTypist import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftSnapshot import com.flipcash.shared.chat.ChatDraftStore -import com.flipcash.shared.chat.chatDraftOf import com.flipcash.shared.chat.ChatMembership +import com.flipcash.shared.chat.GroupAccess +import com.flipcash.shared.chat.MessageCapability +import com.flipcash.shared.chat.MessagePolicy +import com.flipcash.shared.chat.applying +import com.flipcash.shared.chat.chatDraftOf +import com.flipcash.shared.chat.groupAccess +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.ChatQuoteSnippet +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.LinkCardResolution +import com.flipcash.shared.chat.models.ReceiptStatus +import com.flipcash.shared.chat.models.SenderIdentity +import com.flipcash.shared.chat.models.SeparatorConfig +import com.flipcash.shared.chat.resolveCapabilities +import com.flipcash.shared.chat.ui.detectUrls +import com.flipcash.shared.chat.ui.linkableText +import com.flipcash.shared.chat.withinWindows import com.flipcash.shared.payments.ContactPaymentDelegate import com.flipcash.shared.payments.TipPaymentDelegate -import com.getcode.solana.keys.Mint -import com.getcode.utils.hexEncodedString -import com.getcode.opencode.model.core.ID import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.exchange.VerifiedFiatCalculator +import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.core.errors.ComputeVerifiedFiatError import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Limits import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.Mint import com.getcode.ui.utils.generateComplementaryColorPalette import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.hexEncodedString import com.getcode.utils.trace import com.getcode.view.BaseViewModel import com.getcode.view.LoadingSuccessState import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlin.math.min +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce @@ -113,6 +118,7 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -120,15 +126,9 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject -import kotlin.math.min -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds data class TypingConstraints( val enabled: Boolean = false, @@ -466,6 +466,10 @@ internal class ChatViewModel @Inject constructor( data class EditMessage(val messageId: Long, val text: String) : Event data class DeleteMessage(val messageId: Long) : Event + // Carries nothing because it does nothing but dismiss the bar: report is its own top-level + // flow, and the bar's caller has already captured the message to push the route with. + data object ReportRequested : Event + data object SubmitEdit : Event data object CancelEdit : Event data object EditingEnded : Event @@ -1495,6 +1499,7 @@ internal class ChatViewModel @Inject constructor( } .launchIn(viewModelScope) + eventFlow.filterIsInstance() .mapNotNull { stateFlow.value.subject as? ChatSubject.Group } .onEach { group -> @@ -2161,6 +2166,9 @@ internal class ChatViewModel @Inject constructor( is Event.CopyMessage -> { state -> state.copy(selection = null, confirmingDelete = false) } + Event.ReportRequested -> { state -> + state.copy(selection = null, confirmingDelete = false) + } is Event.EditMessage -> { state -> state.copy( selection = null, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ReportViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ReportViewModel.kt new file mode 100644 index 0000000000..fc707f98b6 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ReportViewModel.kt @@ -0,0 +1,133 @@ +package com.flipcash.app.messenger.internal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.chat.ReportSubject +import com.flipcash.features.messenger.R +import com.flipcash.reporting.ReportDescription +import com.flipcash.reporting.ReportReason +import com.flipcash.services.controllers.ReportingController +import com.flipcash.services.models.ReportTarget +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.trace +import com.getcode.view.LoadingSuccessState +import com.getcode.view.MinimumLoadingDuration +import com.getcode.view.SuccessHoldDuration +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Filing the report the flow has collected. + * + * Scoped to the flow rather than to either step, because the reason is chosen on one screen and — + * for [ReportReason.Other] — described on the next, and the submit outlives the step that asked for + * it. Previously this lived in the chat's view model, which tied a report to the conversation it + * happened to be opened from and kept a half-written one in chat state. + * + * The flow stays up until the report has been acknowledged. It used to close in the same frame the + * submit was called, which left the confirmation to land on whatever was behind it — telling + * someone their report was sent on a screen that had nothing to do with reporting, and saying + * nothing at all if the send failed. + */ +@HiltViewModel +internal class ReportViewModel @Inject constructor( + private val reporting: ReportingController, + private val resources: ResourceHelper, +) : ViewModel() { + + private val _state = MutableStateFlow(LoadingSuccessState()) + + /** + * What the submit button is showing: the send in flight, then that it landed. + * + * It stays on [LoadingSuccessState.success] rather than returning to idle, because the flow is + * still on screen behind the confirmation and a button back to "Submit Report" would be + * offering to file the same report again. + */ + val state: StateFlow = _state.asStateFlow() + + // Buffered rather than a shared flow: this fires once, from a bottom bar callback that can + // outlive the composition collecting it, and losing it would strand the flow open. + private val _confirmed = Channel(Channel.BUFFERED) + + /** + * Emits once the report has been acknowledged, which is what closes the flow. + * + * Dismissing the confirmation, not the send returning: the acknowledgement is the point of + * staying, so leaving before it is read would waste it. + */ + val confirmed: Flow = _confirmed.receiveAsFlow() + + /** [details] is the reporter's own words, and only [ReportReason.Other] collects them. */ + fun submit(subject: ReportSubject, reason: ReportReason, details: String?) { + // Covers a second press both while the first is in flight and after it has succeeded — + // either would file the same report twice and answer with two confirmations. + if (!_state.value.isIdle) return + + val target = when (subject) { + is ReportSubject.User -> ReportTarget.User(subject.userId) + is ReportSubject.Chat -> ReportTarget.Chat(subject.chatId) + is ReportSubject.Message -> ReportTarget.Message( + chatId = subject.chatId, + messageId = subject.messageId, + ) + } + + viewModelScope.launch { + _state.value = LoadingSuccessState(loading = true) + + // A report is a single unary call and usually answers inside a frame, so the button + // crossfaded from its label straight to a checkmark and the send read as never having + // happened. The floor starts with the spinner and runs alongside the send, so joining + // it waits only for whatever is left — a send that already outlasted it is not held + // back. Racing two `delay`s rather than reading a clock keeps this on whatever time + // source the caller's scheduler is using, virtual or real. + val floor = launch { delay(MinimumLoadingDuration) } + val sent = async { reporting.report(target, ReportDescription.build(reason, details)) } + + sent.await() + .onSuccess { + floor.join() + _state.value = LoadingSuccessState(success = true) + // The checkmark is drawn on the frame after this is set, so the confirmation + // waits out the same hold every other confirm button in the app does. Raising + // it immediately would put a scrim over a checkmark nobody had seen yet. + delay(SuccessHoldDuration) + + // The contract makes a duplicate report a no-op that answers OK, so this says + // the same thing whether or not the report was the first one. That is the + // intended reading: telling someone they had already reported this would be + // answering a question they did not ask. + BottomBarManager.showSuccess( + title = resources.getString(R.string.prompt_title_reportSubmitted), + message = resources.getString(R.string.prompt_description_reportSubmitted), + onDismiss = { _confirmed.trySend(Unit) }, + ) + } + .onFailure { + // Nothing is waiting on the floor down here: an error has its own bar to put + // up, and holding a spinner first would only delay it. + floor.cancel() + trace("failed to report - ${it.localizedMessage}") + // Back to idle, not to an error state: the flow stays where it is with the + // reason still picked, so the retry is one press on a button that has to be + // pressable to take it. + _state.value = LoadingSuccessState() + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToReport), + message = resources.getString(R.string.error_description_failedToReport), + ) + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReasonSelectionScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReasonSelectionScreen.kt new file mode 100644 index 0000000000..d5b4396524 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReasonSelectionScreen.kt @@ -0,0 +1,213 @@ +package com.flipcash.app.messenger.internal.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.sp +import com.flipcash.features.messenger.R +import com.flipcash.reporting.ReportReason +import com.getcode.navigation.flow.FlowDismissStyle +import com.getcode.navigation.flow.LocalFlowDismissStyle +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 +import com.getcode.theme.extraSmall +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.core.verticalScrollStateGradient +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeRadioButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.view.LoadingSuccessState + +/** + * Why this is being reported: one row per [ReportReason], picked before it is sent. + * + * A radio group rather than six buttons that each file on touch. Filing a report is not undoable + * from here, and the labels are close enough to one another that a mis-tap is a plausible way to + * reach the wrong one; a selection that a second, deliberate press confirms costs one tap and + * removes that whole class of mistake. It also gives each row room for a line of scope underneath + * its label — see [descriptionRes] — which six tap-to-send rows have nowhere to put. + * + * The button says what it will do, because that differs by row: [ReportReason.Other] opens a + * second step and everything else submits outright. What the flow then does with the answer is + * still the flow's business — this screen reports a choice and nothing more, which is what keeps + * the rows uniform. + * + * The nav control is a ✕ in the leading slot, asked for rather than inferred. [AppBarWithTitle] + * can resolve ✕ against ← on its own, but only from the depth of the stack the screen sits in, and + * report is pushed over the chat it was opened from — so at this step, which is the flow's first + * and leaves it outright, that reading comes out a ←. Leading rather than trailing because the + * route is a fullscreen modal; the trailing ✕ belongs to the bottom sheets. + */ +@Composable +internal fun ReasonSelectionContent( + onChoose: (ReportReason) -> Unit, + onNavigateUp: () -> Unit, + // The flow stays up until the report is acknowledged, so the button carries the send itself: + // the wait, then the checkmark that closing used to swallow. + progress: LoadingSuccessState = LoadingSuccessState(), +) { + // Saved, so stepping forward to the details screen and back again doesn't lose the pick that + // got you there. + var selected by rememberSaveable { mutableStateOf(null) } + + // This screen is always the first step of the report flow, so its nav control leaves the flow + // rather than stepping back within it. AppBarWithTitle cannot infer that: it decides from the + // outer stack depth, and report is pushed over the chat rather than opened as the sheet's + // first entry. Declaring it here puts the close on the trailing edge, where the app's other + // dismiss controls sit. + CompositionLocalProvider(LocalFlowDismissStyle provides FlowDismissStyle.Close) { + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_report), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = onNavigateUp, + ) + }, + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .testTag("action_report_reason_continue") + .padding(horizontal = CodeTheme.dimens.inset) + .navigationBarsPadding() + .padding(bottom = CodeTheme.dimens.grid.x3), + text = if (selected == ReportReason.Other) { + stringResource(com.flipcash.core.R.string.action_next) + } else { + stringResource(R.string.action_submitReport) + }, + // `progress.isIdle` as well as the pick: `CodeButton` reads its content + // colour and its clickability from `enabled`, not from `isLoading`/ + // `isSuccess`. Left enabled, the Filled button stays white, which hides a + // checkmark drawn in white and keeps taking presses through the send. + enabled = selected != null && progress.isIdle, + isLoading = progress.loading, + isSuccess = progress.success, + onClick = { selected?.let(onChoose) }, + ) + }, + ) { padding -> + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(scrollState) + .verticalScrollStateGradient( + scrollState = scrollState, + color = CodeTheme.colors.background, + isLongGradient = true, + ) + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x4), + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.subtitle_report), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = CodeTheme.dimens.grid.x4) + .selectableGroup(), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + for (reason in ReportReason.entries) { + ReportReasonRow( + modifier = Modifier.testTag("action_report_reason_${reason.name}"), + label = stringResource(reason.labelRes), + description = stringResource(reason.descriptionRes), + selected = selected == reason, + onSelect = { selected = reason }, + ) + } + } + } + } + } +} + +/** + * One choice: a radio, its label, and the line of scope under it. + * + * Built here rather than on [com.getcode.ui.components.ChoiceRow], which is a card with an icon + * and a single line and no notion of being selected. The card shape and [White05] fill are taken + * from it so the two read as the same family. + * + * The whole row is the target — [selectable] on the row, `null` on the radio — so the radio is a + * state indicator rather than a second, smaller thing to hit. + */ +@Composable +private fun ReportReasonRow( + label: String, + description: String, + selected: Boolean, + onSelect: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(CodeTheme.shapes.extraSmall) + .background(White05) + .selectable( + selected = selected, + role = Role.RadioButton, + onClick = onSelect, + ) + .padding(CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + CodeRadioButton( + selected = selected, + // The row owns the click; a second target inside it would only be a smaller one. + onClick = null, + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + text = label, + // 17sp Demi, matching ChoiceRow and the settings rows rather than `textMedium`. + style = CodeTheme.typography.textMedium.copy(fontSize = 17.sp, lineHeight = 22.sp), + color = CodeTheme.colors.textMain, + ) + + Text( + text = description, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReportDetailsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReportDetailsScreen.kt new file mode 100644 index 0000000000..5301e38b56 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReportDetailsScreen.kt @@ -0,0 +1,205 @@ +package com.flipcash.app.messenger.internal.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.ui.DisplayTextInput +import com.flipcash.features.messenger.R +import com.flipcash.reporting.ReportDescription +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.text.AnimatedNumberText +import com.getcode.ui.core.verticalScrollStateGradient +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.rememberKeyboardController +import com.getcode.view.LoadingSuccessState + +/** + * The free-text step, reached only by picking [ReportReason.Other]. + * + * This is a step of its own rather than a second act inside the reason sheet, and the reason is + * load-bearing: a sheet that sizes itself to its content derives its drag anchors from the height + * that content reports, then reads those anchors again while placing it. Growing the content to + * hold a text field therefore changes the number the sheet was measured from, and the two chase + * each other off the frame clock until Android kills the app for not responding. A step rests at a + * detent decided before the content is measured, so nothing it holds can feed back into its size — + * which is also why the field can simply take the room left over and the keyboard inset can be + * honoured honestly, instead of both being approximated from a fraction of the screen. + * + * The heading is two blocks, not one sentence: the ask ("tell us what's wrong") is what the + * screen wants, and the privacy line is what it offers in return. Run together at one weight they + * compete, and the part that reassures is the part that gets skipped. + * + * The counter runs down rather than up, and the row carries the remaining count as its + * content description: [AnimatedNumberText] animates per digit, so a screen reader walking the + * children would otherwise read a number that is mid-flight and briefly wrong. + */ +@Composable +internal fun ReportDetailsContent( + state: TextFieldState, + onSubmit: (String) -> Unit, + // See [ReasonSelectionContent]: the send is shown here rather than behind a closed flow. + progress: LoadingSuccessState = LoadingSuccessState(), + onNavigateUp: () -> Unit, +) { + val keyboard = rememberKeyboardController() + val typed = state.text.toString() + val remaining = ReportDescription.MAX_DETAILS_LENGTH - typed.length + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_report), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = onNavigateUp, + ) + }, + bottomBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x1), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + val isOverLimit = remaining < 0 + val textColor by animateColorAsState( + if (isOverLimit) CodeTheme.colors.errorText else CodeTheme.colors.textSecondary + ) + + Row( + modifier = Modifier + .testTag("report_details_counter") + .semantics(mergeDescendants = true) { + contentDescription = remaining.toString() + }, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedNumberText( + value = remaining.toString(), + style = CodeTheme.typography.textSmall, + color = textColor, + ) + + Text( + text = stringResource(com.flipcash.core.R.string.label_characters), + style = CodeTheme.typography.textSmall, + color = textColor, + ) + + AnimatedVisibility( + visible = typed.isNotEmpty(), + enter = fadeIn(), + exit = fadeOut(), + ) { + Text( + text = stringResource(com.flipcash.core.R.string.label_remaining), + style = CodeTheme.typography.textSmall, + color = textColor, + ) + } + } + + CodeButton( + modifier = Modifier + .fillMaxWidth() + .testTag("action_report_submit") + .navigationBarsPadding() + .padding(bottom = CodeTheme.dimens.grid.x3) + .imePadding(), + text = stringResource(R.string.action_submitReport), + // See [ReasonSelectionContent] on why `progress.isIdle` belongs here. + enabled = typed.isNotBlank() && !isOverLimit && progress.isIdle, + isLoading = progress.loading, + isSuccess = progress.success, + onClick = { keyboard.hideIfVisible { onSubmit(typed.trim()) } }, + ) + } + }, + ) { padding -> + val focusRequester = remember { FocusRequester() } + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(scrollState) + .verticalScrollStateGradient( + scrollState = scrollState, + color = CodeTheme.colors.background, + isLongGradient = true, + ) + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x4), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), + ) { + Column(verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2)) { + Text( + modifier = Modifier.fillMaxWidth(0.80f), + text = stringResource(R.string.title_reportDetails), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + + Text( + modifier = Modifier.fillMaxWidth(0.80f), + text = stringResource(R.string.subtitle_reportDetails), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + + DisplayTextInput( + state = state, + placeholder = stringResource(R.string.hint_reportDetails), + modifier = Modifier + .fillMaxWidth() + .testTag("input_report_details") + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + ), + style = CodeTheme.typography.textMedium, + placeholderStyle = CodeTheme.typography.textMedium.copy( + color = CodeTheme.colors.textTertiary, + ), + maxLines = Int.MAX_VALUE, + ) + } + + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReportReasonLabels.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReportReasonLabels.kt new file mode 100644 index 0000000000..0cfc572842 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ReportReasonLabels.kt @@ -0,0 +1,44 @@ +package com.flipcash.app.messenger.internal.screens + +import androidx.annotation.StringRes +import com.flipcash.features.messenger.R +import com.flipcash.reporting.ReportReason + +/** + * The label for each reason. + * + * An exhaustive `when` rather than a field on the enum: [ReportReason] is shared with iOS and + * compiled for both, and an Android string resource cannot cross that boundary. Exhaustive so a + * reason added upstream fails the build here instead of shipping a row with no words on it. + */ +@get:StringRes +internal val ReportReason.labelRes: Int + get() = when (this) { + ReportReason.Spam -> R.string.action_reportReason_spam + ReportReason.ScamOrFraud -> R.string.action_reportReason_scamOrFraud + ReportReason.Harassment -> R.string.action_reportReason_harassment + ReportReason.SexualContent -> R.string.action_reportReason_sexualContent + ReportReason.Violence -> R.string.action_reportReason_violence + ReportReason.Other -> R.string.action_reportReason_other + } + +/** + * What each reason covers, in one line under its label. + * + * Six words of label leave people guessing which bucket their situation belongs in, and a guess + * resolves as the nearest wrong one — which costs both the reporter and whoever reads it. The + * descriptions are scope, not policy: they say what the row collects, and promise nothing about + * what happens next. + * + * Exhaustive for the same reason [labelRes] is. + */ +@get:StringRes +internal val ReportReason.descriptionRes: Int + get() = when (this) { + ReportReason.Spam -> R.string.description_reportReason_spam + ReportReason.ScamOrFraud -> R.string.description_reportReason_scamOrFraud + ReportReason.Harassment -> R.string.description_reportReason_harassment + ReportReason.SexualContent -> R.string.description_reportReason_sexualContent + ReportReason.Violence -> R.string.description_reportReason_violence + ReportReason.Other -> R.string.description_reportReason_other + } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 07c061449a..ba7d530d91 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.automirrored.outlined.Reply import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Flag import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon @@ -40,11 +41,15 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ReportSubject import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.topFade import com.flipcash.features.messenger.R import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.models.ChatAction @@ -57,9 +62,8 @@ import com.getcode.theme.extraLarge import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.CircularIconButton -import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.topFade import com.getcode.ui.core.measured -import com.getcode.ui.core.unboundedClickable +import com.getcode.ui.core.noRippleClickable import com.getcode.ui.utils.KeyboardController import com.getcode.ui.utils.rememberKeyboardController @@ -99,7 +103,23 @@ internal fun ChatTopBar( when (target) { TopBarMode.Conversation -> ConversationTitleBar(navigator, state, chatActionHandler) TopBarMode.Editing -> EditingBar(dispatch) - is TopBarMode.Selecting -> MessageSelectionBar(target.selection, keyboard, dispatch) + is TopBarMode.Selecting -> MessageSelectionBar( + selection = target.selection, + keyboard = keyboard, + dispatch = dispatch, + // Resolved here rather than in the bar: reporting is its own top-level + // flow now, so it needs the chat's id, and this is the nearest scope holding + // both that and the navigator. + onReport = { + state.chatId?.let { chatId -> + navigator.push( + AppRoute.Messaging.Report( + ReportSubject.Message(chatId, target.selection.messageId) + ) + ) + } + }, + ) } } } @@ -150,7 +170,10 @@ private fun ConversationTitleBar( .fillMaxWidth() .then( if (state.canViewProfile) { - Modifier.unboundedClickable { + // No indication: the target is the whole title row, so an unbounded + // ripple centred on it washed across the bar on every tap. The role + // is kept so the row still reads as a button. + Modifier.noRippleClickable(role = Role.Button) { chatActionHandler(ChatAction.ViewProfile) } } else { @@ -225,6 +248,7 @@ private fun MessageSelectionBar( selection: ChatListItem.ContentBubble, keyboard: KeyboardController, dispatch: (ChatViewModel.Event) -> Unit, + onReport: () -> Unit, ) { val capabilities = selection.capabilities val body = selection.plainText @@ -232,7 +256,9 @@ private fun MessageSelectionBar( // Order is priority: the first actions keep their icons when the bar runs out of room. Reply // leads because it is the most common action and the only one a cash bubble offers — burying it // is the one choice that would leave that bubble's bar empty. Delete follows: putting the one - // action with a confirmation behind a menu makes it a three-tap job. + // action with a confirmation behind a menu makes it a three-tap job. Report goes last: it is + // the rarest of the five and the only one that is never about your own message, so it is the + // right one to collapse into the overflow on a narrow screen. val actions = buildList { if (MessageCapability.Reply in capabilities) { add( @@ -281,6 +307,22 @@ private fun MessageSelectionBar( ) ) } + if (MessageCapability.Report in capabilities) { + add( + MessageAction( + label = stringResource(R.string.title_report), + icon = Icons.Outlined.Flag, + testTag = "action_report_message", + // Unlike the others this one leaves the chat, so nothing on the way back + // dismisses the bar. Clearing it here means the transcript is at rest behind + // the report flow, and still at rest when it closes. + onClick = { + dispatch(ChatViewModel.Event.ReportRequested) + onReport() + }, + ) + ) + } } AppBarWithTitle( @@ -313,14 +355,30 @@ private data class MessageAction( ) /** - * The share of the bar the actions may occupy before they start collapsing into the overflow. + * The share of the actions' slot they may occupy before the rest collapse into the overflow. * * A share rather than a slot count, so the answer tracks the screen: at 40dp a button and 10dp - * between them, a normal phone fits all three actions and a compact one keeps the first inline - * with the rest a tap away. It stays a minority of the bar so the cluster still reads as trailing - * and leaves room for a title, should the selection bar ever grow one. + * between them, every phone width fits the three actions another participant's message offers, and + * the two extra on your own collapse into the menu. + * + * It is a ceiling, not a width — the row is laid out to its contents, so a bar leaving the budget + * unspent is no wider for it. The figure only has to clear three buttons and their two gaps, 140dp, + * on the narrowest phone worth supporting. 0.35 did not, and the miss was small enough to look like + * it should have worked: the slot is the bar less the app bar's 5dp either side, so a 400dp device + * budgets against 390dp and lands on 136.5dp — three and a half short of three buttons, with most + * of the bar standing empty and copy and report in a menu. + */ +private const val ActionBudgetFraction = 0.5f + +/** + * How many actions stay as icons in a [barWidth] slot. Extracted so the widths that decide it can + * be tested without laying a bar out; see `MessageActionCapacityTest`. */ -private const val ActionBudgetFraction = 0.35f +internal fun inlineActionCapacity(barWidth: Dp, buttonSize: Dp, spacing: Dp): Int = + // n buttons cost n widths and n-1 gaps, so adding one gap to both sides makes it a division. + ((barWidth * ActionBudgetFraction + spacing) / (buttonSize + spacing)) + .toInt() + .coerceAtLeast(1) @Composable private fun MessageActions(actions: List) { @@ -332,10 +390,7 @@ private fun MessageActions(actions: List) { val spacing = CodeTheme.dimens.grid.x2 BoxWithConstraints { - // n buttons cost n widths and n-1 gaps, so adding one gap to both sides makes it a division. - val capacity = ((maxWidth * ActionBudgetFraction + spacing) / (buttonSize + spacing)) - .toInt() - .coerceAtLeast(1) + val capacity = inlineActionCapacity(maxWidth, buttonSize, spacing) // The overflow needs a slot of its own, so it only pays for itself when it is holding // something — the last action is not displaced by a menu that would contain only it. val inline = if (actions.size <= capacity) actions else actions.take(capacity - 1) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index fc93d31e67..59bcf44b0a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -21,6 +21,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.ReportSubject +import com.flipcash.app.core.AppRoute +import com.getcode.navigation.core.LocalCodeNavigator import com.flipcash.app.menu.MenuItem import com.flipcash.app.menu.MenuList import com.flipcash.app.messenger.internal.ChatMuteStatusChip @@ -60,6 +63,7 @@ internal fun ChatProfileScreen( chatViewModel: ChatViewModel, ) { val flowNavigator = rememberFlowNavigator() + val navigator = LocalCodeNavigator.current val state by viewModel.stateFlow.collectAsStateWithLifecycle() val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() @@ -72,12 +76,14 @@ internal fun ChatProfileScreen( modifier = Modifier .fillMaxSize() .padding(innerPadding), - // Mute first, block last: the one that is reversible and routine sits above the one - // that ends the conversation, the same order the group's profile puts leaving in. + // Mute first, then report, block last: the reversible and routine sits above the one + // that asks someone else to look, which sits above the one that ends the conversation. + // Same shape as the group's profile, where leaving holds the last place. items = buildList> { if (chatState.chatType == ChatType.TIP_DM) { add(MuteDm) } + add(ReportUser) add(BlockUser) }, header = { @@ -105,6 +111,17 @@ internal fun ChatProfileScreen( // Both muting and unmuting go through the picker, which is why this row // navigates either way rather than acting on one of them here. ChatProfileAction.Mute -> flowNavigator.navigateTo(ChatStep.MuteChat) + // Not flowNavigator: Report is a top-level route rather than a step of + // this flow, and LocalCodeNavigator hands a non-FlowStep route up to its + // parent. So it opens over the chat rather than inside it. + ChatProfileAction.Report -> + (state.participant as? ChatParticipant.TipUser)?.let { participant -> + navigator.push( + AppRoute.Messaging.Report( + ReportSubject.User(participant.userId) + ) + ) + } } }, endSlot = { item -> diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt index c0c0e2eb53..9c4c0fc4b4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.screens.profile import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.material.icons.outlined.Flag import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter @@ -22,6 +23,7 @@ internal sealed interface GroupProfileAction { data object Invite : GroupProfileAction data object Mute : GroupProfileAction data object Leave : GroupProfileAction + data object Report : GroupProfileAction } /** @@ -44,6 +46,16 @@ internal data object InviteToGroup : FullMenuItem() { override val action: GroupProfileAction = GroupProfileAction.Invite } +internal data object ReportGroup : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Flag) + + override val name: String + @Composable get() = stringResource(R.string.title_report) + + override val action: GroupProfileAction = GroupProfileAction.Report +} + internal data object LeaveChat : FullMenuItem() { override val icon: Painter @Composable get() = rememberVectorPainter(Icons.AutoMirrored.Outlined.Logout) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt index d42bd5ebd3..2f99654c8c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -19,6 +19,9 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.ReportSubject +import com.flipcash.app.core.AppRoute +import com.getcode.navigation.core.LocalCodeNavigator import com.flipcash.app.menu.MenuItem import com.flipcash.app.menu.MenuList import com.flipcash.app.messenger.internal.ChatMuteStatusChip @@ -44,6 +47,7 @@ import kotlinx.coroutines.flow.filterIsInstance @Composable internal fun GroupProfileScreen(viewModel: ChatViewModel) { val flowNavigator = rememberFlowNavigator() + val navigator = LocalCodeNavigator.current val state by viewModel.stateFlow.collectAsStateWithLifecycle() val group = state.subject as? ChatSubject.Group @@ -67,16 +71,18 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { modifier = Modifier .fillMaxSize() .padding(innerPadding), - // Every row is for members only: a non-member has no link to hand out, nothing to - // leave, and no viewer state on a chat they are not in. Membership comes from the + // Every other row is for members only: a non-member has no link to hand out, nothing + // to leave, and no viewer state on a chat they are not in. Membership comes from the // roster rather than from the gate, so the rows go away the moment the leave itself // lands, not when the balance rule next re-decides. + // + // Reporting is the exception, and deliberately so: a group you have already left is + // the one you are most likely to report. items = buildList> { if (state.groupInviteUrl != null) add(InviteToGroup) - if (group?.isMember == true) { - add(MuteChat) - add(LeaveChat) - } + if (group?.isMember == true) add(MuteChat) + add(ReportGroup) + if (group?.isMember == true) add(LeaveChat) }, header = { GroupProfileHeader( @@ -105,6 +111,13 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { GroupProfileAction.Mute -> flowNavigator.navigateTo(ChatStep.MuteChat) GroupProfileAction.Leave -> viewModel.dispatchEvent(ChatViewModel.Event.LeaveChat) + // The outer navigator, not this flow's: reporting is its own top-level + // route, so it opens over the chat rather than inside it. + GroupProfileAction.Report -> state.chatId?.let { chatId -> + navigator.push( + AppRoute.Messaging.Report(ReportSubject.Chat(chatId)) + ) + } } }, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt index 569cac053d..cb9ffce220 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.screens.profile import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.Flag import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter @@ -21,6 +22,17 @@ import com.flipcash.features.messenger.R internal sealed interface ChatProfileAction { data object Block : ChatProfileAction data object Mute : ChatProfileAction + data object Report : ChatProfileAction +} + +internal data object ReportUser : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Flag) + + override val name: String + @Composable get() = stringResource(R.string.title_report) + + override val action: ChatProfileAction = ChatProfileAction.Report } internal data object BlockUser : FullMenuItem() { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt index 790879ae2b..8070348935 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt @@ -141,6 +141,21 @@ class ChatMessageActionReducerTest { assertNull(state.selection) } + @Test + fun `reporting clears the bar`() { + // Report leaves the chat for a flow of its own, so nothing here dismisses the bar on the + // way back. Without this the transcript is still holding the reported message highlighted + // when the flow closes over it. + val selected = reduce( + ChatViewModel.State(messagePolicy = unbounded), + ChatViewModel.Event.ToggleMessageSelection(bubble(1)), + ) + + val state = reduce(selected, ChatViewModel.Event.ReportRequested) + + assertNull(state.selection) + } + @Test fun `the delete confirmation holds the selection but drops the focus`() { // The bar still has its message, so the sheet's Cancel has something to return to. The diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSelectionBarTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSelectionBarTest.kt index 0dbd97cb83..d7c0c16607 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSelectionBarTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSelectionBarTest.kt @@ -72,7 +72,95 @@ class ChatSelectionBarTest { "action_delete_message", "action_copy_message", "action_edit_message", + "action_report_message", "action_message_overflow", ).forEach { composeTestRule.onAllNodesWithTag(it).assertCountEquals(0) } } + + // Wider than the class default on purpose. Which actions stay as icons and which collapse into + // the overflow falls out of the width, and these two tests are about whether report is offered + // at all — given room for every action, the icon is there or it is not. + @Test + @Config(qualifiers = "w800dp-h800dp-xhdpi") + fun `a message from someone else can be reported`() { + // The capability set is what `resolveCapabilities` returns for another participant's text; + // that mapping has its own tests in `:shared:chat`, so this pins only the bar's reading. + select( + text( + isFromSelf = false, + capabilities = setOf( + MessageCapability.Copy, + MessageCapability.Reply, + MessageCapability.Report, + ), + ) + ) + + composeTestRule.onNodeWithTag("action_report_message").assertIsDisplayed() + } + + @Test + @Config(qualifiers = "w800dp-h800dp-xhdpi") + fun `your own message cannot be reported`() { + select( + text( + isFromSelf = true, + capabilities = setOf( + MessageCapability.Copy, + MessageCapability.Reply, + MessageCapability.Edit, + MessageCapability.Delete, + ), + ) + ) + + composeTestRule.onAllNodesWithTag("action_report_message").assertCountEquals(0) + } + + // At the class's own 400dp — a real device width, and the one the budget used to tie on. + @Test + fun `another participant's message shows all three actions as icons`() { + select( + text( + isFromSelf = false, + capabilities = setOf( + MessageCapability.Copy, + MessageCapability.Reply, + MessageCapability.Report, + ), + ) + ) + + listOf("action_reply_message", "action_copy_message", "action_report_message") + .forEach { composeTestRule.onNodeWithTag(it).assertIsDisplayed() } + // Three fit, so there is nothing left for a menu to hold. + composeTestRule.onAllNodesWithTag("action_message_overflow").assertCountEquals(0) + } + + private fun text(isFromSelf: Boolean, capabilities: Set) = + ChatListItem.ContentBubble( + messageId = 2, + contentIndex = 0, + content = MessageContent.Text("hello"), + isFromSelf = isFromSelf, + timestamp = Instant.fromEpochSeconds(1_000), + capabilities = capabilities, + ) + + private fun select(bubble: ChatListItem.ContentBubble) { + composeTestRule.setContent { + FlipcashPreview { + ChatTopBar( + navigator = mockk(relaxed = true), + state = ChatViewModel.State( + chatType = ChatType.CONTACT_DM, + selection = bubble, + ), + onBarHeightChange = {}, + chatActionHandler = {}, + dispatch = {}, + ) + } + } + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ConversationTitleBarTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ConversationTitleBarTest.kt new file mode 100644 index 0000000000..9af27f5dac --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ConversationTitleBarTest.kt @@ -0,0 +1,80 @@ +package com.flipcash.app.messenger.internal + +import androidx.activity.ComponentActivity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.flipcash.app.messenger.internal.screens.components.ChatTopBar +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.models.ChatAction +import com.flipcash.services.models.chat.ChatType +import com.getcode.navigation.core.CodeNavigator +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals + +/** + * The conversation bar's title opens the subject's profile. It is the whole title row, so it draws + * no indication — an unbounded ripple centred on a row that wide washed across the bar on every + * tap. Dropping a ripple is easy to do by dropping the click's semantics with it, which is what + * these hold in place: the row still announces itself as a button and still acts on a tap. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class ConversationTitleBarTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val group = ChatSubject.Group( + chatId = ChatId(byteArrayOf(1)), + groupTitle = "Bad Boys", + picture = null, + memberCount = 412L, + rules = null, + isMember = true, + ) + + private fun show(onAction: (ChatAction) -> Unit = {}) { + composeTestRule.setContent { + FlipcashPreview { + ChatTopBar( + navigator = mockk(relaxed = true), + state = ChatViewModel.State(chatType = ChatType.GROUP, subject = group), + onBarHeightChange = {}, + chatActionHandler = onAction, + dispatch = {}, + ) + } + } + } + + @Test + fun `the title still announces itself as a button`() { + show() + + composeTestRule.onNodeWithText("Bad Boys").assert( + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button) + ) + } + + @Test + fun `tapping the title opens the profile`() { + val actions = mutableListOf() + show(onAction = { actions += it }) + + composeTestRule.onNodeWithText("Bad Boys").assert(hasClickAction()).performClick() + + assertEquals(listOf(ChatAction.ViewProfile), actions.toList()) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ReportViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ReportViewModelTest.kt new file mode 100644 index 0000000000..172387fc88 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ReportViewModelTest.kt @@ -0,0 +1,224 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.core.chat.ReportSubject +import com.flipcash.reporting.ReportReason +import com.flipcash.services.controllers.ReportingController +import com.getcode.manager.BottomBarManager +import com.getcode.manager.SelectedBottomBarAction +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.MinimumLoadingDuration +import com.getcode.view.SuccessHoldDuration +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * What a report does once it has been sent. + * + * The flow used to close the moment submit was called and let a message land behind it, which said + * the report was sent to a screen that was already gone. These pin the replacement: the button + * carries the send and holds its checkmark long enough to be read, the confirmation follows while + * the flow is still up, and it is dismissing that confirmation which closes it. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ReportViewModelTest { + + private val reporting = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val subject = ReportSubject.User(listOf(1, 2, 3)) + + // Shared with `runTest` below, so the hold the view model waits out is the same virtual clock + // the test advances. A dispatcher on its own scheduler would make that delay real time. + private val scheduler = TestCoroutineScheduler() + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher(scheduler)) + BottomBarManager.clear() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + BottomBarManager.clear() + } + + private fun viewModel() = ReportViewModel(reporting, resources) + + @Test + fun `a sent report is confirmed, not mentioned in passing`() = runTest(scheduler) { + coEvery { reporting.report(any(), any()) } returns Result.success(Unit) + + viewModel().submit(subject, ReportReason.Spam, details = null) + advanceUntilIdle() + + val shown = BottomBarManager.messages.value.last() + assertEquals(BottomBarManager.BottomBarMessageType.SUCCESS, shown.type) + } + + @Test + fun `an instant send still shows its spinner`() = runTest(scheduler) { + // The report call answers inside a frame, so without a floor the button crossfaded from + // its label straight to the checkmark and the send read as having never happened. + coEvery { reporting.report(any(), any()) } returns Result.success(Unit) + val model = viewModel() + + model.submit(subject, ReportReason.Spam, details = null) + + assertTrue(model.state.value.loading) + advanceTimeBy(MinimumLoadingDuration.inWholeMilliseconds - 1) + assertTrue(model.state.value.loading) + advanceTimeBy(2) + assertTrue(model.state.value.success) + } + + @Test + fun `a slow send is not held back any further`() = runTest(scheduler) { + // The floor is a minimum, not an addition: a send that already outlasted it goes straight + // to the checkmark rather than sitting on a spinner for another half second. + val answer = CompletableDeferred>() + coEvery { reporting.report(any(), any()) } coAnswers { answer.await() } + val model = viewModel() + + model.submit(subject, ReportReason.Spam, details = null) + advanceTimeBy(MinimumLoadingDuration.inWholeMilliseconds * 2) + assertTrue(model.state.value.loading) + + answer.complete(Result.success(Unit)) + advanceTimeBy(1) + + assertTrue(model.state.value.success) + } + + @Test + fun `the checkmark is held long enough to be read before the confirmation`() = runTest(scheduler) { + coEvery { reporting.report(any(), any()) } returns Result.success(Unit) + val viewModel = viewModel() + + viewModel.submit(subject, ReportReason.Spam, details = null) + // Past the spinner's floor, which is where the checkmark's own hold starts. + // `advanceTimeBy` stops short of the endpoint, so the floor's own instant needs the +1. + advanceTimeBy(MinimumLoadingDuration.inWholeMilliseconds + 1) + + assertTrue("the button never showed the send succeeded", viewModel.state.value.success) + assertTrue( + "the confirmation covered the checkmark before it could be read", + BottomBarManager.messages.value.isEmpty(), + ) + + advanceTimeBy(SuccessHoldDuration) + advanceUntilIdle() + assertEquals( + BottomBarManager.BottomBarMessageType.SUCCESS, + BottomBarManager.messages.value.last().type, + ) + } + + @Test + fun `the button stays done while the confirmation is up`() = runTest(scheduler) { + coEvery { reporting.report(any(), any()) } returns Result.success(Unit) + val viewModel = viewModel() + + viewModel.submit(subject, ReportReason.Spam, details = null) + advanceUntilIdle() + + assertTrue( + "the button went back to inviting a second report of the same thing", + viewModel.state.value.success, + ) + } + + @Test + fun `the flow closes when the confirmation is dismissed, not when the report lands`() = runTest(scheduler) { + coEvery { reporting.report(any(), any()) } returns Result.success(Unit) + val viewModel = viewModel() + + var closed = false + val collector = launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.confirmed.collect { closed = true } + } + + viewModel.submit(subject, ReportReason.Spam, details = null) + advanceUntilIdle() + assertFalse("the flow closed before anyone saw the confirmation", closed) + + BottomBarManager.messages.value.last().onClose(SelectedBottomBarAction(index = 0)) + assertTrue("dismissing the confirmation left the flow open", closed) + + collector.cancel() + } + + @Test + fun `a failed report leaves the flow open to try again`() = runTest(scheduler) { + coEvery { reporting.report(any(), any()) } returns Result.failure(Throwable("nope")) + val viewModel = viewModel() + + var closed = false + val collector = launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.confirmed.collect { closed = true } + } + + viewModel.submit(subject, ReportReason.Spam, details = null) + advanceUntilIdle() + + assertEquals( + BottomBarManager.BottomBarMessageType.ERROR, + BottomBarManager.messages.value.last().type, + ) + assertFalse("a failed report closed the flow", closed) + assertTrue("the retry was left disabled", viewModel.state.value.isIdle) + + collector.cancel() + } + + @Test + fun `the send is visible while it is in flight`() = runTest(scheduler) { + val inFlight = CompletableDeferred>() + coEvery { reporting.report(any(), any()) } coAnswers { inFlight.await() } + val viewModel = viewModel() + + assertTrue(viewModel.state.value.isIdle) + + viewModel.submit(subject, ReportReason.Spam, details = null) + assertTrue("the button gives no sign the report is being sent", viewModel.state.value.loading) + + inFlight.complete(Result.success(Unit)) + advanceUntilIdle() + assertFalse("the button was still spinning after the report landed", viewModel.state.value.loading) + } + + @Test + fun `a second press cannot file the same report twice`() = runTest(scheduler) { + val inFlight = CompletableDeferred>() + coEvery { reporting.report(any(), any()) } coAnswers { inFlight.await() } + val viewModel = viewModel() + + viewModel.submit(subject, ReportReason.Spam, details = null) + viewModel.submit(subject, ReportReason.Spam, details = null) + + inFlight.complete(Result.success(Unit)) + advanceUntilIdle() + + assertEquals( + "the report was filed twice, and answered twice", + 1, + BottomBarManager.messages.value.size, + ) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/ReportFlowTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/ReportFlowTest.kt new file mode 100644 index 0000000000..90425f1c68 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/ReportFlowTest.kt @@ -0,0 +1,298 @@ +package com.flipcash.app.messenger.internal.screens + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertContentDescriptionEquals +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.filter +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.flipcash.features.messenger.R +import com.flipcash.reporting.ReportDescription +import com.flipcash.reporting.ReportReason +import com.flipcash.app.theme.FlipcashPreview +import com.getcode.view.LoadingSuccessState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class ReportFlowTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private var chosen: ReportReason? = null + private var submitted: String? = null + private var dismissed = false + + private fun showReasons(progress: LoadingSuccessState = LoadingSuccessState()) { + composeTestRule.setContent { + FlipcashPreview { + ReasonSelectionContent( + progress = progress, + onChoose = { chosen = it }, + onNavigateUp = { dismissed = true }, + ) + } + } + } + + private fun showDetails( + state: TextFieldState = TextFieldState(), + progress: LoadingSuccessState = LoadingSuccessState(), + ) { + composeTestRule.setContent { + FlipcashPreview { + ReportDetailsContent( + state = state, + progress = progress, + onSubmit = { submitted = it }, + onNavigateUp = { dismissed = true }, + ) + } + } + } + + // `TopAppBarBase` subcomposes the leading slot more than once: once to measure and place it, + // once as a probe to tell a real control from an empty slot, and — for a centred title — a + // phantom back arrow that only reserves width. Every copy carries the icon's test tag, so the + // tag alone says nothing about what is on screen. Only the real control is ever placed. + private val placed = SemanticsMatcher("is placed") { it.layoutInfo.isPlaced } + + private fun navControl(tag: String) = + composeTestRule.onAllNodesWithTag(tag).filterToOne(placed) + + private fun assertNoNavControl(tag: String) = + composeTestRule.onAllNodesWithTag(tag).filter(placed).assertCountEquals(0) + + @Test + fun `offers every reason in the shared vocabulary`() { + showReasons() + + for (reason in ReportReason.entries) { + composeTestRule.onNodeWithTag("action_report_reason_${reason.name}").assertExists() + } + } + + @Test + fun `says what each reason covers`() { + showReasons() + + for (reason in ReportReason.entries) { + composeTestRule + .onNodeWithText(composeTestRule.activity.getString(reason.descriptionRes)) + .assertExists() + } + } + + @Test + fun `picking a row is not yet an answer`() { + showReasons() + + composeTestRule.onNodeWithTag("action_report_reason_${ReportReason.Harassment.name}") + .performClick() + + assertNull(chosen) + } + + @Test + fun `will not continue before a reason is picked`() { + showReasons() + + composeTestRule.onNodeWithTag("action_report_reason_continue").assertIsNotEnabled() + } + + @Test + fun `answers with the reason that was picked`() { + showReasons() + + composeTestRule.onNodeWithTag("action_report_reason_${ReportReason.Harassment.name}") + .performClick() + composeTestRule.onNodeWithTag("action_report_reason_continue").performClick() + + assertEquals(ReportReason.Harassment, chosen) + } + + @Test + fun `answers with the last row picked, not the first`() { + showReasons() + + composeTestRule.onNodeWithTag("action_report_reason_${ReportReason.Spam.name}") + .performClick() + composeTestRule.onNodeWithTag("action_report_reason_${ReportReason.Violence.name}") + .performClick() + composeTestRule.onNodeWithTag("action_report_reason_continue").performClick() + + assertEquals(ReportReason.Violence, chosen) + } + + @Test + fun `answers with Other rather than deciding what it costs`() { + showReasons() + + composeTestRule.onNodeWithTag("action_report_reason_${ReportReason.Other.name}") + .performClick() + composeTestRule.onNodeWithTag("action_report_reason_continue").performClick() + + assertEquals(ReportReason.Other, chosen) + } + + @Test + fun `calls the button what it will do`() { + showReasons() + + composeTestRule.onNodeWithTag("action_report_reason_${ReportReason.Spam.name}") + .performClick() + composeTestRule.onNodeWithTag("action_report_reason_continue") + .assertTextEquals( + composeTestRule.activity.getString(R.string.action_submitReport) + ) + + composeTestRule.onNodeWithTag("action_report_reason_${ReportReason.Other.name}") + .performClick() + composeTestRule.onNodeWithTag("action_report_reason_continue") + .assertTextEquals( + composeTestRule.activity.getString(com.flipcash.core.R.string.action_next) + ) + } + + @Test + fun `will not submit an empty description`() { + showDetails() + + composeTestRule.onNodeWithTag("action_report_submit").assertIsNotEnabled() + } + + @Test + fun `submits the description without its surrounding whitespace`() { + showDetails() + + composeTestRule.onNode(hasSetTextAction()) + .performTextInput(" they kept messaging me ") + composeTestRule.onNodeWithTag("action_report_submit").performClick() + + assertEquals("they kept messaging me", submitted) + } + + @Test + fun `counts down the room left in the description`() { + showDetails() + + composeTestRule.onNode(hasSetTextAction()).performTextInput("hello") + + composeTestRule.onNodeWithTag("report_details_counter") + .assertContentDescriptionEquals("${ReportDescription.MAX_DETAILS_LENGTH - 5}") + } + + @Test + fun `refuses a description that is over the limit`() { + showDetails(TextFieldState("x".repeat(ReportDescription.MAX_DETAILS_LENGTH + 1))) + + composeTestRule.onNodeWithTag("action_report_submit").assertIsNotEnabled() + composeTestRule.onNodeWithTag("action_report_submit").performClick() + assertNull(submitted) + } + + @Test + fun `submits once there is something to send`() { + showDetails() + + composeTestRule.onNode(hasSetTextAction()).performTextInput("spamming links") + + composeTestRule.onNodeWithTag("action_report_submit").assertIsEnabled() + } + + @Test + fun `leaves the first step by closing, not by going back`() { + showReasons() + + navControl("action_close").assertExists() + assertNoNavControl("action_back") + } + + @Test + fun `the close sits on the trailing edge`() { + showReasons() + val bar = composeTestRule.onRoot().getUnclippedBoundsInRoot() + val close = navControl("action_close").getUnclippedBoundsInRoot() + // Trailing, not leading: the close dismisses a layer, and this app puts that control at + // the far edge (see the sheet-root close in AppBarWithTitle) rather than where a back + // arrow would sit. + val midpoint = (bar.left + bar.right) / 2 + assertTrue( + "close starts at ${close.left}, expected past the midpoint $midpoint", + close.left > midpoint, + ) + } + + @Test + fun `closing the first step asks to leave the flow`() { + showReasons() + + navControl("action_close").performClick() + + assertTrue(dismissed) + } + + @Test + fun `leaves the details step by going back, not by closing`() { + showDetails() + + navControl("action_back").assertExists() + assertNoNavControl("action_close") + } + + // `CodeButton` draws its spinner in the content colour and its checkmark in a hardcoded white, + // and picks the content colour from `enabled` rather than from `isLoading`/`isSuccess`. Left + // enabled, a Filled button keeps its white background and the checkmark is white on white. + // Every other confirm button in the app pairs the two — `enabled = && + // progress.isIdle` — and that pairing is what these hold in place. `CodeButton` also wires + // `clickable` to the raw `enabled`, so without it the button keeps taking presses as well. + @Test + fun `the reason button stops taking presses while the report is sending`() { + showReasons(LoadingSuccessState(loading = true)) + + composeTestRule.onNodeWithTag("action_report_reason_${ReportReason.Spam.name}") + .performClick() + + composeTestRule.onNodeWithTag("action_report_reason_continue").assertIsNotEnabled() + } + + @Test + fun `the reason button stops taking presses once the report has landed`() { + showReasons(LoadingSuccessState(success = true)) + + composeTestRule.onNodeWithTag("action_report_reason_${ReportReason.Spam.name}") + .performClick() + + composeTestRule.onNodeWithTag("action_report_reason_continue").assertIsNotEnabled() + } + + @Test + fun `the details button stops taking presses while the report is sending`() { + showDetails(progress = LoadingSuccessState(loading = true)) + + composeTestRule.onNode(hasSetTextAction()).performTextInput("something happened") + + composeTestRule.onNodeWithTag("action_report_submit").assertIsNotEnabled() + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageActionCapacityTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageActionCapacityTest.kt new file mode 100644 index 0000000000..74cf6edba0 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageActionCapacityTest.kt @@ -0,0 +1,62 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * How many of the selection bar's actions stay as icons before the rest collapse into the overflow. + * + * The number a phone lands on is the whole point of the budget, so these pin it at real device + * widths rather than at the fraction. Three is the figure that matters: another participant's text + * message offers reply, copy and report, and that is the common case. + */ +class MessageActionCapacityTest { + + /** + * The budget is measured on the slot the actions are laid out in, not on the screen. The app + * bar wraps that slot in 5dp either side, so the slot is 10dp short of the device — the gap + * that put a 400dp phone one capacity below three. + */ + private fun capacityOnDevice(deviceWidth: Int) = inlineActionCapacity( + // What the bar actually lays out: a 40dp circular button with 10dp between. + barWidth = (deviceWidth - 10).dp, + buttonSize = 40.dp, + spacing = 10.dp, + ) + + @Test + fun `a 400dp phone fits all three of another participant's actions`() { + // A real device width, and the one this was reported on: 0.35 of its 390dp slot came to + // 136.5dp against the 140dp three buttons and their two gaps need, so copy and report sat + // in a menu with most of the bar empty. + assertTrue(capacityOnDevice(400) >= 3) + } + + @Test + fun `the narrowest phone worth supporting still fits all three`() { + // 320dp is the floor, and the binding case: it is what the fraction has to clear. + assertEquals(3, capacityOnDevice(320)) + } + + @Test + fun `a typical phone fits all three`() { + assertTrue(capacityOnDevice(411) >= 3) + } + + @Test + fun `a tablet fits every action the bar has`() { + // Five is the most the bar ever offers — reply, delete, copy, edit and report on your own + // text. The budget is a ceiling rather than a width, so a roomier screen leaving it unspent + // costs nothing; what matters is only that nothing is forced into a menu. + assertTrue(capacityOnDevice(800) >= 5) + } + + @Test + fun `a bar with no room to speak of still offers one action`() { + // Below one button's width the budget goes negative; the bar shows an icon and a menu + // rather than a menu alone, which would hide reply behind two taps on every message. + assertEquals(1, inlineActionCapacity(barWidth = 0.dp, buttonSize = 40.dp, spacing = 10.dp)) + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt index 34d91cd236..0b139562c4 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt @@ -26,6 +26,7 @@ enum class MessageCapability { Reply, Edit, Delete, + Report, } /** @@ -97,9 +98,15 @@ data class MessagePolicy( * | Own text, confirmed, past the edit window | Copy, Reply, Delete | * | Own text, confirmed, past both windows | Copy, Reply | * | Own text, unconfirmed (`eventSequence == 0`) | none | - * | Another participant's text | Copy, Reply | - * | Any cash or tip message | Reply | + * | Another participant's text | Copy, Reply, Report | + * | Own cash or tip message | Reply | + * | Another participant's cash or tip message | Reply, Report | * | A tombstone | none | + * | A system notice | none | + * + * Report follows one rule: anything a participant sent can be reported, and anything the server + * wrote, or that no longer exists, cannot. Your own messages are left out because reporting one is + * not a thing anyone does, and a row that is always present is a row people stop reading. */ fun resolveCapabilities( message: ChatMessage, @@ -120,7 +127,12 @@ fun resolveCapabilities( // Cash is never editable: `EditMessageRequest.content` accepts Text, Reply, and Media, never // Cash. It is deliberately not deletable either, so a payment cannot be hidden from the // transcript that records it. - if (contents.any { it is MessageContent.Cash }) return setOf(MessageCapability.Reply) + if (contents.any { it is MessageContent.Cash }) { + return buildSet { + add(MessageCapability.Reply) + if (!message.isFromSelf) add(MessageCapability.Report) + } + } // Server-authored notices, not a participant's message. if (contents.all { it is MessageContent.System }) return emptySet() @@ -135,6 +147,8 @@ fun resolveCapabilities( if (message.isFromSelf) { if (hasText) add(MessageCapability.Edit) add(MessageCapability.Delete) + } else { + add(MessageCapability.Report) } }.withinWindows(message.timestamp, policy, now) } @@ -156,7 +170,10 @@ fun Set.withinWindows( when (capability) { MessageCapability.Edit -> policy.editWindow.stillOpen(sentAt, now) MessageCapability.Delete -> policy.deleteWindow.stillOpen(sentAt, now) - MessageCapability.Copy, MessageCapability.Reply -> true + // Reporting has no window on purpose: the edit and delete windows exist because the + // server enforces them, and nothing in the contract limits how old a reportable message + // may be. + MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report -> true } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt index f8e80af6d6..8d33e91d97 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt @@ -70,9 +70,9 @@ class MessageCapabilityTest { } @Test - fun `someone else's text message is copyable but not editable or deletable`() { + fun `someone else's text message is copyable and reportable, not editable or deletable`() { assertEquals( - setOf(MessageCapability.Copy, MessageCapability.Reply), + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report), resolveCapabilities(text(isFromSelf = false)), ) } @@ -80,7 +80,56 @@ class MessageCapabilityTest { @Test fun `cash is never editable or deletable, by either party`() { assertEquals(setOf(MessageCapability.Reply), resolveCapabilities(cash())) - assertEquals(setOf(MessageCapability.Reply), resolveCapabilities(cash(isFromSelf = false))) + // Reportable though: a payment is something a participant sent, and it is one of the + // things most worth reporting. + assertEquals( + setOf(MessageCapability.Reply, MessageCapability.Report), + resolveCapabilities(cash(isFromSelf = false)), + ) + } + + /** + * Reporting your own message is left out because it is not a thing anyone does, and a row that + * is always present is a row people stop reading. This is the first capability whose answer + * turns on `isFromSelf` for a reason that is not about authorship privileges, so it is worth + * pinning at both ends. + */ + @Test + fun `your own messages are never reportable`() { + assertEquals( + emptySet(), + resolveCapabilities(text(), now = sentAt).filterTo(mutableSetOf()) { + it == MessageCapability.Report + }, + ) + assertEquals(emptySet(), resolveCapabilities(cash()) - MessageCapability.Reply) + } + + @Test + fun `nothing the server wrote, or that is already gone, can be reported`() { + val theirTombstone = message( + listOf(MessageContent.Deleted(sentAt, otherId)), + isFromSelf = false, + ) + assertEquals(emptySet(), resolveCapabilities(theirTombstone)) + + val theirSystemNotice = message( + listOf(MessageContent.System("Anna joined")), + isFromSelf = false, + ) + assertEquals(emptySet(), resolveCapabilities(theirSystemNotice)) + } + + @Test + fun `reporting has no window, so an old message stays reportable`() { + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report), + resolveCapabilities( + text(isFromSelf = false), + policy = MessagePolicy.Default, + now = sentAt + 365.days, + ), + ) } @Test diff --git a/ui/core/src/main/kotlin/com/getcode/ui/core/Modifier.kt b/ui/core/src/main/kotlin/com/getcode/ui/core/Modifier.kt index d156b23ca7..9ef7692e6d 100644 --- a/ui/core/src/main/kotlin/com/getcode/ui/core/Modifier.kt +++ b/ui/core/src/main/kotlin/com/getcode/ui/core/Modifier.kt @@ -69,13 +69,24 @@ inline fun Modifier.addIf( } } -fun Modifier.noRippleClickable(enabled: Boolean = true, onClick: () -> Unit) = - this.clickable( - enabled = enabled, - indication = null, - interactionSource = null, - onClick = onClick - ) +/** + * A click with no indication at all. + * + * [role] defaults to null to leave every existing caller's semantics as they were; pass + * [Role.Button] when the target is one, so dropping the ripple does not also drop what a screen + * reader announces. + */ +fun Modifier.noRippleClickable( + enabled: Boolean = true, + role: Role? = null, + onClick: () -> Unit, +) = this.clickable( + enabled = enabled, + role = role, + indication = null, + interactionSource = null, + onClick = onClick +) fun Modifier.unboundedClickable( enabled: Boolean = true, diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt index 3ff4996d24..85845cb761 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/NavMetadata.kt @@ -18,6 +18,7 @@ enum class NavMetadataKeys(val key: String, ) { IsSheet("sheet"), IsWrapContentSheet("sheet_wrap_content"), IsHalfSheet("sheet_half"), + IsFullscreenSheet("sheet_fullscreen"), IsSolitarySheet("sheet_solitary"), NavResultKey("navresult_key"), } @@ -70,6 +71,7 @@ fun KClass<*>.metadata(): Map { NavMetadataKeys.IsSheet.key to Sheet::class.java.isAssignableFrom(this.java), NavMetadataKeys.IsWrapContentSheet.key to WrapContentSheet::class.java.isAssignableFrom(this.java), NavMetadataKeys.IsHalfSheet.key to HalfSheet::class.java.isAssignableFrom(this.java), + NavMetadataKeys.IsFullscreenSheet.key to FullscreenSheet::class.java.isAssignableFrom(this.java), NavMetadataKeys.IsSolitarySheet.key to SolitarySheet::class.java.isAssignableFrom(this.java), NavMetadataKeys.IsNonDismissable.key to NonDismissableRoute::class.java.isAssignableFrom(this.java), NavMetadataKeys.IsNonDraggable.key to NonDraggableRoute::class.java.isAssignableFrom(this.java), diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt index 2e7de506b7..ee8d0f0a6d 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/Types.kt @@ -14,6 +14,19 @@ interface WrapContentSheet: NavKey * [com.getcode.ui.utils.AllowSheetExpansionWhenScrollable]. */ interface HalfSheet: NavKey +/** + * Rests at the full height of the screen, status bar included. + * + * Every other sheet stops at 92.5%, which leaves the screen behind it visible as a reminder that + * the sheet is a layer over something. A route that is a screen in its own right — one someone + * works through rather than glances at — has nothing to gain from that sliver and loses the room + * it costs. [com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy] squares off the top + * corners and insets the content past the status bar for these, so the result reads as a screen + * that arrived from the bottom rather than a card wedged against the top of the display. + * + * Declare it alongside [Sheet]; on its own it does nothing, exactly as [HalfSheet] does not. + */ +interface FullscreenSheet: NavKey interface NonDismissableRoute: NavKey interface NonDraggableRoute: NavKey interface SolitarySheet: NavKey diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowNavigator.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowNavigator.kt index 4a38171e70..0f493b9796 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowNavigator.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/flow/FlowNavigator.kt @@ -30,8 +30,12 @@ interface FlowNavigator { fun replaceStack(steps: List) /** - * Pop the current step. Returns true if a step was popped, or false if this was the root of - * the flow (in which case the flow itself should be exited by the host). + * Pop the current step, or exit the flow if this was its root. + * + * The return value reports which of those happened — true if a step was popped, false if the + * flow was exited. It is not a request for the caller to exit: the host has already been told. + * Following a false return with [exitCanceled] exits a second time, which costs a host that + * pops on exit one entry more than it opened. */ fun back(): Boolean diff --git a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt index a2968bbae2..03665b4042 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/navigation/scenes/ModalBottomSheetSceneStrategy.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -90,6 +91,17 @@ private val Half = SheetDetent("half") { containerHeight, _ -> containerHeight * 0.5f } +/** + * Resting detent for a [com.getcode.navigation.FullscreenSheet]: the whole container, status bar + * included. + * + * [Expanded]'s 7.5% gap is what tells you a sheet is a layer over something. A route that is a + * screen in its own right does not need telling, so it takes the height back. + */ +private val Fullscreen = SheetDetent("fullscreen") { containerHeight, _ -> + containerHeight +} + /** An [OverlayScene] that renders an [entry] within an [UnstyledBottomSheet]. */ internal class ModalBottomSheetScene constructor( override val key: T, @@ -147,13 +159,17 @@ internal class ModalBottomSheetScene constructor( val isWrapContent = metadata[NavMetadataKeys.IsWrapContentSheet.key] as? Boolean ?: false - // A half sheet gains an extra resting detent and opens there; a wrap-content sheet rests - // at its own content height; every other sheet keeps the two-detent (hidden/expanded) - // behaviour. Half wins the overlap: those sheets are sized by the fraction, and - // wrap-content only strips their `fillMaxHeight()` further down. + // A half sheet gains an extra resting detent and opens there; a fullscreen sheet takes + // the whole container; a wrap-content sheet rests at its own content height; every other + // sheet keeps the two-detent (hidden/expanded) behaviour. Half wins the overlap: those + // sheets are sized by the fraction, and wrap-content only strips their `fillMaxHeight()` + // further down. val isHalfSheet = metadata[NavMetadataKeys.IsHalfSheet.key] as? Boolean ?: false + val isFullscreen = + metadata[NavMetadataKeys.IsFullscreenSheet.key] as? Boolean ?: false val restingDetent = when { isHalfSheet -> Half + isFullscreen -> Fullscreen isWrapContent -> WrapContent else -> Expanded } @@ -317,7 +333,19 @@ internal class ModalBottomSheetScene constructor( Sheet( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)) + // Rounded corners read as a card lifted over the screen behind it. + // A fullscreen sheet has no screen behind it to be lifted over, and + // the curves would only carve two notches out of the display's own + // square corners. + .then( + if (isFullscreen) { + Modifier + } else { + Modifier.clip( + RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) + ) + } + ) .background(CodeTheme.colors.surface), ) { Box( @@ -327,6 +355,16 @@ internal class ModalBottomSheetScene constructor( if (!isWrapContent) { Modifier.fillMaxHeight() } else Modifier + ) + // Every other sheet rests below the status bar and so never had + // to think about it. This one reaches it: the surface is drawn + // behind the bar (no scrim sliver at the top) and the content is + // inset past it, the way a full-screen route's own app bar + // insets itself. + .then( + if (isFullscreen) { + Modifier.statusBarsPadding() + } else Modifier ), ) { SharedTransitionLayout { diff --git a/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt b/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt index 26f639dea0..93a9b24752 100644 --- a/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt +++ b/ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt @@ -83,6 +83,20 @@ abstract class BaseViewModel( */ val SuccessHoldDuration = 500.milliseconds +/** + * The least time a confirm button's spinner stays up once it has gone up. + * + * A floor, not a hold: work that already ran longer moves on as soon as it is done, and only work + * that answers faster than this is waited out. Pairs with [SuccessHoldDuration] at the other end — + * between them a fast call still reads as start, work, done, rather than crossfading from a label + * to a checkmark with nothing in between. + * + * Measure it from the frame the spinner was asked for. Measuring from the call's return, as + * `Result.onSuccessWithDelay` does, always waits the full duration, because by then the work is + * over — which adds latency to a slow call rather than putting a floor under a fast one. + */ +val MinimumLoadingDuration = 500.milliseconds + data class LoadingSuccessState( val loading: Boolean = false, val success: Boolean = false,