refactor: wallet, filename, language and local-time seams for more screens

- WalletAppLauncher opens any payment URI; payInvoice and payBolt12Offer are
  extensions, so Bolt12OffersDialog stops launching intents itself.
- MediaUploader.displayName replaces MarmotFileUploader's content-resolver
  query; it no longer takes a Context.
- LanguageOption/availableLanguages/languageDisplayName expects in commons
  replace java.util.Locale in the language settings.
- RequestToVanishScreen uses LocalClock and rememberIs24HourClock.
- Toasts in MarmotInviteDeviceBanner and Bolt12OffersDialog go through
  the shared notices; PodcastTopSupporters uses the KMP BigDecimal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-09-30 03:07:59 +00:00
parent dced71bc0c
commit 3a1e7ec5ba
17 changed files with 182 additions and 86 deletions
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.uploads
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
@@ -605,4 +606,10 @@ class AndroidMediaUploader(
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.lowercase(Locale.getDefault()))
FileHeader.prepare(url, mimeType, null) { httpClients.okHttpClientForImage(it) }.getOrNull()
}
override fun displayName(uri: MediaUri): String? =
appContext.contentResolver.query(uri, null, null, null, null)?.use {
val idx = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && it.moveToFirst()) it.getString(idx) else null
}
}
@@ -45,7 +45,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -232,7 +231,6 @@ fun MarmotGroupMessageComposer(
) {
val scope = rememberCoroutineScope()
val canPost by remember { derivedStateOf { newMessageModel.canPost() } }
val context = LocalContext.current
val shortNotice = rememberShortNotice()
var isUploading by remember { mutableStateOf(false) }
@@ -350,7 +348,6 @@ private fun MarmotGroupFileUploadDialog(
onUpload: suspend () -> Unit,
onCancel: () -> Unit,
) {
val context = LocalContext.current
val shortNotice = rememberShortNotice()
val longNotice = rememberLongNotice()
val scope = rememberCoroutineScope()
@@ -382,7 +379,6 @@ private fun MarmotGroupFileUploadDialog(
longNotice.show("$title: $message")
}
},
context = context,
uploader = accountViewModel.host.mediaUploader,
onceUploaded = { uploads ->
MarmotFileSender(nostrGroupId, accountViewModel).send(uploads)
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -45,7 +44,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -57,6 +55,8 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_failure
import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_publish
import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_rejected
import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_success
import com.vitorpamplona.amethyst.commons.ui.components.rememberLongNotice
import com.vitorpamplona.amethyst.commons.ui.components.rememberShortNotice
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -96,7 +96,8 @@ fun MarmotInviteDeviceBanner(
// has already read and waved away.
var dismissed by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val shortNotice = rememberShortNotice()
val longNotice = rememberLongNotice()
// A read-only login cannot publish at all, so the only action this banner
// offers is impossible for it. Better to say nothing than to offer a button
// that silently does nothing.
@@ -178,9 +179,9 @@ fun MarmotInviteDeviceBanner(
val accepted = accountViewModel.republishKeyPackage()
launch(Dispatchers.Main) {
if (accepted) {
Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show()
shortNotice.show(successMessage)
} else {
Toast.makeText(context, rejectedMessage, Toast.LENGTH_LONG).show()
longNotice.show(rejectedMessage)
owner = LatestKeyPackageOwner.OTHER_DEVICE
}
}
@@ -194,7 +195,7 @@ fun MarmotInviteDeviceBanner(
val failureMessage =
loadStringRes(Res.string.marmot_invite_device_failure, e.message ?: "")
launch(Dispatchers.Main) {
Toast.makeText(context, failureMessage, Toast.LENGTH_LONG).show()
longNotice.show(failureMessage)
// The warning was right after all, so put it back.
owner = LatestKeyPackageOwner.OTHER_DEVICE
}
@@ -20,8 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send
import android.content.Context
import android.net.Uri
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details
@@ -79,7 +77,6 @@ class MarmotFileUploader(
viewState: ChatFileUploadState,
exporterSecret: ByteArray,
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
/**
* Produce `encrypted-media-v2` references instead of MIP-04 ones.
@@ -98,7 +95,7 @@ class MarmotFileUploader(
val item = multiOrchestrator.get(i)
val media = item.media
val mimeType = media.mimeType ?: "application/octet-stream"
val filename = resolveFilename(context, media.uri, mimeType)
val filename = uploader.displayName(media.uri) ?: fallbackFilename(mimeType)
// v2 puts `m` inside both the key derivation and the AEAD
// associated data, so it has to be the canonical form and not
@@ -197,19 +194,7 @@ class MarmotFileUploader(
viewState.mediaUploadTracker.finishUpload()
}
private fun resolveFilename(
context: Context,
uri: Uri,
mimeType: String,
): String {
val cursor = context.contentResolver.query(uri, null, null, null, null)
val name =
cursor?.use {
val idx = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && it.moveToFirst()) it.getString(idx) else null
}
if (name != null) return name
private fun fallbackFilename(mimeType: String): String {
val ext =
when {
mimeType.startsWith("image/jpeg") -> "jpg"
@@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.grayText
import com.vitorpamplona.amethyst.commons.util.showAmountInteger
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent
import java.math.BigDecimal
import com.vitorpamplona.quartz.utils.BigDecimal
private val Gold = Color(0xFFFFC300)
private val Silver = Color(0xFFB0B7C0)
@@ -151,7 +151,7 @@ private fun SupporterRow(
ZapIcon(Size16Modifier, BitcoinOrange)
Text(
text = showAmountInteger(BigDecimal.valueOf(entry.totalSats)),
text = showAmountInteger(BigDecimal(entry.totalSats)),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
)
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -45,7 +44,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
@@ -62,13 +60,15 @@ import com.vitorpamplona.amethyst.commons.resources.copy_to_clipboard
import com.vitorpamplona.amethyst.commons.resources.no_wallet_found
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.commons.ui.components.rememberShortNotice
import com.vitorpamplona.amethyst.commons.ui.components.util.setText
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.wallet.payBolt12Offer
import com.vitorpamplona.amethyst.commons.ui.wallet.rememberWalletAppLauncher
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.note.payViaBolt12Intent
import kotlinx.coroutines.launch
@Composable
@@ -78,7 +78,8 @@ fun Bolt12OffersDialog(
onDismiss: () -> Unit,
) {
val noWalletFoundStr = stringRes(Res.string.no_wallet_found)
val context = LocalContext.current
val walletApp = rememberWalletAppLauncher()
val shortNotice = rememberShortNotice()
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
@@ -101,22 +102,16 @@ fun Bolt12OffersDialog(
onCopy = {
scope.launch {
clipboardManager.setText(offer)
Toast
.makeText(
context,
loadStringRes(Res.string.copied_to_clipboard),
Toast.LENGTH_SHORT,
).show()
shortNotice.show(loadStringRes(Res.string.copied_to_clipboard))
}
},
onPayViaIntent = {
payViaBolt12Intent(
walletApp.payBolt12Offer(
offer = offer,
context = context,
noWalletFoundStr,
noWalletFound = noWalletFoundStr,
onPaid = { onDismiss() },
onError = { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
shortNotice.show(msg)
},
)
},
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish
import android.text.format.DateFormat
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -59,7 +58,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -89,12 +87,14 @@ import com.vitorpamplona.amethyst.commons.resources.vanish_select_date
import com.vitorpamplona.amethyst.commons.resources.vanish_select_time
import com.vitorpamplona.amethyst.commons.resources.vanish_send_request
import com.vitorpamplona.amethyst.commons.resources.vanish_target_relay
import com.vitorpamplona.amethyst.commons.search.calendar.LocalClock
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.DateTimeStyle
import com.vitorpamplona.amethyst.commons.ui.note.formatDateTime
import com.vitorpamplona.amethyst.commons.ui.note.rememberIs24HourClock
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.RelayUrlEditField
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
@@ -108,9 +108,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -149,14 +146,13 @@ fun RequestToVanishScreen(
initialSelectedDateMillis = vanishDate * 1000,
)
val currentTime = Instant.ofEpochMilli(vanishDate * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime()
val context = LocalContext.current
// The vanish time as a local wall-clock time: seconds since the epoch shifted by this zone's offset.
val localSecondOfDay = (vanishDate + LocalClock.utcOffsetSeconds(vanishDate)).mod(TimeUtils.ONE_DAY)
val timePickerState =
rememberTimePickerState(
initialHour = currentTime.hour,
initialMinute = currentTime.minute,
is24Hour = DateFormat.is24HourFormat(context),
initialHour = localSecondOfDay / TimeUtils.ONE_HOUR,
initialMinute = (localSecondOfDay % TimeUtils.ONE_HOUR) / TimeUtils.ONE_MINUTE,
is24Hour = rememberIs24HourClock(),
)
val relayOptions =
@@ -387,9 +383,7 @@ fun RequestToVanishScreen(
(timePickerState.minute * TimeUtils.ONE_MINUTE)
} ?: TimeUtils.now()
val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now())
vanishDate = datetimeLocalTimeZone - offset.totalSeconds
vanishDate = datetimeLocalTimeZone - LocalClock.utcOffsetSeconds(TimeUtils.now())
showTimePicker = false
},
@@ -91,9 +91,11 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp
import com.vitorpamplona.amethyst.commons.ui.theme.SpacedBy10dp
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.commons.util.LanguageOption
import com.vitorpamplona.amethyst.commons.util.availableLanguages
import com.vitorpamplona.amethyst.commons.util.languageDisplayName
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import java.util.Locale as JavaLocale
@Preview(device = "spec:width=2160px,height=2340px,dpi=440")
@Composable
@@ -134,18 +136,12 @@ fun UserSettingsScreen(
}
}
private fun getAllLanguagesSorted(): List<JavaLocale> {
val seen = mutableSetOf<String>()
return JavaLocale
.getAvailableLocales()
.filter { it.language.isNotBlank() && it.country.isBlank() && seen.add(it.language) }
.sortedBy { it.displayName.lowercase() }
}
private fun getAllLanguagesSorted(): List<LanguageOption> = availableLanguages().sortedBy { it.displayName.lowercase() }
@Composable
private fun SearchableLanguageList(
languages: List<JavaLocale>,
onSelect: (JavaLocale) -> Unit,
languages: List<LanguageOption>,
onSelect: (LanguageOption) -> Unit,
modifier: Modifier = Modifier,
) {
var searchQuery by remember { mutableStateOf("") }
@@ -225,7 +221,7 @@ fun TranslateToSetting(accountViewModel: AccountViewModel) {
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = JavaLocale.forLanguageTag(currentTranslateTo).displayName,
text = languageDisplayName(currentTranslateTo),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
@@ -277,7 +273,7 @@ fun DontTranslateFromSetting(accountViewModel: AccountViewModel) {
InputChip(
selected = true,
onClick = { accountViewModel.removeDontTranslateFrom(languageCode) },
label = { Text(JavaLocale.forLanguageTag(languageCode).displayName) },
label = { Text(languageDisplayName(languageCode)) },
trailingIcon = {
Icon(
symbol = MaterialSymbols.Close,
@@ -379,8 +375,8 @@ private fun LanguagePreferenceCard(
preference: String,
accountViewModel: AccountViewModel,
) {
val sourceName = JavaLocale.forLanguageTag(source).displayName
val targetName = JavaLocale.forLanguageTag(target).displayName
val sourceName = languageDisplayName(source)
val targetName = languageDisplayName(target)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
@@ -464,9 +460,9 @@ private fun AddLanguagePairCard(
onDismiss: () -> Unit,
) {
val allLanguages = remember { getAllLanguagesSorted() }
var selectedSource by remember { mutableStateOf<JavaLocale?>(null) }
var selectedTarget by remember { mutableStateOf<JavaLocale?>(null) }
var selectedPreference by remember { mutableStateOf<JavaLocale?>(null) }
var selectedSource by remember { mutableStateOf<LanguageOption?>(null) }
var selectedTarget by remember { mutableStateOf<LanguageOption?>(null) }
var selectedPreference by remember { mutableStateOf<LanguageOption?>(null) }
var pickingSource by remember { mutableStateOf(false) }
var pickingTarget by remember { mutableStateOf(false) }
@@ -86,6 +86,9 @@ interface MediaUploader {
url: String,
httpClients: IRoleBasedHttpClientBuilder,
): FileHeader?
/** The file name the picker reported for [uri] (Android's `DISPLAY_NAME`), if any. */
fun displayName(uri: MediaUri): String? = uri.lastPathSegmentOrNull()
}
/** No media pipeline on this front end yet: every upload fails with a clear message. */
@@ -0,0 +1,33 @@
/*
* 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.util
/** A language the user can pick: its ISO code and its name in the user's own language. */
data class LanguageOption(
val language: String,
val displayName: String,
)
/** Every language the platform can name, one entry per language code (no regional variants). */
expect fun availableLanguages(): List<LanguageOption>
/** The name of the language [tag] (a BCP-47 tag such as `pt` or `pt-BR`) in the user's language. */
expect fun languageDisplayName(tag: String): String
@@ -0,0 +1,35 @@
/*
* 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.util
import platform.Foundation.NSLocale
import platform.Foundation.availableLocaleIdentifiers
import platform.Foundation.currentLocale
import platform.Foundation.localizedStringForLanguageCode
actual fun availableLanguages(): List<LanguageOption> =
NSLocale.availableLocaleIdentifiers
.mapNotNull { (it as? String)?.substringBefore('_')?.substringBefore('-') }
.filter { it.isNotBlank() }
.distinct()
.map { LanguageOption(it, languageDisplayName(it)) }
actual fun languageDisplayName(tag: String): String = NSLocale.currentLocale.localizedStringForLanguageCode(tag) ?: tag
@@ -0,0 +1,33 @@
/*
* 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.util
import java.util.Locale
actual fun availableLanguages(): List<LanguageOption> {
val seen = mutableSetOf<String>()
return Locale
.getAvailableLocales()
.filter { it.language.isNotBlank() && it.country.isBlank() && seen.add(it.language) }
.map { LanguageOption(it.language, it.displayName) }
}
actual fun languageDisplayName(tag: String): String = Locale.forLanguageTag(tag).displayName
@@ -31,9 +31,9 @@ import kotlin.coroutines.cancellation.CancellationException
actual fun rememberWalletAppLauncher(): WalletAppLauncher {
val context = LocalContext.current
return remember(context) {
WalletAppLauncher { invoice, noWalletFound, onPaid, onError ->
WalletAppLauncher { uri, noWalletFound, onPaid, onError ->
try {
val intent = Intent(Intent.ACTION_VIEW, "lightning:$invoice".toUri())
val intent = Intent(Intent.ACTION_VIEW, uri.toUri())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
context.startActivity(intent)
@@ -87,6 +87,7 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.SimpleImage75Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
import com.vitorpamplona.amethyst.commons.ui.wallet.payInvoice
import com.vitorpamplona.amethyst.commons.ui.wallet.rememberWalletAppLauncher
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
@@ -25,16 +25,33 @@ import androidx.compose.runtime.Composable
/** Hands a payment to a wallet app installed on the device. */
fun interface WalletAppLauncher {
/**
* Opens [invoice] (BOLT-11) in the wallet app. [onPaid] runs once the wallet took it, which is
* not a payment confirmation; [onError] gets [noWalletFound] when no wallet can open it.
* Opens the payment [uri] (`lightning:` or `bitcoin:`) in the wallet app. [onPaid] runs once
* the wallet took it, which is not a payment confirmation; [onError] gets [noWalletFound] when
* no wallet can open it.
*/
fun payInvoice(
invoice: String,
fun openPaymentUri(
uri: String,
noWalletFound: String,
onPaid: () -> Unit,
onError: (String) -> Unit,
)
}
/** Opens a BOLT-11 [invoice] in the wallet app; see [WalletAppLauncher.openPaymentUri]. */
fun WalletAppLauncher.payInvoice(
invoice: String,
noWalletFound: String,
onPaid: () -> Unit,
onError: (String) -> Unit,
) = openPaymentUri("lightning:$invoice", noWalletFound, onPaid, onError)
/** Opens a BOLT-12 [offer] in the wallet app; see [WalletAppLauncher.openPaymentUri]. */
fun WalletAppLauncher.payBolt12Offer(
offer: String,
noWalletFound: String,
onPaid: () -> Unit,
onError: (String) -> Unit,
) = openPaymentUri("bitcoin:?lno=$offer", noWalletFound, onPaid, onError)
@Composable
expect fun rememberWalletAppLauncher(): WalletAppLauncher
@@ -25,14 +25,14 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalUriHandler
import kotlin.coroutines.cancellation.CancellationException
/** Opens the `lightning:` link with whatever the system registered for it. */
/** Opens the payment link with whatever the system registered for it. */
@Composable
actual fun rememberWalletAppLauncher(): WalletAppLauncher {
val uriHandler = LocalUriHandler.current
return remember(uriHandler) {
WalletAppLauncher { invoice, noWalletFound, onPaid, onError ->
WalletAppLauncher { uri, noWalletFound, onPaid, onError ->
try {
uriHandler.openUri("lightning:$invoice")
uriHandler.openUri(uri)
onPaid()
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -25,14 +25,14 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalUriHandler
import kotlin.coroutines.cancellation.CancellationException
/** Opens the `lightning:` link with whatever the system registered for it. */
/** Opens the payment link with whatever the system registered for it. */
@Composable
actual fun rememberWalletAppLauncher(): WalletAppLauncher {
val uriHandler = LocalUriHandler.current
return remember(uriHandler) {
WalletAppLauncher { invoice, noWalletFound, onPaid, onError ->
WalletAppLauncher { uri, noWalletFound, onPaid, onError ->
try {
uriHandler.openUri("lightning:$invoice")
uriHandler.openUri(uri)
onPaid()
} catch (e: Exception) {
if (e is CancellationException) throw e