refactor(commons): extract PS1 memory-card save card to commons

Fifth batch of the note-ui-commons extraction, and the first to use the
platform-specific @Composable slot pattern the plan calls for (§3e/§4).

The PS1 save card (kind 38192) is pure value-in except for its animated
16×16 icon, which is decoded from raw memory-card pixels via Android Bitmap
APIs (createBitmap/setPixels/asImageBitmap) that can't move to commonMain.

- commons/ui/note/Ps1SaveCard.kt: the card takes decoded primitives (title,
  filename, region, block number, blank flag, hex preview) plus an
  `icon: (@Composable () -> Unit)?` slot; a null icon falls back to the
  floppy-disk emoji prefix. Labels resolve via commons Res.
- amethyst keeps the thin RenderPs1Save(Note) entry, which decodes the event
  and supplies the native Ps1SaveIconImage (the bitmap/frame-clock animation)
  as the icon slot.
- Migrated ps1_save_title/ps1_save_block/ps1_save_empty_slot with all locale
  translations into commons/composeResources; deleted the amethyst copies.

Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
This commit is contained in:
Claude
2026-07-17 22:49:34 +00:00
parent a9ff6f5fbb
commit 3b96dc2f82
24 changed files with 169 additions and 114 deletions
@@ -21,15 +21,7 @@
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -37,43 +29,26 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameMillis
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.note.Ps1SaveCard
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveIcon
/** How many hex characters of the block to preview (the full block is ~16K chars). */
private const val HEX_PREVIEW_CHARS = 192
/** Floppy-disk emoji fallback for blocks without a decodable save icon. */
private const val FLOPPY_PREFIX = "💾 "
/** Milliseconds per animation frame, roughly the BIOS memory-card screen cadence. */
private const val ICON_FRAME_MILLIS = 250L
/**
* Minimal, fixed-size card for a PS1 memory-card save block (kind 38192).
*
* The content is a full 8 KiB memory-card block, hex-encoded (~16K characters),
* so the card never renders it whole: it shows the save's own 16×16 icon from
* the title frame (animated exactly like the PS1 BIOS memory-card screen; a
* floppy emoji when the block has none), the save title, a metadata line
* (game product code, region, block number), and a short monospace hex preview
* clamped to a few lines — no expansion, no network calls. The card is
* identical in the feed and the opened view, so it takes no makeItShort flag.
* Entry for a PS1 memory-card save block: decodes the [Note] and hands the parsed
* values to the shared commons [Ps1SaveCard]. The save's 16×16 icon needs Android
* bitmap decoding, so it is supplied here as the card's icon slot.
*/
@Composable
fun RenderPs1Save(baseNote: Note) {
@@ -97,58 +72,15 @@ fun RenderPs1Save(baseNote: Note) {
)
}
Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (save.icon != null) {
Ps1SaveIconImage(save.icon)
Spacer(Modifier.width(8.dp))
}
Text(
text = (if (save.icon == null) FLOPPY_PREFIX else "") + (save.title ?: stringRes(R.string.ps1_save_title)),
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
val details =
listOfNotNull(
save.filename,
save.region,
save.blockNumber?.let { stringRes(R.string.ps1_save_block, it) },
).joinToString(" · ")
if (details.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
Text(
text = details,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (save.isBlank) {
Spacer(Modifier.height(6.dp))
Text(
text = stringRes(R.string.ps1_save_empty_slot),
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.placeholderText,
)
} else if (save.hexPreview != null) {
Spacer(Modifier.height(6.dp))
Text(
text = save.hexPreview,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
Ps1SaveCard(
title = save.title,
filename = save.filename,
region = save.region,
blockNumber = save.blockNumber,
isBlank = save.isBlank,
hexPreview = save.hexPreview,
icon = save.icon?.let { ic -> { Ps1SaveIconImage(ic) } },
)
}
/**
@@ -3800,9 +3800,6 @@
<string name="fundraiser_ends">Končí %1$s</string>
<string name="fundraiser_onchain_donation">On-chain dar</string>
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
<string name="ps1_save_title">Uložení na paměťovou kartu PS1</string>
<string name="ps1_save_block">blok %1$d</string>
<string name="ps1_save_empty_slot">Prázdný slot</string>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="road_event_police">Policie</string>
<string name="road_event_speed_camera">Rychlostní radar</string>
@@ -3692,9 +3692,6 @@
<string name="fundraiser_ends">Endet %1$s</string>
<string name="fundraiser_onchain_donation">On-Chain-Spende</string>
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
<string name="ps1_save_title">PS1-Memory-Card-Spielstand</string>
<string name="ps1_save_block">Block %1$d</string>
<string name="ps1_save_empty_slot">Leerer Slot</string>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="road_event_police">Polizei</string>
<string name="road_event_speed_camera">Blitzer</string>
@@ -3606,9 +3606,6 @@
<string name="fundraiser_ends">समाप्ति %1$s</string>
<string name="fundraiser_onchain_donation">खण्डश्रृंखलाबद्ध दान</string>
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
<string name="ps1_save_title">पीएस१ स्मृति पत्रक अभिलेखन</string>
<string name="ps1_save_block">खण्ड %1$d</string>
<string name="ps1_save_empty_slot">रिक्त छेद</string>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="road_event_police">रक्षापालिका</string>
<string name="road_event_speed_camera">गति चित्रग्राहक</string>
@@ -3578,9 +3578,6 @@
<string name="fundraiser_ends">Véget ér ekkor: %1$s</string>
<string name="fundraiser_onchain_donation">Láncon belüli adomány</string>
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
<string name="ps1_save_title">PS1 memóriakártya-mentés</string>
<string name="ps1_save_block">%1$d blokk</string>
<string name="ps1_save_empty_slot">Üres hely</string>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="road_event_police">Rendőrség</string>
<string name="road_event_speed_camera">Sebességmérő kamera</string>
@@ -3133,9 +3133,6 @@
<string name="fundraiser_ends">Eindigt %1$s</string>
<string name="fundraiser_onchain_donation">On-chain donatie</string>
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
<string name="ps1_save_title">PS1 memory card-save</string>
<string name="ps1_save_block">blok %1$d</string>
<string name="ps1_save_empty_slot">Leeg slot</string>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="road_event_police">Politie</string>
<string name="road_event_speed_camera">Flitser</string>
@@ -3795,9 +3795,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="fundraiser_ends">Kończy się %1$s</string>
<string name="fundraiser_onchain_donation">Darowizna on-chain</string>
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
<string name="ps1_save_title">Zapis karty pamięci PS1</string>
<string name="ps1_save_block">blok %1$d</string>
<string name="ps1_save_empty_slot">Pusty slot</string>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="road_event_police">Policja</string>
<string name="road_event_speed_camera">Fotoradar</string>
@@ -3690,9 +3690,6 @@
<string name="fundraiser_ends">Termina %1$s</string>
<string name="fundraiser_onchain_donation">Doação on-chain</string>
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
<string name="ps1_save_title">Salvamento em cartão de memória PS1</string>
<string name="ps1_save_block">bloco %1$d</string>
<string name="ps1_save_empty_slot">Espaço vazio</string>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="road_event_police">Polícia</string>
<string name="road_event_speed_camera">Radar de velocidade</string>
@@ -3396,9 +3396,6 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
<string name="fundraiser_ends">Izteče se: %1$s</string>
<string name="fundraiser_onchain_donation">On-chain donacija</string>
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
<string name="ps1_save_title">Shrani na pomnilniško kartico PS1</string>
<string name="ps1_save_block">%1$d blok</string>
<string name="ps1_save_empty_slot">Prazen prostor</string>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="road_event_police">Policija</string>
<string name="road_event_speed_camera">Hitrostna kamera</string>
@@ -3690,9 +3690,6 @@
<string name="fundraiser_ends">Slutar %1$s</string>
<string name="fundraiser_onchain_donation">On-chain-donation</string>
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
<string name="ps1_save_title">PS1-minneskortsparning</string>
<string name="ps1_save_block">block %1$d</string>
<string name="ps1_save_empty_slot">Tom plats</string>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="road_event_police">Polis</string>
<string name="road_event_speed_camera">Fartkamera</string>
@@ -3556,9 +3556,6 @@
<string name="fundraiser_ends">结束 %1$s</string>
<string name="fundraiser_onchain_donation">链上捐助</string>
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
<string name="ps1_save_title">PS1 内存卡保存</string>
<string name="ps1_save_block">块 %1$d</string>
<string name="ps1_save_empty_slot">空槽位</string>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="road_event_police">警察</string>
<string name="road_event_speed_camera">测速照相</string>
-3
View File
@@ -4058,9 +4058,6 @@
<string name="fundraiser_ends">Ends %1$s</string>
<string name="fundraiser_onchain_donation">On-chain donation</string>
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
<string name="ps1_save_title">PS1 memory card save</string>
<string name="ps1_save_block">block %1$d</string>
<string name="ps1_save_empty_slot">Empty slot</string>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="road_event_police">Police</string>
<string name="road_event_speed_camera">Speed camera</string>
@@ -24,4 +24,7 @@
<item quantity="many">%1$s +%2$d dalších</item>
<item quantity="other">%1$s +%2$d dalších</item>
</plurals>
<string name="ps1_save_title">Uložení na paměťovou kartu PS1</string>
<string name="ps1_save_block">blok %1$d</string>
<string name="ps1_save_empty_slot">Prázdný slot</string>
</resources>
@@ -18,4 +18,7 @@
<item quantity="one">%1$s +%2$d weitere</item>
<item quantity="other">%1$s +%2$d weitere</item>
</plurals>
<string name="ps1_save_title">PS1-Memory-Card-Spielstand</string>
<string name="ps1_save_block">Block %1$d</string>
<string name="ps1_save_empty_slot">Leerer Slot</string>
</resources>
@@ -18,4 +18,7 @@
<item quantity="one">%1$s सं॰ %2$d अधिक</item>
<item quantity="other">%1$s सं॰ %2$d अधिक</item>
</plurals>
<string name="ps1_save_title">पीएस१ स्मृति पत्रक अभिलेखन</string>
<string name="ps1_save_block">खण्ड %1$d</string>
<string name="ps1_save_empty_slot">रिक्त छेद</string>
</resources>
@@ -18,4 +18,7 @@
<item quantity="one">%1$s +%2$d további</item>
<item quantity="other">%1$s +%2$d további</item>
</plurals>
<string name="ps1_save_title">PS1 memóriakártya-mentés</string>
<string name="ps1_save_block">%1$d blokk</string>
<string name="ps1_save_empty_slot">Üres hely</string>
</resources>
@@ -18,4 +18,7 @@
<item quantity="one">%1$s +%2$d meer</item>
<item quantity="other">%1$s +%2$d meer</item>
</plurals>
<string name="ps1_save_title">PS1 memory card-save</string>
<string name="ps1_save_block">blok %1$d</string>
<string name="ps1_save_empty_slot">Leeg slot</string>
</resources>
@@ -23,4 +23,7 @@
<item quantity="many">%1$s +%2$d więcej</item>
<item quantity="other">%1$s +%2$d więcej</item>
</plurals>
<string name="ps1_save_title">Zapis karty pamięci PS1</string>
<string name="ps1_save_block">blok %1$d</string>
<string name="ps1_save_empty_slot">Pusty slot</string>
</resources>
@@ -18,4 +18,7 @@
<item quantity="one">%1$s +%2$d a mais</item>
<item quantity="other">%1$s +%2$d a mais</item>
</plurals>
<string name="ps1_save_title">Salvamento em cartão de memória PS1</string>
<string name="ps1_save_block">bloco %1$d</string>
<string name="ps1_save_empty_slot">Espaço vazio</string>
</resources>
@@ -24,4 +24,7 @@
<item quantity="few">%1$s in še %2$d druge</item>
<item quantity="other">%1$s in še %2$d drugih</item>
</plurals>
<string name="ps1_save_title">Shrani na pomnilniško kartico PS1</string>
<string name="ps1_save_block">%1$d blok</string>
<string name="ps1_save_empty_slot">Prazen prostor</string>
</resources>
@@ -18,4 +18,6 @@
<item quantity="one">%1$s +%2$d till</item>
<item quantity="other">%1$s +%2$d till</item>
</plurals>
<string name="ps1_save_title">PS1-minneskortsparning</string>
<string name="ps1_save_empty_slot">Tom plats</string>
</resources>
@@ -15,4 +15,7 @@
<plurals name="birdex_species_preview_more">
<item quantity="other">%1$s + 另%2$d</item>
</plurals>
<string name="ps1_save_title">PS1 内存卡保存</string>
<string name="ps1_save_block">块 %1$d</string>
<string name="ps1_save_empty_slot">空槽位</string>
</resources>
@@ -119,4 +119,7 @@
<item quantity="one">%1$s +%2$d more</item>
<item quantity="other">%1$s +%2$d more</item>
</plurals>
<string name="ps1_save_title">PS1 memory card save</string>
<string name="ps1_save_block">block %1$d</string>
<string name="ps1_save_empty_slot">Empty slot</string>
</resources>
@@ -0,0 +1,124 @@
/*
* 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.commons.ui.note
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.ps1_save_block
import com.vitorpamplona.amethyst.commons.resources.ps1_save_empty_slot
import com.vitorpamplona.amethyst.commons.resources.ps1_save_title
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import org.jetbrains.compose.resources.stringResource
/** Floppy-disk emoji fallback for blocks without a decodable save icon. */
private const val FLOPPY_PREFIX = "💾 "
/**
* Minimal, fixed-size card for a PS1 memory-card save block (kind 38192).
*
* The content is a full 8 KiB memory-card block, hex-encoded (~16K characters),
* so the card never renders it whole: it shows the save title, a metadata line
* (game product code, region, block number), and a short monospace hex preview
* clamped to a few lines — no expansion, no network calls. The card is identical
* in the feed and the opened view, so it takes no makeItShort flag.
*
* The save's animated 16×16 icon is decoded from raw memory-card pixels, which is
* platform-specific bitmap work, so it arrives as the [icon] slot supplied by the
* host. A null [icon] falls back to a floppy-disk emoji prefix on the title.
*/
@Composable
fun Ps1SaveCard(
title: String?,
filename: String?,
region: String?,
blockNumber: Int?,
isBlank: Boolean,
hexPreview: String?,
icon: (@Composable () -> Unit)?,
) {
Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (icon != null) {
icon()
Spacer(Modifier.width(8.dp))
}
Text(
text = (if (icon == null) FLOPPY_PREFIX else "") + (title ?: stringResource(Res.string.ps1_save_title)),
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
val details =
listOfNotNull(
filename,
region,
blockNumber?.let { stringResource(Res.string.ps1_save_block, it) },
).joinToString(" · ")
if (details.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
Text(
text = details,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (isBlank) {
Spacer(Modifier.height(6.dp))
Text(
text = stringResource(Res.string.ps1_save_empty_slot),
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.placeholderText,
)
} else if (hexPreview != null) {
Spacer(Modifier.height(6.dp))
Text(
text = hexPreview,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}