From a8a33e830c460f42b5a729f3100e0c897d6e854e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 21:33:06 +0000 Subject: [PATCH 1/3] feat: share any note as an uploaded image Add a "Share as Image" action to the note 3-dot menu. It renders the post into a framed card, captures it to a bitmap via a GraphicsLayer, uploads the PNG to one of the user's Blossom media servers, and hands the resulting URL to the Android share sheet. The on-screen preview is the capture source, so what the user sees is exactly what gets shared. A server spinner lets the user pick which Blossom server to upload to, defaulting to their configured default. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Y5ipS9wu6ffCPk5rtB8k3x --- .../amethyst/ui/note/elements/DropDownMenu.kt | 17 + .../ui/note/share/ShareNoteAsImageDialog.kt | 320 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 + 3 files changed, 339 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageDialog.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index f4043c7f95..c86e29467a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.note.externalLinkForNote +import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageDialog import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog @@ -116,6 +117,7 @@ fun NoteDropDownMenu( ) { var reportDialogShowing by remember { mutableStateOf(false) } var addLabelDialogShowing by remember { mutableStateOf(false) } + var shareAsImageShowing by remember { mutableStateOf(false) } val state by observeBookmarksFollowsAndAccount(note, accountViewModel).collectAsStateWithLifecycle( DropDownParams( @@ -239,6 +241,9 @@ fun NoteDropDownMenu( actContext.startActivity(shareIntent) onDismiss() } + M3ActionRow(icon = MaterialSymbols.Image, text = stringRes(R.string.share_as_image)) { + shareAsImageShowing = true + } } } @@ -425,6 +430,18 @@ fun NoteDropDownMenu( }, ) } + + if (shareAsImageShowing) { + ShareNoteAsImageDialog( + note = note, + accountViewModel = accountViewModel, + nav = nav, + onDismiss = { + shareAsImageShowing = false + onDismiss() + }, + ) + } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageDialog.kt new file mode 100644 index 0000000000..967c28c99e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageDialog.kt @@ -0,0 +1,320 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.share + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.draw.drawWithContent +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.content.FileProvider +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge +import com.vitorpamplona.amethyst.ui.components.TextSpinner +import com.vitorpamplona.amethyst.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.ActionTopBar +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream + +/** + * Renders [note] into a framed, shareable bitmap, uploads it to one of the user's Blossom + * media servers, and then hands the resulting URL to the Android share sheet. + * + * The on-screen preview *is* the capture source: a [rememberGraphicsLayer] records exactly + * what the user sees so there are no surprises between preview and the shared image. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareNoteAsImageDialog( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, + onDismiss: () -> Unit, +) { + val account = accountViewModel.account + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val graphicsLayer = rememberGraphicsLayer() + val orchestrator = remember { UploadOrchestrator() } + + var isProcessing by remember { mutableStateOf(false) } + + val fileServers by account.blossomServers.hostNameFlow.collectAsState() + + var selectedServer by remember(fileServers) { + mutableStateOf( + fileServers.firstOrNull { it == account.settings.defaultFileServer } + ?: fileServers.firstOrNull() + ?: DEFAULT_MEDIA_SERVERS[0], + ) + } + + val fileServerOptions = + remember(fileServers) { + fileServers.map { TitleExplainer(it.name, it.baseUrl) }.toImmutableList() + } + + fun shareImage() { + if (isProcessing) return + isProcessing = true + scope.launch { + // Capture must run on the composition thread before any IO work. + val imageBitmap = graphicsLayer.toImageBitmap() + val server = selectedServer + + val finalState = + withContext(Dispatchers.IO) { + val uri = saveBitmapToCache(context, imageBitmap.asAndroidBitmap()) + orchestrator.upload( + uri = uri, + mimeType = PNG_MIME, + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.UNCOMPRESSED, + server = server, + account = account, + context = context, + stripMetadata = false, + ) + } + + isProcessing = false + + when (finalState) { + is UploadingState.Finished -> { + val result = finalState.result + if (result is UploadOrchestrator.OrchestratorResult.ServerResult) { + account.settings.changeDefaultFileServer(server) + startShareUrlIntent(context, result.url) + onDismiss() + } else { + accountViewModel.toastManager.toast( + R.string.failed_to_upload_media_no_details, + R.string.server_did_not_provide_a_url_after_uploading, + ) + } + } + + is UploadingState.Error -> { + accountViewModel.toastManager.toast( + R.string.failed_to_upload_media_no_details, + finalState.errorResource, + *finalState.params, + ) + } + + else -> {} + } + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), + ) { + SetDialogToEdgeToEdge() + Scaffold( + topBar = { + ActionTopBar( + postRes = R.string.quick_action_share, + titleRes = R.string.share_as_image, + isActive = { !isProcessing }, + onCancel = onDismiss, + onPost = ::shareImage, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad), + ) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SettingsRow(R.string.file_server, R.string.file_server_description) { + TextSpinner( + label = "", + placeholder = selectedServer.name, + options = fileServerOptions, + onSelect = { selectedServer = fileServers[it] }, + ) + } + + Box(contentAlignment = Alignment.Center) { + ShareableNoteCard( + note = note, + graphicsLayer = graphicsLayer, + accountViewModel = accountViewModel, + nav = nav, + ) + + if (isProcessing) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + } + } + } + } +} + +/** + * The framed card that is both shown to the user and recorded into [graphicsLayer]. The capture + * modifier is placed first in the chain so the opaque background and border are part of the bitmap. + */ +@Composable +private fun ShareableNoteCard( + note: Note, + graphicsLayer: GraphicsLayer, + accountViewModel: AccountViewModel, + nav: INav, +) { + val shape = RoundedCornerShape(18.dp) + Column( + modifier = + Modifier + .fillMaxWidth() + .drawWithContent { + graphicsLayer.record { this@drawWithContent.drawContent() } + drawLayer(graphicsLayer) + }.clip(shape) + .background(MaterialTheme.colorScheme.surface) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) + .padding(8.dp), + ) { + NoteCompose( + baseNote = note, + isQuotedNote = true, + quotesLeft = 1, + accountViewModel = accountViewModel, + nav = nav, + ) + + Text( + text = stringRes(R.string.share_as_image_watermark), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp, end = 4.dp), + ) + } +} + +private const val PNG_MIME = "image/png" + +private fun saveBitmapToCache( + context: Context, + bitmap: Bitmap, +): Uri { + val file = File(context.cacheDir, "amethyst_share_${System.currentTimeMillis()}.png") + FileOutputStream(file).use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + return FileProvider.getUriForFile(context, "${context.packageName}.provider", file) +} + +private fun startShareUrlIntent( + context: Context, + url: String, +) { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) + } + val shareIntent = Intent.createChooser(sendIntent, stringRes(context, R.string.share_as_image)) + context.startActivity(shareIntent) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 86d82fcc06..c74131fa8e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -473,6 +473,8 @@ Select Share Browser Link Share + Share as Image + Shared via Amethyst Author ID Note ID Copy Text From 1894d5f4b6d80d5af6f0b01850416cb2978020e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 21:48:06 +0000 Subject: [PATCH 2/3] refactor: make share-as-image a screen with an Image preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the share-as-image flow from a dialog into a proper navigation screen (Route.ShareNoteAsImage), reached from the note 3-dot menu. The note is rendered off-screen into a GraphicsLayer, captured to a bitmap, and the captured bitmap is shown as the preview via an Image composable — so the preview is exactly what gets uploaded and shared. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Y5ipS9wu6ffCPk5rtB8k3x --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../amethyst/ui/note/elements/DropDownMenu.kt | 18 +- ...ageDialog.kt => ShareNoteAsImageScreen.kt} | 174 ++++++++++-------- 4 files changed, 104 insertions(+), 94 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/{ShareNoteAsImageDialog.kt => ShareNoteAsImageScreen.kt} (67%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 3a996974ac..9a5e444f3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.isSameRoute import com.vitorpamplona.amethyst.ui.note.PayViaIntentScreen import com.vitorpamplona.amethyst.ui.note.UpdateReactionTypeScreen import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen +import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageScreen import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -415,6 +416,7 @@ fun BuildNavigation( composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ProfileScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { ShareNoteAsImageScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ContactListUsersScreen(it.noteId, accountViewModel, nav) } composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b9340f3568..09ec9177e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -432,6 +432,10 @@ sealed class Route { val id: String, ) : Route() + @Serializable data class ShareNoteAsImage( + val id: String, + ) : Route() + @Serializable data class ContactListUsers( val noteId: String, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index c86e29467a..ded21d5121 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -48,7 +48,6 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.note.externalLinkForNote -import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageDialog import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog @@ -117,7 +116,6 @@ fun NoteDropDownMenu( ) { var reportDialogShowing by remember { mutableStateOf(false) } var addLabelDialogShowing by remember { mutableStateOf(false) } - var shareAsImageShowing by remember { mutableStateOf(false) } val state by observeBookmarksFollowsAndAccount(note, accountViewModel).collectAsStateWithLifecycle( DropDownParams( @@ -242,7 +240,9 @@ fun NoteDropDownMenu( onDismiss() } M3ActionRow(icon = MaterialSymbols.Image, text = stringRes(R.string.share_as_image)) { - shareAsImageShowing = true + val shareId = if (note is AddressableNote) note.address.toValue() else note.idHex + nav.nav(Route.ShareNoteAsImage(shareId)) + onDismiss() } } } @@ -430,18 +430,6 @@ fun NoteDropDownMenu( }, ) } - - if (shareAsImageShowing) { - ShareNoteAsImageDialog( - note = note, - accountViewModel = accountViewModel, - nav = nav, - onDismiss = { - shareAsImageShowing = false - onDismiss() - }, - ) - } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt similarity index 67% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageDialog.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt index 967c28c99e..cd6634ee1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt @@ -24,12 +24,12 @@ import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.net.Uri +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -40,28 +40,28 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.layer.GraphicsLayer -import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.core.content.FileProvider import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -69,7 +69,7 @@ import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS -import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge +import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -86,19 +86,29 @@ import java.io.File import java.io.FileOutputStream /** - * Renders [note] into a framed, shareable bitmap, uploads it to one of the user's Blossom - * media servers, and then hands the resulting URL to the Android share sheet. - * - * The on-screen preview *is* the capture source: a [rememberGraphicsLayer] records exactly - * what the user sees so there are no surprises between preview and the shared image. + * Full-screen flow that turns [id]'s note into a shareable image: it renders the post into a + * framed card, captures it to a bitmap, uploads the PNG to one of the user's Blossom media + * servers, and hands the resulting URL to the Android share sheet. */ +@Composable +fun ShareNoteAsImageScreen( + id: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadNote(id, accountViewModel) { note -> + if (note != null) { + ShareNoteAsImageScreen(note, accountViewModel, nav) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ShareNoteAsImageDialog( +fun ShareNoteAsImageScreen( note: Note, accountViewModel: AccountViewModel, nav: INav, - onDismiss: () -> Unit, ) { val account = accountViewModel.account val context = LocalContext.current @@ -107,6 +117,8 @@ fun ShareNoteAsImageDialog( val graphicsLayer = rememberGraphicsLayer() val orchestrator = remember { UploadOrchestrator() } + // The captured bitmap that becomes the preview and, on share, the uploaded file. + var preview by remember { mutableStateOf(null) } var isProcessing by remember { mutableStateOf(false) } val fileServers by account.blossomServers.hostNameFlow.collectAsState() @@ -125,16 +137,14 @@ fun ShareNoteAsImageDialog( } fun shareImage() { + val image = preview ?: return if (isProcessing) return isProcessing = true scope.launch { - // Capture must run on the composition thread before any IO work. - val imageBitmap = graphicsLayer.toImageBitmap() val server = selectedServer - val finalState = withContext(Dispatchers.IO) { - val uri = saveBitmapToCache(context, imageBitmap.asAndroidBitmap()) + val uri = saveBitmapToCache(context, image.asAndroidBitmap()) orchestrator.upload( uri = uri, mimeType = PNG_MIME, @@ -156,7 +166,7 @@ fun ShareNoteAsImageDialog( if (result is UploadOrchestrator.OrchestratorResult.ServerResult) { account.settings.changeDefaultFileServer(server) startShareUrlIntent(context, result.url) - onDismiss() + nav.popBack() } else { accountViewModel.toastManager.toast( R.string.failed_to_upload_media_no_details, @@ -178,67 +188,71 @@ fun ShareNoteAsImageDialog( } } - Dialog( - onDismissRequest = onDismiss, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false, - ), - ) { - SetDialogToEdgeToEdge() - Scaffold( - topBar = { - ActionTopBar( - postRes = R.string.quick_action_share, - titleRes = R.string.share_as_image, - isActive = { !isProcessing }, - onCancel = onDismiss, - onPost = ::shareImage, + Scaffold( + topBar = { + ActionTopBar( + postRes = R.string.quick_action_share, + titleRes = R.string.share_as_image, + isActive = { preview != null && !isProcessing }, + onCancel = { nav.popBack() }, + onPost = ::shareImage, + ) + }, + ) { pad -> + Column( + Modifier + .padding(pad) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SettingsRow(R.string.file_server, R.string.file_server_description) { + TextSpinner( + label = "", + placeholder = selectedServer.name, + options = fileServerOptions, + onSelect = { selectedServer = fileServers[it] }, ) - }, - ) { pad -> - Surface( - modifier = - Modifier - .padding(pad) - .consumeWindowInsets(pad), + } + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, ) { - Column( - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - SettingsRow(R.string.file_server, R.string.file_server_description) { - TextSpinner( - label = "", - placeholder = selectedServer.name, - options = fileServerOptions, - onSelect = { selectedServer = fileServers[it] }, - ) + val captured = preview + if (captured == null) { + // Render the note off-screen into the graphics layer, then snapshot it. + CaptureSource( + note = note, + graphicsLayer = graphicsLayer, + accountViewModel = accountViewModel, + nav = nav, + ) + LaunchedEffect(note) { + // Wait a couple of frames so the card is measured and drawn before capture. + withFrameNanos {} + withFrameNanos {} + preview = graphicsLayer.toImageBitmap() } + CircularProgressIndicator() + } else { + Image( + bitmap = captured, + contentDescription = stringRes(R.string.share_as_image), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) - Box(contentAlignment = Alignment.Center) { - ShareableNoteCard( - note = note, - graphicsLayer = graphicsLayer, - accountViewModel = accountViewModel, - nav = nav, - ) - - if (isProcessing) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f)), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } + if (isProcessing) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() } } } @@ -248,11 +262,12 @@ fun ShareNoteAsImageDialog( } /** - * The framed card that is both shown to the user and recorded into [graphicsLayer]. The capture - * modifier is placed first in the chain so the opaque background and border are part of the bitmap. + * The framed card that is recorded into [graphicsLayer] but not painted to the screen — the + * visible preview is the captured [ImageBitmap] instead. The capture modifier is first in the + * chain so the opaque background and border are part of the bitmap. */ @Composable -private fun ShareableNoteCard( +private fun CaptureSource( note: Note, graphicsLayer: GraphicsLayer, accountViewModel: AccountViewModel, @@ -265,7 +280,8 @@ private fun ShareableNoteCard( .fillMaxWidth() .drawWithContent { graphicsLayer.record { this@drawWithContent.drawContent() } - drawLayer(graphicsLayer) + // Intentionally do not call drawLayer: this source stays invisible and only + // feeds the snapshot; the user sees the resulting Image. }.clip(shape) .background(MaterialTheme.colorScheme.surface) .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) From fa2dd03be90c29784534f1d99e8d49bf4751f701 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 23:02:33 +0000 Subject: [PATCH 3/3] feat: polish share-as-image screen - Re-layout the screen as a centered "hero" preview on a subtle gradient backdrop, with a soft drop shadow, instead of a top-aligned full-width bitmap above a settings row. - Move the primary action into a bottom bar: a clearer server picker plus a full-width "Share" button with an icon. Top bar is now a plain back bar. - Brand the captured card with a divider + Amethyst logo watermark and roomier padding. - Capture in two passes (quick first preview, then a refined snapshot after a short settle delay) so async media has time to load into the image instead of showing empty placeholders. - Show real upload progress (stage + percentage) via the shared UploadProgressIndicator instead of a bare spinner. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Y5ipS9wu6ffCPk5rtB8k3x --- .../ui/note/share/ShareNoteAsImageScreen.kt | 256 +++++++++++++----- amethyst/src/main/res/values/strings.xml | 1 + 2 files changed, 189 insertions(+), 68 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt index cd6634ee1f..2e697ace58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt @@ -30,16 +30,25 @@ import androidx.compose.foundation.border 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -54,32 +63,40 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.ActionTopBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size18Modifier +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File @@ -119,6 +136,8 @@ fun ShareNoteAsImageScreen( // The captured bitmap that becomes the preview and, on share, the uploaded file. var preview by remember { mutableStateOf(null) } + // Once true the off-screen capture source is disposed; [preview] holds the final bitmap. + var captureComplete by remember { mutableStateOf(false) } var isProcessing by remember { mutableStateOf(false) } val fileServers by account.blossomServers.hostNameFlow.collectAsState() @@ -189,71 +208,96 @@ fun ShareNoteAsImageScreen( } Scaffold( - topBar = { - ActionTopBar( - postRes = R.string.quick_action_share, - titleRes = R.string.share_as_image, - isActive = { preview != null && !isProcessing }, - onCancel = { nav.popBack() }, - onPost = ::shareImage, + topBar = { TopBarWithBackButton(stringRes(R.string.share_as_image), nav) }, + bottomBar = { + ShareBottomBar( + serverName = selectedServer.name, + serverOptions = fileServerOptions, + onSelectServer = { selectedServer = fileServers[it] }, + shareEnabled = preview != null && !isProcessing, + onShare = ::shareImage, ) }, ) { pad -> - Column( - Modifier - .padding(pad) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + Box( + modifier = + Modifier + .padding(pad) + .fillMaxSize() + .background( + Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.surface, + MaterialTheme.colorScheme.surfaceVariant, + ), + ), + ), + contentAlignment = Alignment.Center, ) { - SettingsRow(R.string.file_server, R.string.file_server_description) { - TextSpinner( - label = "", - placeholder = selectedServer.name, - options = fileServerOptions, - onSelect = { selectedServer = fileServers[it] }, + // Keep the note rendered (but unpainted) until the snapshot is final, so async + // images have a chance to load into the layer before the second capture. + if (!captureComplete) { + CaptureSource( + note = note, + graphicsLayer = graphicsLayer, + accountViewModel = accountViewModel, + nav = nav, ) + LaunchedEffect(note) { + // Wait a couple of frames so the card is measured and drawn, snapshot a + // first preview, then refine once network media has had time to settle. + withFrameNanos {} + withFrameNanos {} + preview = graphicsLayer.toImageBitmap() + delay(IMAGE_SETTLE_MS) + preview = graphicsLayer.toImageBitmap() + captureComplete = true + } } - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, + val captured = preview + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, ) { - val captured = preview - if (captured == null) { - // Render the note off-screen into the graphics layer, then snapshot it. - CaptureSource( - note = note, - graphicsLayer = graphicsLayer, - accountViewModel = accountViewModel, - nav = nav, - ) - LaunchedEffect(note) { - // Wait a couple of frames so the card is measured and drawn before capture. - withFrameNanos {} - withFrameNanos {} - preview = graphicsLayer.toImageBitmap() - } - CircularProgressIndicator() - } else { + if (captured != null) { Image( bitmap = captured, contentDescription = stringRes(R.string.share_as_image), contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .widthIn(max = 460.dp) + .fillMaxWidth() + .shadow(18.dp, PreviewShape, clip = false) + .clip(PreviewShape), ) + } else { + GeneratingPreview() + } + } - if (isProcessing) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f)), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } + if (isProcessing) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center, + ) { + Surface( + shape = PreviewShape, + tonalElevation = 6.dp, + ) { + UploadProgressIndicator( + orchestrator, + modifier = Modifier.widthIn(min = 160.dp).padding(horizontal = 24.dp), + ) } } } @@ -261,6 +305,64 @@ fun ShareNoteAsImageScreen( } } +@Composable +private fun GeneratingPreview() { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + CircularProgressIndicator() + Text( + text = stringRes(R.string.share_as_image_generating), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShareBottomBar( + serverName: String, + serverOptions: ImmutableList, + onSelectServer: (Int) -> Unit, + shareEnabled: Boolean, + onShare: () -> Unit, +) { + Surface(tonalElevation = 3.dp) { + Column( + modifier = + Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + TextSpinner( + label = stringRes(R.string.file_server), + placeholder = serverName, + options = serverOptions, + onSelect = onSelectServer, + modifier = Modifier.fillMaxWidth(), + ) + + Button( + onClick = onShare, + enabled = shareEnabled, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + symbol = MaterialSymbols.Share, + contentDescription = null, + modifier = Size18Modifier, + ) + Spacer(Modifier.width(8.dp)) + Text(stringRes(R.string.quick_action_share)) + } + } + } +} + /** * The framed card that is recorded into [graphicsLayer] but not painted to the screen — the * visible preview is the captured [ImageBitmap] instead. The capture modifier is first in the @@ -273,19 +375,19 @@ private fun CaptureSource( accountViewModel: AccountViewModel, nav: INav, ) { - val shape = RoundedCornerShape(18.dp) Column( modifier = Modifier + .widthIn(max = 600.dp) .fillMaxWidth() .drawWithContent { graphicsLayer.record { this@drawWithContent.drawContent() } - // Intentionally do not call drawLayer: this source stays invisible and only - // feeds the snapshot; the user sees the resulting Image. - }.clip(shape) + // Intentionally no drawLayer: this source stays invisible and only feeds the + // snapshot; the user sees the resulting Image. + }.clip(PreviewShape) .background(MaterialTheme.colorScheme.surface) - .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) - .padding(8.dp), + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, PreviewShape) + .padding(14.dp), ) { NoteCompose( baseNote = note, @@ -295,21 +397,39 @@ private fun CaptureSource( nav = nav, ) - Text( - text = stringRes(R.string.share_as_image_watermark), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, - modifier = - Modifier - .fillMaxWidth() - .padding(top = 8.dp, end = 4.dp), + HorizontalDivider( + modifier = Modifier.padding(top = 12.dp, bottom = 8.dp), + color = MaterialTheme.colorScheme.outlineVariant, ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(R.drawable.amethyst), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = stringRes(R.string.share_as_image_watermark), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } private const val PNG_MIME = "image/png" +// Time given for async media to load into the off-screen card before the final snapshot. +private const val IMAGE_SETTLE_MS = 900L + +private val PreviewShape = RoundedCornerShape(18.dp) + private fun saveBitmapToCache( context: Context, bitmap: Bitmap, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c74131fa8e..ecd67f2dfb 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -474,6 +474,7 @@ Share Browser Link Share Share as Image + Generating preview… Shared via Amethyst Author ID Note ID