diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt index 9615badac2..f0b9ed5f4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt @@ -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 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index 4fee3c10b5..b5ed1e7750 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotInviteDeviceBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotInviteDeviceBanner.kt index d8655ff66b..9309f23d35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotInviteDeviceBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotInviteDeviceBanner.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt index 371626de80..e5cd237070 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt @@ -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" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt index 2ab7bcab5f..5bb2f5a567 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt index 0c89d5d432..667cde6e94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt @@ -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) }, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt index ef923afa9c..334c456b0c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt @@ -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 }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt index 0b8f3f2a38..c4bf9d3bc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt @@ -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 { - val seen = mutableSetOf() - return JavaLocale - .getAvailableLocales() - .filter { it.language.isNotBlank() && it.country.isBlank() && seen.add(it.language) } - .sortedBy { it.displayName.lowercase() } -} +private fun getAllLanguagesSorted(): List = availableLanguages().sortedBy { it.displayName.lowercase() } @Composable private fun SearchableLanguageList( - languages: List, - onSelect: (JavaLocale) -> Unit, + languages: List, + 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(null) } - var selectedTarget by remember { mutableStateOf(null) } - var selectedPreference by remember { mutableStateOf(null) } + var selectedSource by remember { mutableStateOf(null) } + var selectedTarget by remember { mutableStateOf(null) } + var selectedPreference by remember { mutableStateOf(null) } var pickingSource by remember { mutableStateOf(false) } var pickingTarget by remember { mutableStateOf(false) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUploader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUploader.kt index 285902e8a0..1165156444 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUploader.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUploader.kt @@ -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. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.kt new file mode 100644 index 0000000000..8be7292975 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.kt @@ -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 + +/** 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 diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.ios.kt new file mode 100644 index 0000000000..15d0fe71f1 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.ios.kt @@ -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 = + 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 diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.jvmAndroid.kt new file mode 100644 index 0000000000..e3ee56e4e0 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/LanguageNames.jvmAndroid.kt @@ -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 { + val seen = mutableSetOf() + 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 diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.android.kt index 2977a00f85..aba51de5b0 100644 --- a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.android.kt +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.android.kt @@ -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) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index 403708c1cc..829d030b12 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -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 diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.kt index 3b37a76947..6b9074bc97 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.kt @@ -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 diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.ios.kt index 9ecd3a05f9..e189462710 100644 --- a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.ios.kt +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.ios.kt @@ -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 diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.jvm.kt index 9ecd3a05f9..e189462710 100644 --- a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.jvm.kt +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/wallet/WalletAppLauncher.jvm.kt @@ -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