diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index d80ee5c0..51555f18 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -199,6 +199,19 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/components/QrCode.kt b/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/components/QrCode.kt
index ac3e96ae..ce130796 100644
--- a/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/components/QrCode.kt
+++ b/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/components/QrCode.kt
@@ -7,7 +7,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
@@ -44,7 +43,7 @@ fun QrCode(
) {
val sizePx = with(LocalDensity.current) { size.roundToPx() }
val bitmap = remember(content, sizePx, foreground, background) {
- encodeQr(content, sizePx, foreground.toArgb(), background.toArgb())
+ qrBitmap(content, sizePx, foreground.toArgb(), background.toArgb())?.asImageBitmap()
} ?: return
Image(
@@ -57,7 +56,18 @@ fun QrCode(
)
}
-private fun encodeQr(content: String, sizePx: Int, fgArgb: Int, bgArgb: Int): ImageBitmap? {
+/**
+ * The same code as [QrCode], as a plain bitmap — for the callers that hand one to something other
+ * than composition, such as the share sheet attaching it to an `ACTION_SEND`.
+ *
+ * `null` when the encoder refuses [content], exactly as [QrCode] draws nothing for it.
+ */
+fun qrBitmap(
+ content: String,
+ sizePx: Int,
+ fgArgb: Int = Color.Black.toArgb(),
+ bgArgb: Int = Color.White.toArgb(),
+): Bitmap? {
if (content.isEmpty() || sizePx <= 0) return null
val matrix = runCatching {
QRCodeWriter().encode(
@@ -86,7 +96,7 @@ private fun encodeQr(content: String, sizePx: Int, fgArgb: Int, bgArgb: Int): Im
}
return Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888).apply {
setPixels(pixels, 0, sizePx, 0, 0, sizePx, sizePx)
- }.asImageBitmap()
+ }
}
private val DEFAULT_SIZE = 220.dp
diff --git a/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/components/ShareLinkSheet.kt b/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/components/ShareLinkSheet.kt
new file mode 100644
index 00000000..33eb4a3a
--- /dev/null
+++ b/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/components/ShareLinkSheet.kt
@@ -0,0 +1,213 @@
+package com.github.jvsena42.loopky.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTagsAsResourceId
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.github.jvsena42.loopky.R
+import com.github.jvsena42.loopky.ui.layout.PaneWidth
+import com.github.jvsena42.loopky.ui.layout.contentPane
+import com.github.jvsena42.loopky.ui.theme.LoopkyTheme
+import com.github.jvsena42.loopky.ui.util.shareLinkWithQr
+import kotlinx.coroutines.delay
+
+/**
+ * [ShareLinkSheet] for [target], or nothing when there is none.
+ *
+ * The copy and share behaviour is the same wherever a share button is, so it lives here rather
+ * than in each screen that raises one.
+ */
+@Composable
+fun ShareLinkSheetHost(target: ShareLinkTarget?, onDismiss: () -> Unit) {
+ if (target == null) return
+ val context = LocalContext.current
+ val clipboard = LocalClipboardManager.current
+ // On the button rather than in a toast: Android 13 raises its own clipboard confirmation, and
+ // a toast lands on top of it — two notices, both over the buttons that just moved out of reach.
+ var copied by remember(target.link) { mutableStateOf(false) }
+ LaunchedEffect(copied) {
+ if (copied) {
+ delay(COPIED_LABEL_MS)
+ copied = false
+ }
+ }
+ ShareLinkSheet(
+ title = target.title,
+ link = target.link,
+ copied = copied,
+ onCopy = {
+ clipboard.setText(AnnotatedString(target.link))
+ copied = true
+ },
+ onShare = {
+ context.shareLinkWithQr(
+ text = target.message,
+ link = target.link,
+ chooserTitle = context.getString(target.chooserTitle),
+ )
+ onDismiss()
+ },
+ onDismiss = onDismiss,
+ )
+}
+
+/**
+ * What a share button raises: the link as a QR code, with the ways out of the app underneath.
+ *
+ * The code is the point of the sheet. Sharing used to go straight to the system chooser, which
+ * only ever helps someone who already has the recipient in a messaging app — the person sitting
+ * across the table had no way to take the link off the screen. A code they can point a camera at
+ * needs no channel at all, and the same picture is what [onShare] attaches.
+ */
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
+@Composable
+fun ShareLinkSheet(
+ title: String,
+ link: String,
+ copied: Boolean,
+ onCopy: () -> Unit,
+ onShare: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val colors = LoopkyTheme.colors
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ // A raised surface cannot take the ground's colour, or in dark mode the sheet has no edge.
+ containerColor = colors.surfaceSecondary,
+ ) {
+ Column(
+ modifier = Modifier
+ // A sheet spans the window, so on a tablet the buttons would otherwise sit a
+ // thousand dp apart with the code stranded between them.
+ .contentPane(PaneWidth.Focused)
+ .semantics { testTagsAsResourceId = true }
+ .testTag("share_link_sheet")
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = title,
+ color = colors.foregroundPrimary,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.ExtraBold,
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = stringResource(R.string.share_sheet_hint),
+ color = colors.foregroundSecondary,
+ fontSize = 14.sp,
+ textAlign = TextAlign.Center,
+ )
+ // White in both themes: a QR inverted for dark mode is not one any scanner will read,
+ // and this padding is the quiet zone the encoder is only asked for one module of.
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(20.dp))
+ .background(Color.White)
+ .padding(16.dp),
+ ) {
+ QrCode(
+ content = link,
+ contentDescription = stringResource(R.string.share_sheet_qr_content_description),
+ )
+ }
+ Text(
+ text = link,
+ color = colors.foregroundMuted,
+ fontSize = 12.sp,
+ textAlign = TextAlign.Center,
+ // Middle, not tail: a pubky URI's two ends are what identify it — the account at
+ // the front, the deck id at the back — and eliding the back leaves 60 characters
+ // of key saying nothing. One line, since a wrapped address never elides at all.
+ maxLines = 1,
+ overflow = TextOverflow.MiddleEllipsis,
+ softWrap = false,
+ modifier = Modifier.testTag("share_link_uri"),
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ LoopkyOutlinedButton(
+ label = stringResource(
+ if (copied) R.string.share_sheet_copied else R.string.share_sheet_copy,
+ ),
+ onClick = onCopy,
+ modifier = Modifier
+ .weight(1f)
+ .testTag("share_link_copy"),
+ )
+ LoopkyPrimaryButton(
+ label = stringResource(R.string.share_sheet_send),
+ onClick = onShare,
+ modifier = Modifier
+ .weight(1f)
+ .testTag("share_link_send"),
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun ShareLinkSheetPreview() {
+ LoopkyTheme {
+ Column(
+ modifier = Modifier
+ .background(LoopkyTheme.colors.surfaceSecondary)
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(text = "Spanish Verbs", fontWeight = FontWeight.ExtraBold, fontSize = 20.sp)
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(20.dp))
+ .background(Color.White)
+ .padding(16.dp),
+ ) {
+ QrCode(content = "pubky://abc/pub/loopky/decks/deck1/manifest.json")
+ }
+ }
+ }
+}
+
+private const val COPIED_LABEL_MS = 2000L
diff --git a/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/components/ShareLinkTarget.kt b/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/components/ShareLinkTarget.kt
new file mode 100644
index 00000000..c7df6c14
--- /dev/null
+++ b/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/components/ShareLinkTarget.kt
@@ -0,0 +1,17 @@
+package com.github.jvsena42.loopky.ui.components
+
+import androidx.annotation.StringRes
+
+/**
+ * A link a share button has raised [ShareLinkSheet] for.
+ *
+ * [message] is what leaves the app — the named line a recipient reads — while [link] is the bare
+ * address that goes into the code and onto the clipboard. Sharing the message and copying the
+ * address is deliberate: a pasted link is usually about to be opened, and a pasted sentence is not.
+ */
+data class ShareLinkTarget(
+ val title: String,
+ val link: String,
+ val message: String,
+ @StringRes val chooserTitle: Int,
+)
diff --git a/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/decks/DeckDetailScreen.kt b/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/decks/DeckDetailScreen.kt
index b7d2b64a..07609907 100644
--- a/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/decks/DeckDetailScreen.kt
+++ b/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/decks/DeckDetailScreen.kt
@@ -36,8 +36,10 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -63,6 +65,8 @@ import com.github.jvsena42.loopky.ui.components.CardPreviewRow
import com.github.jvsena42.loopky.ui.components.ExpandableLinkedText
import com.github.jvsena42.loopky.ui.components.LoopkyLoadingScreen
import com.github.jvsena42.loopky.ui.components.LoopkyPrimaryButton
+import com.github.jvsena42.loopky.ui.components.ShareLinkSheetHost
+import com.github.jvsena42.loopky.ui.components.ShareLinkTarget
import com.github.jvsena42.loopky.ui.components.SharePromptDialog
import com.github.jvsena42.loopky.ui.components.SignInPromptDialog
import com.github.jvsena42.loopky.ui.components.errorMessage
@@ -72,7 +76,6 @@ import com.github.jvsena42.loopky.ui.layout.contentPane
import com.github.jvsena42.loopky.ui.layout.windowWidthClass
import com.github.jvsena42.loopky.ui.theme.LoopkyTheme
import com.github.jvsena42.loopky.ui.util.label
-import com.github.jvsena42.loopky.ui.util.shareText
import com.github.jvsena42.loopky.ui.util.toast
import kotlinx.coroutines.flow.collectLatest
import org.koin.compose.viewmodel.koinViewModel
@@ -108,6 +111,8 @@ fun DeckDetailRoute(
val currentOpenProfile by rememberUpdatedState(onOpenProfile)
val currentOpenClone by rememberUpdatedState(onOpenClone)
+ var shareTarget by remember { mutableStateOf(null) }
+
LaunchedEffect(viewModel) {
viewModel.effects.collectLatest { effect ->
when (effect) {
@@ -115,9 +120,11 @@ fun DeckDetailRoute(
is DeckDetailEffect.NavigateEditDeck -> currentEditDeck(effect.deckId)
DeckDetailEffect.NavigateStudy -> currentStudy(deckId)
DeckDetailEffect.NavigateStudyPreview -> currentPreview(deckId)
- is DeckDetailEffect.Share -> context.shareText(
- text = context.getString(R.string.share_deck_body, effect.title, effect.uri),
- chooserTitle = context.getString(R.string.share_deck_chooser_title),
+ is DeckDetailEffect.Share -> shareTarget = ShareLinkTarget(
+ title = effect.title,
+ link = effect.uri,
+ message = context.getString(R.string.share_deck_body, effect.title, effect.uri),
+ chooserTitle = R.string.share_deck_chooser_title,
)
DeckDetailEffect.Deleted -> currentBack()
is DeckDetailEffect.Cloned -> currentOpenClone(effect.deckId)
@@ -170,6 +177,8 @@ fun DeckDetailRoute(
onNeverAsk = viewModel::onShareNeverAsk,
)
}
+
+ ShareLinkSheetHost(target = shareTarget, onDismiss = { shareTarget = null })
}
@Composable
diff --git a/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/profile/FriendProfileScreen.kt b/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/profile/FriendProfileScreen.kt
index 47fa61e1..67475938 100644
--- a/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/profile/FriendProfileScreen.kt
+++ b/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/profile/FriendProfileScreen.kt
@@ -29,7 +29,10 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -60,6 +63,8 @@ import com.github.jvsena42.loopky.ui.components.ProfileHero
import com.github.jvsena42.loopky.ui.components.ProfileStat
import com.github.jvsena42.loopky.ui.components.ProfileStatsCard
import com.github.jvsena42.loopky.ui.components.PubkyAppIconButton
+import com.github.jvsena42.loopky.ui.components.ShareLinkSheetHost
+import com.github.jvsena42.loopky.ui.components.ShareLinkTarget
import com.github.jvsena42.loopky.ui.components.SignInPromptDialog
import com.github.jvsena42.loopky.ui.components.errorMessage
import com.github.jvsena42.loopky.ui.layout.PaneWidth
@@ -68,7 +73,6 @@ import com.github.jvsena42.loopky.ui.layout.deckGridColumns
import com.github.jvsena42.loopky.ui.theme.LoopkyTheme
import com.github.jvsena42.loopky.ui.util.label
import com.github.jvsena42.loopky.ui.util.openUrl
-import com.github.jvsena42.loopky.ui.util.shareText
import kotlinx.coroutines.flow.collectLatest
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -91,6 +95,7 @@ fun FriendProfileRoute(
val currentOpenProfile by rememberUpdatedState(onOpenProfile)
val currentOpenFollows by rememberUpdatedState(onOpenFollows)
val clipboard = LocalClipboardManager.current
+ var shareTarget by remember { mutableStateOf(null) }
LaunchedEffect(viewModel) {
viewModel.effects.collectLatest { effect ->
@@ -99,14 +104,16 @@ fun FriendProfileRoute(
is FriendProfileEffect.OpenDeck -> currentOpenDeck(effect.authorPubky, effect.deckId)
is FriendProfileEffect.OpenProfile -> currentOpenProfile(effect.pubky)
is FriendProfileEffect.OpenUrl -> context.openUrl(effect.url)
- is FriendProfileEffect.ShareProfile -> context.shareText(
+ is FriendProfileEffect.ShareProfile -> shareTarget = ShareLinkTarget(
+ title = effect.identity.label(context),
+ link = effect.uri,
// Named, not a bare key: a recipient sees who it is before tapping.
- text = context.getString(
+ message = context.getString(
R.string.share_profile_body,
effect.identity.label(context),
effect.uri,
),
- chooserTitle = context.getString(R.string.share_profile_chooser_title),
+ chooserTitle = R.string.share_profile_chooser_title,
)
}
}
@@ -139,6 +146,8 @@ fun FriendProfileRoute(
onOpenDeck = viewModel::onOpenDeck,
onOpenAuthor = viewModel::onOpenAuthor,
)
+
+ ShareLinkSheetHost(target = shareTarget, onDismiss = { shareTarget = null })
}
@Composable
diff --git a/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/profile/ProfileScreen.kt b/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/profile/ProfileScreen.kt
index 8867cd82..aaf61c35 100644
--- a/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/profile/ProfileScreen.kt
+++ b/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/profile/ProfileScreen.kt
@@ -75,13 +75,14 @@ import com.github.jvsena42.loopky.ui.components.ProfileHero
import com.github.jvsena42.loopky.ui.components.ProfileStat
import com.github.jvsena42.loopky.ui.components.ProfileStatsCard
import com.github.jvsena42.loopky.ui.components.PubkyAppProfileCta
+import com.github.jvsena42.loopky.ui.components.ShareLinkSheetHost
+import com.github.jvsena42.loopky.ui.components.ShareLinkTarget
import com.github.jvsena42.loopky.ui.layout.PaneWidth
import com.github.jvsena42.loopky.ui.layout.contentPane
import com.github.jvsena42.loopky.ui.layout.windowWidthClass
import com.github.jvsena42.loopky.ui.theme.LoopkyTheme
import com.github.jvsena42.loopky.ui.util.label
import com.github.jvsena42.loopky.ui.util.openUrl
-import com.github.jvsena42.loopky.ui.util.shareText
import kotlinx.coroutines.flow.collectLatest
import org.koin.compose.viewmodel.koinViewModel
@@ -99,19 +100,22 @@ fun ProfileRoute(
val clipboard = LocalClipboardManager.current
val currentSignedOut by rememberUpdatedState(onSignedOut)
var errorMessage by remember { mutableStateOf(null) }
+ var shareTarget by remember { mutableStateOf(null) }
LaunchedEffect(viewModel) {
viewModel.effects.collectLatest { effect ->
when (effect) {
ProfileEffect.NavigateToOnboarding -> currentSignedOut()
- is ProfileEffect.ShareProfile -> context.shareText(
+ is ProfileEffect.ShareProfile -> shareTarget = ShareLinkTarget(
+ title = effect.identity.label(context),
+ link = effect.uri,
// Named, not a bare key: a recipient sees who it is before tapping.
- text = context.getString(
+ message = context.getString(
R.string.share_profile_body,
effect.identity.label(context),
effect.uri,
),
- chooserTitle = context.getString(R.string.share_profile_chooser_title),
+ chooserTitle = R.string.share_profile_chooser_title,
)
is ProfileEffect.CopyToClipboard -> clipboard.setText(AnnotatedString(effect.text))
is ProfileEffect.OpenUrl -> context.openUrl(effect.url)
@@ -143,6 +147,8 @@ fun ProfileRoute(
onSaveClick = viewModel::onSaveClick,
onDismissError = { errorMessage = null },
)
+
+ ShareLinkSheetHost(target = shareTarget, onDismiss = { shareTarget = null })
}
@OptIn(ExperimentalMaterial3Api::class)
diff --git a/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/util/ShareIntent.kt b/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/util/ShareIntent.kt
index 0d4eab5b..3685c3cc 100644
--- a/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/util/ShareIntent.kt
+++ b/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/util/ShareIntent.kt
@@ -6,9 +6,7 @@ import android.content.Intent
/**
* Opens the system share sheet with [text].
*
- * Both share buttons in the app previously did nothing: `DeckDetailEffect.Share` was consumed
- * by an empty lambda and `ProfileEffect.ShareProfile` by a TODO, and no `ACTION_SEND` existed
- * anywhere in the codebase.
+ * The plain-text share, and [shareLinkWithQr]'s fallback when the code cannot be encoded.
*/
fun Context.shareText(text: String, chooserTitle: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
diff --git a/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/util/ShareLinkImage.kt b/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/util/ShareLinkImage.kt
new file mode 100644
index 00000000..7b57f6c4
--- /dev/null
+++ b/androidApp/src/main/kotlin/com/github/jvsena42/loopky/ui/util/ShareLinkImage.kt
@@ -0,0 +1,81 @@
+package com.github.jvsena42.loopky.ui.util
+
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.net.Uri
+import androidx.core.content.FileProvider
+import com.github.jvsena42.loopky.ui.components.qrBitmap
+import java.io.File
+
+/**
+ * Opens the system share sheet with [text] and, alongside it, the QR code for [link] as a PNG.
+ *
+ * Falls back to [shareText] if the code cannot be encoded or the file cannot be written — a share
+ * without the picture still carries the link, and the in-app sheet has already shown the code.
+ *
+ * Note that a receiving app decides for itself what to do with both extras: most messengers attach
+ * the image and keep the caption, some mail clients put the text in the body, and a few take the
+ * image only. The link is inside the code either way, which is why the picture is worth sending.
+ */
+fun Context.shareLinkWithQr(text: String, link: String, chooserTitle: String) {
+ val imageUri = qrShareUri(link)
+ if (imageUri == null) {
+ shareText(text = text, chooserTitle = chooserTitle)
+ return
+ }
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "image/png"
+ putExtra(Intent.EXTRA_STREAM, imageUri)
+ putExtra(Intent.EXTRA_TEXT, text)
+ // The read grant rides on the clip, not on the extra: without this the chooser has no
+ // preview to draw and a receiver that resolves the uri itself is refused.
+ clipData = ClipData.newUri(contentResolver, SHARE_QR_FILE, imageUri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ startActivity(Intent.createChooser(intent, chooserTitle))
+}
+
+/**
+ * The QR for [link] written into the cache directory the manifest's `FileProvider` exposes, as a
+ * `content://` uri another app may read.
+ */
+private fun Context.qrShareUri(link: String): Uri? {
+ val code = qrBitmap(link, SHARE_QR_PX) ?: return null
+ val plated = onWhitePlate(code)
+ val dir = File(cacheDir, SHARE_DIR)
+ return runCatching {
+ dir.mkdirs()
+ // One file, overwritten: the previous share's code is of no use to anyone, and a cache
+ // directory that only ever grows is a cache directory that eventually gets noticed.
+ val file = File(dir, SHARE_QR_FILE)
+ file.outputStream().use { plated.compress(Bitmap.CompressFormat.PNG, 100, it) }
+ FileProvider.getUriForFile(this, "$packageName.fileprovider", file)
+ }.getOrNull()
+}
+
+/**
+ * [code] centred on a white square with a margin.
+ *
+ * The margin is the quiet zone, which the encoder is asked for only one module of: a code pasted
+ * into a chat lands flush against a dark bubble, and a reader that cannot find the border will not
+ * lock on to the code inside it.
+ */
+private fun onWhitePlate(code: Bitmap): Bitmap {
+ val side = code.width + SHARE_QR_MARGIN_PX * 2
+ val plate = Bitmap.createBitmap(side, side, Bitmap.Config.ARGB_8888)
+ Canvas(plate).apply {
+ drawColor(Color.WHITE)
+ drawBitmap(code, SHARE_QR_MARGIN_PX.toFloat(), SHARE_QR_MARGIN_PX.toFloat(), null)
+ }
+ return plate
+}
+
+/** Big enough to stay sharp when a chat client re-encodes it, small enough to send over mobile data. */
+private const val SHARE_QR_PX = 720
+private const val SHARE_QR_MARGIN_PX = 48
+private const val SHARE_DIR = "share"
+private const val SHARE_QR_FILE = "loopky-qr.png"
diff --git a/androidApp/src/main/res/values-b+zh+Hans/strings.xml b/androidApp/src/main/res/values-b+zh+Hans/strings.xml
index cae7cc47..4a1a7361 100644
--- a/androidApp/src/main/res/values-b+zh+Hans/strings.xml
+++ b/androidApp/src/main/res/values-b+zh+Hans/strings.xml
@@ -734,6 +734,11 @@
分享个人主页
Loopky 上的 %1$s\n%2$s
Loopky 上的 %1$s\n%2$s
+ 扫码在 Loopky 中打开
+ 复制链接
+ 分享…
+ 已复制链接
+ 此链接的二维码
这段文字中没有 Loopky 链接。请分享包含 pubky:// 地址的消息。
上移卡片
下移卡片
diff --git a/androidApp/src/main/res/values-b+zh+Hant/strings.xml b/androidApp/src/main/res/values-b+zh+Hant/strings.xml
index 4307a505..dfdb97c2 100644
--- a/androidApp/src/main/res/values-b+zh+Hant/strings.xml
+++ b/androidApp/src/main/res/values-b+zh+Hant/strings.xml
@@ -784,6 +784,11 @@
分享個人檔案
Loopky 上的 %1$s\n%2$s
Loopky 上的 %1$s\n%2$s
+ 掃描以在 Loopky 中開啟
+ 複製連結
+ 分享…
+ 已複製連結
+ 此連結的 QR 碼
這段文字中沒有 Loopky 連結。請分享包含 pubky:// 位址的訊息。
將卡片上移
將卡片下移
diff --git a/androidApp/src/main/res/values-de/strings.xml b/androidApp/src/main/res/values-de/strings.xml
index d6b4501b..c2e032bc 100644
--- a/androidApp/src/main/res/values-de/strings.xml
+++ b/androidApp/src/main/res/values-de/strings.xml
@@ -724,6 +724,11 @@
Profil teilen
%1$s auf Loopky\n%2$s
%1$s auf Loopky\n%2$s
+ Scannen, um in Loopky zu öffnen
+ Link kopieren
+ Teilen…
+ Link kopiert
+ QR-Code für diesen Link
In diesem Text ist kein Loopky-Link. Teile eine Nachricht, die eine pubky://-Adresse enthält.
Karte nach oben
Karte nach unten
diff --git a/androidApp/src/main/res/values-es/strings.xml b/androidApp/src/main/res/values-es/strings.xml
index b7f55e2c..a3ef73ec 100644
--- a/androidApp/src/main/res/values-es/strings.xml
+++ b/androidApp/src/main/res/values-es/strings.xml
@@ -724,6 +724,11 @@
Compartir perfil
%1$s en Loopky\n%2$s
%1$s en Loopky\n%2$s
+ Escanea para abrir en Loopky
+ Copiar enlace
+ Compartir…
+ Enlace copiado
+ Código QR de este enlace
No hay ningún enlace de Loopky en ese texto. Comparte un mensaje que contenga una dirección pubky://.
Subir tarjeta
Bajar tarjeta
diff --git a/androidApp/src/main/res/values-fr/strings.xml b/androidApp/src/main/res/values-fr/strings.xml
index 818246b4..3f471c2e 100644
--- a/androidApp/src/main/res/values-fr/strings.xml
+++ b/androidApp/src/main/res/values-fr/strings.xml
@@ -724,6 +724,11 @@
Partager le profil
%1$s sur Loopky\n%2$s
%1$s sur Loopky\n%2$s
+ Scanne pour ouvrir dans Loopky
+ Copier le lien
+ Partager…
+ Lien copié
+ Code QR de ce lien
Aucun lien Loopky dans ce texte. Partage un message contenant une adresse pubky://.
Monter la carte
Descendre la carte
diff --git a/androidApp/src/main/res/values-it/strings.xml b/androidApp/src/main/res/values-it/strings.xml
index 25160307..a0e6ec03 100644
--- a/androidApp/src/main/res/values-it/strings.xml
+++ b/androidApp/src/main/res/values-it/strings.xml
@@ -724,6 +724,11 @@
Condividi profilo
%1$s su Loopky\n%2$s
%1$s su Loopky\n%2$s
+ Scansiona per aprire in Loopky
+ Copia link
+ Condividi…
+ Link copiato
+ Codice QR di questo link
Nessun link Loopky in quel testo. Condividi un messaggio che contenga un indirizzo pubky://.
Sposta la carta in su
Sposta la carta in giù
diff --git a/androidApp/src/main/res/values-ja/strings.xml b/androidApp/src/main/res/values-ja/strings.xml
index 583cb9da..99aeec95 100644
--- a/androidApp/src/main/res/values-ja/strings.xml
+++ b/androidApp/src/main/res/values-ja/strings.xml
@@ -779,6 +779,11 @@
プロフィールを共有
Loopky の %1$s\n%2$s
Loopky の %1$s\n%2$s
+ スキャンしてLoopkyで開く
+ リンクをコピー
+ 共有…
+ リンクをコピーしました
+ このリンクのQRコード
このテキストには Loopky のリンクがありません。pubky:// アドレスを含むメッセージを共有してください。
カードを上に移動
カードを下に移動
diff --git a/androidApp/src/main/res/values-ko/strings.xml b/androidApp/src/main/res/values-ko/strings.xml
index 979eda7c..53fa5e90 100644
--- a/androidApp/src/main/res/values-ko/strings.xml
+++ b/androidApp/src/main/res/values-ko/strings.xml
@@ -736,6 +736,11 @@
프로필 공유
Loopky의 %1$s\n%2$s
Loopky의 %1$s\n%2$s
+ 스캔하여 Loopky에서 열기
+ 링크 복사
+ 공유…
+ 링크를 복사했습니다
+ 이 링크의 QR 코드
이 텍스트에는 Loopky 링크가 없어요. pubky:// 주소가 담긴 메시지를 공유하세요.
카드 위로 이동
카드 아래로 이동
diff --git a/androidApp/src/main/res/values-pt-rBR/strings.xml b/androidApp/src/main/res/values-pt-rBR/strings.xml
index 9b57f9cc..5f65e2f5 100644
--- a/androidApp/src/main/res/values-pt-rBR/strings.xml
+++ b/androidApp/src/main/res/values-pt-rBR/strings.xml
@@ -724,6 +724,11 @@
Compartilhar perfil
%1$s no Loopky\n%2$s
%1$s no Loopky\n%2$s
+ Escaneie para abrir no Loopky
+ Copiar link
+ Compartilhar…
+ Link copiado
+ Código QR deste link
Nenhum link do Loopky nesse texto. Compartilhe uma mensagem que contenha um endereço pubky://.
Mover carta para cima
Mover carta para baixo
diff --git a/androidApp/src/main/res/values-vi/strings.xml b/androidApp/src/main/res/values-vi/strings.xml
index 79cc5d97..b1a0877e 100644
--- a/androidApp/src/main/res/values-vi/strings.xml
+++ b/androidApp/src/main/res/values-vi/strings.xml
@@ -784,6 +784,11 @@
Chia sẻ hồ sơ
%1$s trên Loopky\n%2$s
%1$s trên Loopky\n%2$s
+ Quét để mở trong Loopky
+ Sao chép liên kết
+ Chia sẻ…
+ Đã sao chép liên kết
+ Mã QR cho liên kết này
Không có liên kết Loopky nào trong văn bản đó. Hãy chia sẻ một tin nhắn chứa địa chỉ pubky://.
Di chuyển thẻ lên
Di chuyển thẻ xuống
diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml
index d8278dea..10143d7b 100644
--- a/androidApp/src/main/res/values/strings.xml
+++ b/androidApp/src/main/res/values/strings.xml
@@ -785,6 +785,11 @@
Share profile
%1$s on Loopky\n%2$s
%1$s on Loopky\n%2$s
+ Scan to open in Loopky
+ Copy link
+ Share…
+ Link copied
+ QR code for this link
No Loopky link in that text. Share a message containing a pubky:// address.
Move card up
Move card down
diff --git a/androidApp/src/main/res/xml/file_paths.xml b/androidApp/src/main/res/xml/file_paths.xml
new file mode 100644
index 00000000..8883932c
--- /dev/null
+++ b/androidApp/src/main/res/xml/file_paths.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings
index 5c2ae112..a5b008d5 100644
--- a/iosApp/iosApp/Localizable.xcstrings
+++ b/iosApp/iosApp/Localizable.xcstrings
@@ -42060,6 +42060,148 @@
}
}
},
+ "share_deck_body": {
+ "extractionState": "manual",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ auf Loopky\n%2$@"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ on Loopky\n%2$@"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ en Loopky\n%2$@"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ sur Loopky\n%2$@"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ su Loopky\n%2$@"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Loopky の %1$@\n%2$@"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Loopky의 %1$@\n%2$@"
+ }
+ },
+ "pt-BR": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ no Loopky\n%2$@"
+ }
+ },
+ "vi": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ trên Loopky\n%2$@"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Loopky 上的 %1$@\n%2$@"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Loopky 上的 %1$@\n%2$@"
+ }
+ }
+ }
+ },
+ "share_profile_body": {
+ "extractionState": "manual",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ auf Loopky\n%2$@"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ on Loopky\n%2$@"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ en Loopky\n%2$@"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ sur Loopky\n%2$@"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ su Loopky\n%2$@"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Loopky の %1$@\n%2$@"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Loopky의 %1$@\n%2$@"
+ }
+ },
+ "pt-BR": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ no Loopky\n%2$@"
+ }
+ },
+ "vi": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ trên Loopky\n%2$@"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Loopky 上的 %1$@\n%2$@"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Loopky 上的 %1$@\n%2$@"
+ }
+ }
+ }
+ },
"share_prompt_body": {
"extractionState": "manual",
"localizations": {
@@ -42770,6 +42912,432 @@
}
}
},
+ "share_sheet_close": {
+ "extractionState": "manual",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Fertig"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Done"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Listo"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Terminé"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Fine"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "完了"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "완료"
+ }
+ },
+ "pt-BR": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Concluído"
+ }
+ },
+ "vi": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Xong"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "完成"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "完成"
+ }
+ }
+ }
+ },
+ "share_sheet_copied": {
+ "extractionState": "manual",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Link kopiert"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Link copied"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Enlace copiado"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Lien copié"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Link copiato"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "リンクをコピーしました"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "링크를 복사했습니다"
+ }
+ },
+ "pt-BR": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Link copiado"
+ }
+ },
+ "vi": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Đã sao chép liên kết"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "已复制链接"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "已複製連結"
+ }
+ }
+ }
+ },
+ "share_sheet_copy": {
+ "extractionState": "manual",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Link kopieren"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Copy link"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Copiar enlace"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Copier le lien"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Copia link"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "リンクをコピー"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "링크 복사"
+ }
+ },
+ "pt-BR": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Copiar link"
+ }
+ },
+ "vi": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Sao chép liên kết"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "复制链接"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "複製連結"
+ }
+ }
+ }
+ },
+ "share_sheet_hint": {
+ "extractionState": "manual",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Scannen, um in Loopky zu öffnen"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Scan to open in Loopky"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Escanea para abrir en Loopky"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Scanne pour ouvrir dans Loopky"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Scansiona per aprire in Loopky"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "スキャンしてLoopkyで開く"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "스캔하여 Loopky에서 열기"
+ }
+ },
+ "pt-BR": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Escaneie para abrir no Loopky"
+ }
+ },
+ "vi": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Quét để mở trong Loopky"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "扫码在 Loopky 中打开"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "掃描以在 Loopky 中開啟"
+ }
+ }
+ }
+ },
+ "share_sheet_qr_content_description": {
+ "extractionState": "manual",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "QR-Code für diesen Link"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "QR code for this link"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Código QR de este enlace"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Code QR de ce lien"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Codice QR di questo link"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "このリンクのQRコード"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "이 링크의 QR 코드"
+ }
+ },
+ "pt-BR": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Código QR deste link"
+ }
+ },
+ "vi": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Mã QR cho liên kết này"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "此链接的二维码"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "此連結的 QR 碼"
+ }
+ }
+ }
+ },
+ "share_sheet_send": {
+ "extractionState": "manual",
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Teilen…"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Share…"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Compartir…"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Partager…"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Condividi…"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "共有…"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "공유…"
+ }
+ },
+ "pt-BR": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Compartilhar…"
+ }
+ },
+ "vi": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Chia sẻ…"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "分享…"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "分享…"
+ }
+ }
+ }
+ },
"sign_in_prompt_clone_deck_body": {
"extractionState": "manual",
"localizations": {
diff --git a/iosApp/iosApp/Views/DeckDetailScreen.swift b/iosApp/iosApp/Views/DeckDetailScreen.swift
index 983278c8..10d2f384 100644
--- a/iosApp/iosApp/Views/DeckDetailScreen.swift
+++ b/iosApp/iosApp/Views/DeckDetailScreen.swift
@@ -21,7 +21,7 @@ struct DeckDetailScreen: View {
@State private var uiState: DeckDetailUiState?
@State private var stateSink: FlowEffectSink?
@State private var effectSink: FlowEffectSink?
- @State private var shareItem: ShareItem?
+ @State private var shareTarget: ShareLinkTarget?
@State private var toast: String?
var body: some View {
@@ -42,9 +42,7 @@ struct DeckDetailScreen: View {
} message: {
Text(deleteMessage)
}
- .sheet(item: $shareItem) { item in
- ShareSheet(items: [item.text])
- }
+ .sheet(item: $shareTarget) { ShareLinkSheet(target: $0) }
// Raised by Edit on a deck you follow, the only route to a copy (#254). A sheet rather than
// an alert because an alert snapshots its message: the "pick a different name" line could
// never appear as the reader typed. See CopyDeckSheet.
@@ -199,7 +197,7 @@ struct DeckDetailScreen: View {
onStudy()
case let share as DeckDetailEffectShare:
// Matches Android: " on Loopky" beats a bare pubky:// manifest URL.
- shareItem = ShareItem(text: "\(share.title) on Loopky\n\(share.uri)")
+ shareTarget = ShareLinkTarget(deckTitle: share.title, uri: share.uri)
case is DeckDetailEffectDeleted:
onDeleted()
case is DeckDetailEffectNavigateStudyPreview:
diff --git a/iosApp/iosApp/Views/DiscoverScreen.swift b/iosApp/iosApp/Views/DiscoverScreen.swift
index f6847c01..357466ac 100644
--- a/iosApp/iosApp/Views/DiscoverScreen.swift
+++ b/iosApp/iosApp/Views/DiscoverScreen.swift
@@ -36,9 +36,9 @@ struct DiscoverScreen: View {
onRetryFollowing: { viewModel?.onRetryFollowing() },
onBrowseEndReached: { viewModel?.onBrowseEndReached() },
onPeopleEndReached: { viewModel?.onPeopleEndReached() },
- onGridColumnsChanged: { viewModel?.onGridColumnsChanged(columns: Int32($0)) },
onRetryBrowse: { viewModel?.onRetryBrowse() },
onRetryBrowsePage: { viewModel?.onRetryBrowsePage() },
+ onGridColumnsChanged: { viewModel?.onGridColumnsChanged(columns: Int32($0)) },
isGuest: isGuest,
onSignIn: onSignIn
)
diff --git a/iosApp/iosApp/Views/FriendProfileScreen.swift b/iosApp/iosApp/Views/FriendProfileScreen.swift
index d90ec037..0c4edd6d 100644
--- a/iosApp/iosApp/Views/FriendProfileScreen.swift
+++ b/iosApp/iosApp/Views/FriendProfileScreen.swift
@@ -18,7 +18,7 @@ struct FriendProfileScreen: View {
@State private var uiState: FriendProfileUiState?
@State private var stateSink: FlowEffectSink?
@State private var effectSink: FlowEffectSink?
- @State private var shareItem: ShareItem?
+ @State private var shareTarget: ShareLinkTarget?
var body: some View {
FriendProfileView(
@@ -33,7 +33,7 @@ struct FriendProfileScreen: View {
onDismissSignInPrompt: { viewModel?.onDismissSignInPrompt() },
onSignIn: onSignIn
)
- .sheet(item: $shareItem) { ShareSheet(items: [$0.text]) }
+ .sheet(item: $shareTarget) { ShareLinkSheet(target: $0) }
.onAppear { attach() }
.onDisappear { detach() }
}
@@ -84,7 +84,7 @@ struct FriendProfileScreen: View {
case let author as FriendProfileEffectOpenProfile:
onOpenAuthor(author.pubky)
case let share as FriendProfileEffectShareProfile:
- shareItem = ShareItem(text: "\(IdentityData(share.identity).label) on Loopky\n\(share.uri)")
+ shareTarget = ShareLinkTarget(profile: share.identity, uri: share.uri)
case let copy as FriendProfileEffectCopyToClipboard:
UIPasteboard.general.string = copy.text
case let url as FriendProfileEffectOpenUrl:
diff --git a/iosApp/iosApp/Views/ProfileScreen.swift b/iosApp/iosApp/Views/ProfileScreen.swift
index 8f70e037..cce05093 100644
--- a/iosApp/iosApp/Views/ProfileScreen.swift
+++ b/iosApp/iosApp/Views/ProfileScreen.swift
@@ -19,7 +19,7 @@ struct ProfileScreen: View {
@State private var uiState: ProfileUiState?
@State private var stateSink: FlowEffectSink?
@State private var effectSink: FlowEffectSink?
- @State private var shareItem: ShareItem?
+ @State private var shareTarget: ShareLinkTarget?
@State private var toast: String?
/// Edit-sheet fields are owned here while typing, like every other text input in the app.
@@ -55,7 +55,7 @@ struct ProfileScreen: View {
onOpenSettings: onOpenSettings,
onBackUpNow: onBackUpNow
)
- .sheet(item: $shareItem) { ShareSheet(items: [$0.text]) }
+ .sheet(item: $shareTarget) { ShareLinkSheet(target: $0) }
.overlay(alignment: .bottom) {
if let toast {
Text(toast)
@@ -112,7 +112,7 @@ struct ProfileScreen: View {
case is ProfileEffectNavigateToOnboarding:
onSignedOut()
case let share as ProfileEffectShareProfile:
- shareItem = ShareItem(text: "\(IdentityData(share.identity).label) on Loopky\n\(share.uri)")
+ shareTarget = ShareLinkTarget(profile: share.identity, uri: share.uri)
case let copy as ProfileEffectCopyToClipboard:
UIPasteboard.general.string = copy.text
flash(NSLocalizedString("profile_copied", comment: ""))
diff --git a/iosApp/iosApp/Views/QrCodeView.swift b/iosApp/iosApp/Views/QrCodeView.swift
index fe3c8cd8..79a1cd36 100644
--- a/iosApp/iosApp/Views/QrCodeView.swift
+++ b/iosApp/iosApp/Views/QrCodeView.swift
@@ -1,5 +1,6 @@
import CoreImage.CIFilterBuiltins
import SwiftUI
+import UIKit
/// A QR code rendered from CoreImage — no dependency, unlike Android's zxing.
///
@@ -16,6 +17,8 @@ struct QrCodeView: View {
/// Quiet zone. The spec asks for four modules of blank margin; without it, readers that find
/// the code flush against other content often fail to lock on.
var padding: CGFloat = 16
+ /// What VoiceOver reads. Defaults to the sign-in code this view was written for.
+ var label: LocalizedStringKey = "onboarding_qr_title"
private static let context = CIContext()
@@ -36,7 +39,32 @@ struct QrCodeView: View {
.padding(padding)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
- .accessibilityLabel(Text("onboarding_qr_title"))
+ .accessibilityLabel(Text(label))
+ }
+
+ /// The same code as a shareable image: the modules blown up onto a white square with a margin.
+ ///
+ /// The margin is the quiet zone. A code pasted into a chat lands flush against a dark bubble,
+ /// and a reader that cannot find the border will not lock on to what is inside it.
+ ///
+ /// Drawn through `UIImage`, never `CGContext.draw`: the renderer's context is y-flipped, and a
+ /// vertically mirrored QR code is one no scanner reads.
+ static func shareImage(_ text: String, side: CGFloat = 720, margin: CGFloat = 48) -> UIImage? {
+ guard let code = render(text) else { return nil }
+ let format = UIGraphicsImageRendererFormat.default()
+ format.scale = 1
+ format.opaque = true
+ let size = CGSize(width: side, height: side)
+ return UIGraphicsImageRenderer(size: size, format: format).image { context in
+ UIColor.white.setFill()
+ context.fill(CGRect(origin: .zero, size: size))
+ // One module per pixel scaled up: interpolating smears the edges, and a smeared code
+ // is one a camera has to be nursed into reading.
+ context.cgContext.interpolationQuality = .none
+ UIImage(cgImage: code).draw(
+ in: CGRect(x: margin, y: margin, width: side - margin * 2, height: side - margin * 2)
+ )
+ }
}
private static func render(_ text: String) -> CGImage? {
diff --git a/iosApp/iosApp/Views/ShareLinkSheet.swift b/iosApp/iosApp/Views/ShareLinkSheet.swift
new file mode 100644
index 00000000..42a62833
--- /dev/null
+++ b/iosApp/iosApp/Views/ShareLinkSheet.swift
@@ -0,0 +1,129 @@
+import Shared
+import SwiftUI
+import UIKit
+
+/// A link a share button raised the sheet for.
+///
+/// `message` is what leaves the app — the named line a recipient reads — while `link` is the bare
+/// address that goes into the code and onto the pasteboard. Sharing the sentence and copying the
+/// address is deliberate: a pasted link is usually about to be opened, and a pasted sentence is not.
+struct ShareLinkTarget: Identifiable {
+ let id = UUID()
+ let title: String
+ let link: String
+ let message: String
+}
+
+extension ShareLinkTarget {
+ /// Someone's profile. Named, not a bare key: a recipient sees who it is before tapping.
+ init(profile: PubkyIdentity, uri: String) {
+ let label = IdentityData(profile).label
+ self.init(
+ title: label,
+ link: uri,
+ message: String(format: NSLocalizedString("share_profile_body", comment: ""), label, uri)
+ )
+ }
+
+ init(deckTitle: String, uri: String) {
+ self.init(
+ title: deckTitle,
+ link: uri,
+ message: String(format: NSLocalizedString("share_deck_body", comment: ""), deckTitle, uri)
+ )
+ }
+}
+
+/// What a share button raises: the link as a QR code, with the ways out of the app underneath.
+///
+/// The code is the point of the sheet. Sharing used to go straight to `UIActivityViewController`,
+/// which only ever helps someone who already has the recipient in a messaging app — the person
+/// sitting across the table had no way to take the link off the screen. A code they can point a
+/// camera at needs no channel at all, and the same picture is what Share attaches.
+struct ShareLinkSheet: View {
+ let target: ShareLinkTarget
+
+ @State private var isSystemSharePresented = false
+ @State private var copied = false
+ /// Measured, because iOS has no "fit the content" detent and `.large` leaves the code stranded
+ /// at the top of a full-height sheet. The initial value is what the first frame uses.
+ @State private var sheetHeight: CGFloat = 540
+
+ var body: some View {
+ VStack(spacing: 16) {
+ RoundedRectangle(cornerRadius: 2)
+ .fill(LoopkyColor.borderSubtle)
+ .frame(width: 36, height: 4)
+ .padding(.top, 12)
+
+ Text(verbatim: target.title)
+ .font(.system(size: 20, weight: .heavy))
+ .foregroundColor(LoopkyColor.foregroundPrimary)
+ .multilineTextAlignment(.center)
+ .lineLimit(2)
+
+ Text("share_sheet_hint")
+ .font(.system(size: 14))
+ .foregroundColor(LoopkyColor.foregroundSecondary)
+ .multilineTextAlignment(.center)
+
+ QrCodeView(text: target.link, size: 220, label: "share_sheet_qr_content_description")
+
+ Text(verbatim: target.link)
+ .font(.system(size: 12))
+ .foregroundColor(LoopkyColor.foregroundMuted)
+ .multilineTextAlignment(.center)
+ // Middle, not tail: a pubky URI's two ends are what identify it — the account at
+ // the front, the deck id at the back — and eliding the back leaves 60 characters
+ // of key saying nothing. One line, since a wrapped address never elides at all.
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .padding(.horizontal, 8)
+
+ HStack(spacing: 12) {
+ Button(copied ? "share_sheet_copied" : "share_sheet_copy", action: copy)
+ .buttonStyle(.loopkyOutline)
+ .accessibilityIdentifier("share_link_copy")
+ Button("share_sheet_send") { isSystemSharePresented = true }
+ .buttonStyle(LoopkyFilledButtonStyle(fill: LoopkyColor.accentPrimary, fontSize: 16))
+ .accessibilityIdentifier("share_link_send")
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.bottom, 32)
+ .background(
+ GeometryReader { proxy in
+ Color.clear.onAppear { sheetHeight = proxy.size.height }
+ }
+ )
+ .presentationDetents([.height(sheetHeight)])
+ .presentationDragIndicator(.hidden)
+ .sheet(isPresented: $isSystemSharePresented) {
+ ShareSheet(items: systemShareItems)
+ }
+ }
+
+ /// The picture goes first: a receiving app that takes one item takes the code, and the link is
+ /// inside it. Without a code to attach, the sentence alone still carries the address.
+ private var systemShareItems: [Any] {
+ guard let image = QrCodeView.shareImage(target.link) else { return [target.message] }
+ return [image, target.message]
+ }
+
+ private func copy() {
+ UIPasteboard.general.string = target.link
+ withAnimation { copied = true }
+ Task {
+ try? await Task.sleep(for: .seconds(2))
+ withAnimation { copied = false }
+ }
+ }
+}
+
+#Preview {
+ ShareLinkSheet(target: ShareLinkTarget(
+ title: "Spanish Verbs",
+ link: "pubky://abcdefghij1234567890/pub/loopky/decks/deck1/manifest.json",
+ message: "Spanish Verbs on Loopky"
+ ))
+}
diff --git a/iosApp/iosApp/Views/ShareSheet.swift b/iosApp/iosApp/Views/ShareSheet.swift
index 94829869..54e8c3b5 100644
--- a/iosApp/iosApp/Views/ShareSheet.swift
+++ b/iosApp/iosApp/Views/ShareSheet.swift
@@ -1,13 +1,8 @@
import SwiftUI
import UIKit
-/// Identifiable payload for `.sheet(item:)`-driven shares.
-struct ShareItem: Identifiable {
- let id = UUID()
- let text: String
-}
-
-/// UIActivityViewController wrapper for effect-driven shares (deck URIs, profile links).
+/// `UIActivityViewController` wrapper — what `ShareLinkSheet`'s Share button presents, with the
+/// link's QR code and the named line beside it.
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
diff --git a/journeys/12-dead-controls.xml b/journeys/12-dead-controls.xml
index 9bf94b71..b937199d 100644
--- a/journeys/12-dead-controls.xml
+++ b/journeys/12-dead-controls.xml
@@ -10,14 +10,16 @@
Clear the search, tap the element with resource-id "decks_search" again to close it
Tap the element with resource-id "decks_sort" and verify a menu with Recent / A–Z / Most cards appears; choose "A–Z" and verify the grid reorders
Open any deck and tap the element with resource-id "deck_share"
- Verify the Android share sheet opens (it previously did nothing at all)
- Dismiss the share sheet, tap the element with resource-id "deck_edit"
+ Verify the in-app sheet (resource-id: share_link_sheet) opens showing the deck title, a QR code and the deck's pubky:// link truncated in the middle
+ Tap the element with resource-id "share_link_copy" and verify the button reads "Link copied"
+ Tap the element with resource-id "share_link_send" and verify the Android chooser opens with the QR code as a thumbnail beside the link text
+ Dismiss the chooser, tap the element with resource-id "deck_edit"
Tap the deck cover (resource-id: deck_editor_cover) and verify the image picker sheet opens — a cover could previously only be set while publishing
Dismiss the sheet and verify the card rows show move up/down buttons (resource-ids: card_move_up, card_move_down) alongside the drag handle (resource-id: card_drag_handle)
Tap "card_move_down" on the first card and verify the two cards swap order
Long-press "card_drag_handle" on the first card and drag it below the second, then release, and verify the two cards swap back
Tap the element with resource-id "tab_profile" and tap the Share button
- Verify the Android share sheet opens
+ Verify the same share sheet opens with the profile's QR code, and that "share_link_send" opens the Android chooser with the code attached
Tap the element with resource-id "profile_signout" and verify a confirmation dialog appears rather than signing out immediately
Cancel the dialog
diff --git a/journeys/RESULTS.md b/journeys/RESULTS.md
index 87b09400..733855c9 100644
--- a/journeys/RESULTS.md
+++ b/journeys/RESULTS.md
@@ -3769,3 +3769,42 @@ iOS. The catalog additions and `knownRegions` were made on Linux, where there is
with `-AppleLanguages "(ja)"` (and `zh-Hant`) on the next Mac run is the check. That check should include
the study grade row, since the iOS buttons may clip "もう一度" the way Android's did. The tablet was not
re-run, because no layout changed apart from the grade buttons' padding.
+
+---
+
+## 12 — Share a deck or a profile as a QR code (#325) — ✅ PASS (2026-09-20)
+
+Android on `emulator-5554` (Pixel_9, guest) and `emulator-5556` (Pixel_Tablet, signed in as
+`pk:ckm34u…sjx7mo`), iOS on the iPhone 17 simulator (guest), all against staging.
+
+The share buttons used to go straight to the system chooser. They now raise an in-app sheet
+carrying the link as a QR code, and its Share button attaches that code as a PNG.
+
+| Step | Result |
+| --- | --- |
+| Deck detail → `deck_share`, Android phone (dark) | ✅ PASS — sheet shows the deck title, "Scan to open in Loopky", the code and the link |
+| The link when it does not fit | ✅ PASS — elided in the middle (`pubky://rpzu1u4hphjr1fkjxgkcm…s/px6fsekq7i5g/manifest.json`), so both ends stay readable |
+| `share_link_copy` | ✅ PASS — the button reads "Link copied" for two seconds |
+| `share_link_send` | ✅ PASS — chooser opens titled "Sharing image", QR thumbnail beside the named line |
+| The attached PNG | ✅ PASS — pulled from `cache/share/loopky-qr.png` (816², 6 KB) and decoded with zxing: exactly the deck URI, right way up |
+| Friend profile → `friend_profile_share`, guest | ✅ PASS — same sheet with the person's pubky |
+| Own profile → Share, Pixel_Tablet landscape (light, expanded) | ✅ PASS — content capped at `PaneWidth.Focused` and centred, not smeared across the panel |
+| Pixel_Tablet portrait (medium) | ✅ PASS |
+| iOS deck detail → Share | ✅ PASS — sheet fits its content, middle-elided link, "Link copied" on tap |
+| iOS `share_link_send` | ✅ PASS — activity sheet offers Assign to Contact and Print, which only appear when an image is among the items |
+
+### Worth knowing
+
+**The chooser needs `ClipData`, not just `EXTRA_STREAM`.** With the stream extra alone the chooser
+drew no thumbnail, and a receiver that resolves the uri itself would have been refused: the read
+grant rides on the clip. The image is written to `cacheDir/share/`, the one path the new
+`FileProvider` exposes.
+
+**The copy confirmation is on the button, not in a toast.** Android 13 raises its own clipboard
+chip at the bottom of the screen; a toast landed on top of it, so two notices covered the buttons
+that had just moved out of reach.
+
+### Fixed in passing
+
+`DiscoverScreen.swift` did not compile on `main` — `onRetryBrowse` was passed after
+`onGridColumnsChanged`, which `DiscoverView` declares before it. Argument order, nothing else.