Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import com.flipcash.app.menu.ProfileCardScreen
import com.flipcash.app.menu.ProfileShareScreen
import com.flipcash.app.myaccount.BlocklistScreen
import com.flipcash.app.myaccount.UserProfileScreen
import com.flipcash.app.myaccount.EditProfileScreen
import com.flipcash.app.myaccount.SettingsScreen
import com.flipcash.app.scanner.ScannerScreen
import com.flipcash.app.shareapp.ShareAppScreen
Expand Down Expand Up @@ -171,6 +172,7 @@ fun appEntryProvider(
annotatedEntry<AppRoute.Menu.Lab> { key -> LabsScreen(onboarding = key.onboarding) }
annotatedEntry<AppRoute.Menu.UserProfile> { UserProfileScreen() }
annotatedEntry<AppRoute.Menu.Settings> { SettingsScreen() }
annotatedEntry<AppRoute.Menu.EditProfile> { EditProfileScreen() }
annotatedEntry<AppRoute.Menu.ShareProfile> { ProfileShareScreen() }
annotatedEntry<AppRoute.Menu.ProfileCard> { ProfileCardScreen() }
annotatedEntry<AppRoute.Menu.Blocklist> { BlocklistScreen() }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.flipcash.app.core.ui.transitions

import androidx.compose.animation.EnterExitState
import androidx.compose.animation.core.Transition
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.focus.FocusRequester
import androidx.navigation3.ui.LocalNavAnimatedContentScope
import kotlinx.coroutines.flow.first

/**
* The enter transition of the screen that hosts a nested flow. A flow's own steps animate inside
* their own nav display, so the slide that brought the whole flow in is not visible to them
* through [LocalNavAnimatedContentScope]; the host provides it here.
*/
val LocalHostEnterTransition: ProvidableCompositionLocal<Transition<EnterExitState>?> =
compositionLocalOf { null }

private val Transition<EnterExitState>.isSettled: Boolean
get() = currentState == EnterExitState.Visible && targetState == EnterExitState.Visible

/**
* Asks [focusRequester] for focus once the screen has finished arriving, rather than on first
* composition. Focusing during a push raises the keyboard mid-slide, which resizes the screen under
* the transition and makes the field jump.
*/
@Composable
fun RequestFocusWhenSettled(focusRequester: FocusRequester) {
val own = LocalNavAnimatedContentScope.current.transition
val host = LocalHostEnterTransition.current
LaunchedEffect(own, host) {
snapshotFlow { own.isSettled && (host?.isSettled ?: true) }.first { it }
focusRequester.requestFocus()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,10 @@ sealed interface AppRoute : NavKey, Parcelable {
sealed interface Menu : AppRoute {
@Serializable
data object Settings : Menu

/** Your cover, photo, name, handle, bio and minimum to chat, each opening its editor. */
@Serializable
data object EditProfile : Menu
@Serializable
data object BackupKey : Menu

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ enum class TipCodeExportFormat(val extension: String, val mimeType: String) {
*
* Separate directory from [TipCodePreviewStorage] so the preview cache's aggressive pruning can't
* delete an export out from under a share in progress, and so the two can be tuned independently.
* Files are named by the card's content [tipCodePreviewSignature], so re-exporting the same card
* Files are named by the caller's `baseName` when it sanitises to something usable, and by the
* card's content [tipCodePreviewSignature] otherwise, so re-exporting under the same name
* overwrites rather than accumulates.
*/
object TipCodeExportStorage {
Expand All @@ -50,8 +51,12 @@ object TipCodeExportStorage {
* Name is user-visible in some share targets ("save to Files"), hence the readable prefix
* rather than a bare hash.
*/
fun file(context: Context, signature: String, format: TipCodeExportFormat): File =
File(dir(context), "flipcash-code-$signature.${format.extension}")
fun file(
context: Context,
signature: String,
format: TipCodeExportFormat,
baseName: String? = null,
): File = File(dir(context), "${exportFileBaseName(baseName) ?: "flipcash-code-$signature"}.${format.extension}")

fun uriFor(context: Context, file: File): Uri =
FileProvider.getUriForFile(context, TipCodePreviewStorage.authority(context), file)
Expand All @@ -67,3 +72,19 @@ object TipCodeExportStorage {
private const val DEFAULT_MAX_TOTAL_BYTES = 8L * 1024 * 1024 // 8 MiB
private const val DEFAULT_MAX_AGE_MILLIS = 24L * 60 * 60 * 1000 // 1 day
}

private const val MaxBaseNameLength = 80
private val HostileFileNameChars = Regex("[\\\\/:*?\"<>|\\p{Cntrl}]")

/**
* [raw] made safe to use as a file name, or null when nothing usable is left. Drops path separators
* and the characters common file systems reject, trims, and caps the length without splitting a
* surrogate pair.
*/
fun exportFileBaseName(raw: String?): String? {
val cleaned = raw.orEmpty().replace(HostileFileNameChars, "").trim()
if (cleaned.isEmpty()) return null
var end = minOf(cleaned.length, MaxBaseNameLength)
if (end < cleaned.length && Character.isHighSurrogate(cleaned[end - 1])) end--
return cleaned.substring(0, end).trim().ifEmpty { null }
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,15 @@ class TipCodeExporter @Inject constructor(
* Writes [card]'s code in [format] and returns a `content://` handle to it, or `null` if the
* write failed — callers should degrade (share the URL alone) rather than surface an error.
*
* [baseName] names the file (the extension is added) where the share target shows it; without one
* the file is named from the card's signature.
*
* [sizePx] applies to [TipCodeExportFormat.Png] only; the SVG carries a `viewBox` and scales.
*/
suspend fun export(
card: Scannable.TipCard,
format: TipCodeExportFormat,
baseName: String? = null,
sizePx: Int = DEFAULT_PNG_SIZE_PX,
): TipCodeExport? = withContext(dispatchers.IO) {
val payload = card.data.toByteArray()
Expand All @@ -52,6 +56,7 @@ class TipCodeExporter @Inject constructor(
context = context,
signature = tipCodePreviewSignature(card),
format = format,
baseName = baseName,
)
when (format) {
TipCodeExportFormat.Png -> writePng(payload, sizePx, file)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,21 @@ sealed interface UpdateProfileStep : FlowStep, Parcelable {
@Serializable
object Photo : UpdateProfileStep

/** The banner at the top of the profile, picked and uploaded the way [Photo] is. */
@Parcelize
@Serializable
object Cover : UpdateProfileStep

/**
* The fee another user has to pay to open a DM, which the profile carries as
* `minDmChatInitFee`.
*/
@Parcelize
@Serializable
object MinimumTip : UpdateProfileStep

/** The profile's free-text bio, up to 160 characters. */
@Parcelize
@Serializable
object Bio : UpdateProfileStep
}
11 changes: 0 additions & 11 deletions apps/flipcash/core/src/main/res/drawable/ic_profile_picture.xml

This file was deleted.

26 changes: 20 additions & 6 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,6 @@
<string name="title_settingsSectionContracts">Contracts</string>
<string name="subtitle_settingsUserFlags">User Flags</string>
<string name="title_settingsSectionAccount">Account</string>
<string name="title_settingsSectionProfile">Profile</string>
<string name="title_settingsSectionSecurity">Security</string>
<string name="title_settingsSectionPrivacy">Privacy</string>
<string name="title_userFlags">User Flags</string>
Expand Down Expand Up @@ -1252,6 +1251,10 @@
<string name="error_description_profilePhotoNotAllowedFlaggedMisleading">AI flagged this photo as misleading. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X</string>
<string name="error_description_profilePhotoNotAllowedFlaggedSpam">AI flagged this photo as spam. Please try a different photo. If you think the photo was rejected in error please DM @flipcash on X</string>
<string name="title_setProfilePicture">Set Profile Picture</string>
<string name="title_cover">Cover</string>
<string name="action_changeCover">Change cover</string>
<string name="error_title_coverNotAllowed">Couldn\'t Upload Your Cover</string>
<string name="error_description_coverNotAllowed">This image isn\'t allowed as a cover. Please try a different image. If you think it was rejected in error please DM @flipcash on X</string>
<string name="placeholder_profileDisplayName">Your Name</string>
<string name="subtitle_profileImageRecommended">500x500 Recommended</string>
<string name="title_myTipCard">My Tip Card</string>
Expand All @@ -1271,7 +1274,7 @@
starting a conversation. Distinct from the send-side strings above, which are about a
tip in flight. The floor is explained in prose under the keypad and enforced by keeping
the save button inert, so there is no below-minimum error to show. -->
<string name="title_minimumToChat">Minimum To Chat</string>
<string name="title_minimumToChat">Minimum to Chat</string>
<string name="description_minimumToChat">Choose how much someone must send you to start chatting. You can send it back if the conversation is worthwhile.</string>
<string name="error_title_minimumTipFailed">Something Went Wrong</string>
<string name="error_description_minimumTipFailed">We were unable to save your minimum tip. Please try again</string>
Expand Down Expand Up @@ -1334,6 +1337,11 @@
<!-- You tab (tip card) -->
<string name="action_editProfile">Edit Profile</string>
<string name="action_claimYourUsernameLink">Claim your username ›</string>
<string name="action_changePhoto">Change photo</string>
<string name="label_editProfileName">Name</string>
<string name="placeholder_editProfileName">Add a name</string>
<string name="action_claimUsername">Claim your username</string>
<string name="placeholder_minimumToChat">Not set</string>
<string name="title_shareUserProfile">Share User Profile</string>
<!-- Who the share sheet is for: display name, then @handle -->
<string name="subtitle_shareUserProfile">%1$s · @%2$s</string>
Expand All @@ -1349,12 +1357,18 @@
<string name="label_exportSvg">SVG</string>
<string name="subtitle_exportSvg">Stays sharp at any size</string>
<string name="title_shareTipCode">My Flipcash Code</string>
<string name="label_profileCardFileName">Chat with %1$s</string>
<string name="label_profileCardFileNameFallback">Profile Card</string>
<string name="error_title_tipCardExportFailed">Couldn\'t Export</string>
<string name="error_description_tipCardExportFailed">We were unable to export your tip code. Please try again</string>
<!-- My Account rows (node 9544:18478). Each names the thing it edits rather than the act of
editing it, so the list reads as an inventory of the profile. -->
<string name="title_displayName">Display Name</string>
<string name="title_profilePicture">Profile Picture</string>

<!-- Bio editor. The count under the field is code points left of 160. -->
<string name="title_bio">Bio</string>
<string name="placeholder_bio">Add a bio</string>
<string name="error_bioNotAllowed">This bio isn\'t allowed. Try different wording</string>
<string name="error_bioInvalid">This bio isn\'t valid</string>
<string name="error_title_bioSaveFailed">Something Went Wrong</string>
<string name="error_description_bioSaveFailed">Please try again</string>

<!-- Tab bar labels, in tab order (node 10642:1325). -->
<string name="title_tabScan">Scan</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.flipcash.app.core.share

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class TipCodeExportNameTest {

@Test
fun `an ordinary name passes through`() {
assertEquals("Chat with Brandon", exportFileBaseName("Chat with Brandon"))
}

@Test
fun `characters a file system rejects are dropped`() {
assertEquals("Chat with ab", exportFileBaseName("Chat with a/b:*?\"<>|\\"))
}

@Test
fun `control characters and surrounding space are dropped`() {
assertEquals("Chat with Sam", exportFileBaseName(" Chat with Sam\n\t "))
}

@Test
fun `a name that sanitizes to nothing is null so the caller falls back`() {
assertNull(exportFileBaseName(" /:* "))
assertNull(exportFileBaseName(""))
}

@Test
fun `a very long name is capped`() {
assertEquals(80, exportFileBaseName("a".repeat(200))!!.length)
}

@Test
fun `emoji are kept whole when the cap lands inside one`() {
val name = "a".repeat(79) + "😀"
assertEquals("a".repeat(79), exportFileBaseName(name))
}
}
2 changes: 2 additions & 0 deletions apps/flipcash/features/menu/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ dependencies {
testImplementation(kotlin("test"))
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
testImplementation(libs.bundles.unit.testing)
testImplementation(testFixtures(project(":ui:resources")))

implementation(libs.bundles.haze)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) {
item(key = "profile_header") {
OwnProfileHeader(
profile = profileState.profile,
onEdit = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) },
onEdit = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.EditProfile)) },
onShare = null,
onClaimUsername = state.usernameProgress?.let {
{ viewModel.dispatchEvent(Event.ClaimUsername) }
Expand All @@ -137,11 +137,7 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) {
item(key = "profile_header") {
OwnProfileHeader(
profile = profileState.profile,
onEdit = {
// Slice 4 gives this its own screen; Settings is where profile
// editing lives until then.
viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings))
},
onEdit = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.EditProfile)) },
onShare = { viewModel.dispatchEvent(Event.ShareProfile) },
onClaimUsername = state.usernameProgress?.let {
{ viewModel.dispatchEvent(Event.ClaimUsername) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import com.flipcash.app.menu.internal.components.UsernameProgress
import com.flipcash.app.funding.PurchaseMethodController
import com.flipcash.app.tokens.core.TotalBalanceProvider
import com.flipcash.app.userflags.UserFlagsCoordinator
import com.flipcash.app.userflags.UsernameGate
import com.flipcash.app.userflags.usernameGate
import com.flipcash.features.menu.R
import com.flipcash.services.user.AuthState
import com.flipcash.services.models.UserProfile
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
import com.flipcash.app.core.bill.Scannable
import com.flipcash.app.core.share.TipCodeExportFormat
import com.flipcash.app.core.share.TipCodeExporter
import com.flipcash.app.core.share.exportFileBaseName
import com.flipcash.app.shareable.ShareSheetController
import com.flipcash.app.shareable.Shareable
import com.flipcash.features.menu.R
Expand Down Expand Up @@ -52,7 +53,16 @@ internal class ProfileCardViewModel @Inject constructor(
private fun export(format: TipCodeExportFormat) {
val card = card ?: return
viewModelScope.launch {
val export = tipCodeExporter.export(card, format)
// A name of nothing but characters a file system rejects sanitises to null, which must
// land on the fallback rather than "Chat with ". The exporter sanitises and caps the
// assembled name again.
val safeName = exportFileBaseName(card.user.displayName)
val baseName = if (safeName == null) {
resources.getString(R.string.label_profileCardFileNameFallback)
} else {
resources.getString(R.string.label_profileCardFileName, safeName)
}
val export = tipCodeExporter.export(card, format, baseName = baseName)
if (export == null) {
BottomBarManager.showMessage(
title = resources.getString(R.string.error_title_tipCardExportFailed),
Expand Down
Loading
Loading