refactor: cut more shared-screen blockers (step 6 wave 4 seams)

- @SuppressLint -> @Suppress app-wide (Lint honours both; drops the android.annotation import).
- formatHistoryReachDate becomes expect/actual (NSDateFormatter template on iOS).
- quartz ConcurrentSet gains clear(); the Buzz console and DM-list view models use quartz's
  ConcurrentSet/ConcurrentMap instead of java.util.concurrent.
- HiddenAccountsFeedViewModel and ChessViewModelFactory override the multiplatform
  create(KClass, CreationExtras); StringFeedViewModel drops javaClass.
- SettingsIcon.Painter takes a DrawableResource (relays, ic_tor are Compose drawables).
- Connected-app detail labels come from Compose resources (browser permission keys copied from
  the synchronous tier); its OK button uses the translated "Confirm".
- Share buttons on the Concord invite, people-list and follow-pack screens go through
  rememberTextSharer; dates and counts use formatDateTime/formatGrouped; the attestation screen's
  UTC echo uses SearchDate instead of SimpleDateFormat.
- AppPlatform.isCastingAvailable replaces BuildConfig in the video-player settings;
  supportsEmbeddedAppTabs replaces the API-30 check in the drawer sections.
- WalletViewModel drops an unused appContext and routes CLINK budgets through the host's
  moneyOpRelays; LoadRedirectScreen takes INav.

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-29 04:16:55 +00:00
parent 7913ce156e
commit ce7e6d31bc
61 changed files with 310 additions and 187 deletions
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
@@ -690,7 +689,7 @@ object LocalPreferences {
* deleted. If we use `apply()` there is a race condition and the file will probably not be
* deleted
*/
@SuppressLint("ApplySharedPref")
@Suppress("ApplySharedPref")
suspend fun deleteAccount(accountInfo: AccountInfo) {
Log.d("LocalPreferences") { "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}" }
withContext(Dispatchers.IO) {
@@ -20,11 +20,10 @@
*/
package com.vitorpamplona.amethyst.service
import android.annotation.SuppressLint
import android.content.Context
object PackageUtils {
@SuppressLint("QueryPermissionsNeeded")
@Suppress("QueryPermissionsNeeded")
private fun isPackageInstalled(
context: Context,
target: String,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.location
import android.annotation.SuppressLint
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
@@ -67,7 +66,7 @@ class LocationFlow(
private val locationManager: LocationManager,
private val sdkInt: Int = Build.VERSION.SDK_INT,
) {
@SuppressLint("MissingPermission")
@Suppress("MissingPermission")
fun get(
minTimeMs: Long,
minDistanceM: Float,
@@ -114,7 +113,7 @@ class LocationFlow(
}
/** True when the registration was accepted; false when the provider refused it. */
@SuppressLint("MissingPermission")
@Suppress("MissingPermission")
private fun requestUpdates(
provider: String,
minTimeMs: Long,
@@ -143,7 +142,7 @@ class LocationFlow(
* the update request is — on a device where one refuses us, the others
* should still seed.
*/
@SuppressLint("MissingPermission")
@Suppress("MissingPermission")
private fun freshestLastKnownLocation(providers: List<String>): Location? =
providers
.mapNotNull { provider ->
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.logging
import android.annotation.SuppressLint
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
@@ -166,7 +165,7 @@ class StackSampler(
companion object {
const val SEPARATOR: String = "\r\n"
@SuppressLint("SimpleDateFormat")
@Suppress("SimpleDateFormat")
val TIME_FORMATTER: SimpleDateFormat = SimpleDateFormat("MM-dd HH:mm:ss.SSS")
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.payments
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
@@ -196,7 +195,7 @@ object PayToAppAvailability {
}.getOrNull()
}
@SuppressLint("QueryPermissionsNeeded")
@Suppress("QueryPermissionsNeeded")
private fun queryActivities(
pm: PackageManager,
intent: Intent,
@@ -208,7 +207,7 @@ object PayToAppAvailability {
}.getOrDefault(emptyList())
/** Packages that answer a URL nobody can own — i.e. general-purpose browsers. */
@SuppressLint("QueryPermissionsNeeded")
@Suppress("QueryPermissionsNeeded")
private fun browserPackages(pm: PackageManager): Set<String> = queryActivities(pm, viewIntent(CONTROL_URL)).mapNotNull { it.packageName() }.toSet()
/**
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.playback.diskCache
import android.annotation.SuppressLint
import android.content.Context
import android.os.StatFs
import androidx.media3.database.StandaloneDatabaseProvider
@@ -34,7 +33,7 @@ import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache.Companio
import com.vitorpamplona.quartz.utils.Log
import java.io.File
@SuppressLint("UnsafeOptInUsageError")
@Suppress("UnsafeOptInUsageError")
class VideoCache {
companion object {
// Target fraction of currently-available disk space.
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.Note
@@ -30,7 +29,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourc
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent
@SuppressLint("StateFlowValueCalledInComposition")
@Suppress("StateFlowValueCalledInComposition")
@Composable
fun NWCFinderFilterAssemblerSubscription(
note: Note,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.navigation.drawer
import android.os.Build
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -34,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.resources.drawer_section_navigate
import com.vitorpamplona.amethyst.commons.resources.drawer_section_system
import com.vitorpamplona.amethyst.commons.resources.drawer_section_you
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.commons.ui.platform.supportsEmbeddedAppTabs
import org.jetbrains.compose.resources.StringResource
/**
@@ -126,9 +126,8 @@ private val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SOFTWARE_APPS,
// Favorites can be pinned as inline tabs that render on a cross-process surface
// (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason.
NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R },
// Favorites can be pinned as inline tabs, which need a platform surface for them.
NavBarItem.FAVORITE_APPS.takeIf { supportsEmbeddedAppTabs },
NavBarItem.NAPPLETS,
NavBarItem.NSITES,
NavBarItem.FOLLOW_PACKS,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.navigation.navs
import android.annotation.SuppressLint
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.runtime.Composable
@@ -272,7 +271,7 @@ class Nav(
}
}
@SuppressLint("RestrictedApi")
@Suppress("RestrictedApi")
override fun <T : Route> popUpTo(
route: Route,
klass: KClass<T>,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.polls
import android.annotation.SuppressLint
import android.text.format.DateFormat
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -164,7 +163,7 @@ fun PollDeadlinePicker(model: ShortNotePostViewModel) {
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Suppress("ViewModelConstructorInComposable")
@Preview
@Composable
fun PollDeadlinePickerPreview() {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.polls
import android.annotation.SuppressLint
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -154,7 +153,7 @@ fun PollTypeSelector(
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Suppress("ViewModelConstructorInComposable")
@Preview
@Composable
fun NewPollClosingPreview() {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.zappolls
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -104,7 +103,7 @@ fun ZapPollConsensusThreshold(pollViewModel: ShortNotePostViewModel) {
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Suppress("ViewModelConstructorInComposable")
@Preview
@Composable
fun ZapPollConsensusThresholdPreview() {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.zappolls
import android.annotation.SuppressLint
import android.text.format.DateFormat
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -164,7 +163,7 @@ fun ZapPollDeadlinePicker(model: ShortNotePostViewModel) {
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Suppress("ViewModelConstructorInComposable")
@Preview
@Composable
fun ZapPollDeadlinePickerPreview() {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.zappolls
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.IconButton
@@ -89,7 +88,7 @@ fun ZapPollOption(
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Suppress("ViewModelConstructorInComposable")
@Preview
@Composable
fun ZapPollOptionPreview() {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.zappolls
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -120,7 +119,7 @@ fun ZapPollVoteValueRange(pollViewModel: ShortNotePostViewModel) {
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Suppress("ViewModelConstructorInComposable")
@Preview
@Composable
fun ZapPollVoteValueRangePreview() {
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -46,6 +47,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner.QrCodeScanne
/** Android's [AppPlatform]: the app's own shell pieces, camera scanner, app launcher and icon caches. */
object AndroidAppPlatform : AppPlatform {
override val isCastingAvailable: Boolean get() = BuildConfig.IS_CASTING_AVAILABLE
@Composable
override fun AppBottomBar(
selectedRoute: Route?,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old
import android.annotation.SuppressLint
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
@@ -103,7 +102,7 @@ fun OldBookmarkListScreen(
RenderOldBookmarkScreen(publicFeedViewModel, privateFeedViewModel, bookmarkState, accountViewModel, nav)
}
@SuppressLint("LocalContextGetResourceValueCall")
@Suppress("LocalContextGetResourceValueCall")
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun RenderOldBookmarkScreen(
@@ -91,6 +91,7 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_attest_remove
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_signed_title
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_topbar
import com.vitorpamplona.amethyst.commons.resources.buzz_attest_warning
import com.vitorpamplona.amethyst.commons.search.calendar.SearchDate
import com.vitorpamplona.amethyst.commons.ui.components.util.setText
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
@@ -110,10 +111,6 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
// Common event kinds an agent might be restricted to — suggestions for the "Restrict to kind" field;
// any 0–65535 is still accepted by free numeric entry.
@@ -654,15 +651,18 @@ private fun parseOptionalUnix(input: String): OptionalUnix? {
return OptionalUnix(parsed)
}
private val UNIX_ECHO_FORMAT =
SimpleDateFormat("yyyy-MM-dd HH:mm 'UTC'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }
/** A readable UTC rendering of an entered epoch-seconds string, or null when it's blank/out of range. */
/** A readable UTC rendering (`yyyy-MM-dd HH:mm UTC`) of an entered epoch-seconds string, or null when it's blank/out of range. */
private fun unixEcho(input: String): String? {
if (input.isBlank()) return null
val secs = input.toLongOrNull()?.takeIf { it in 0..4294967295L } ?: return null
return UNIX_ECHO_FORMAT.format(Date(secs * 1000))
val day = SearchDate.civilFromDays(secs / SECONDS_PER_DAY)
val secondOfDay = secs % SECONDS_PER_DAY
val hours = (secondOfDay / 3600).toString().padStart(2, '0')
val minutes = (secondOfDay % 3600 / 60).toString().padStart(2, '0')
return "${day.ymd()} $hours:$minutes UTC"
}
private const val SECONDS_PER_DAY = 86_400L
/** Serializes the `auth` tag to a JSON array string (values are hex / canonical ASCII). */
private fun OwnerAttestation.toTagJson(): String = toTag().joinToString(prefix = "[", postfix = "]") { "\"$it\"" }
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -50,7 +51,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.Collections
/**
* Backing ViewModel for the [AgentConsoleScreen] — the workspace owner's read-only
@@ -94,7 +94,7 @@ class AgentConsoleViewModel : ViewModel() {
private var watchJob: Job? = null
/** Dedups ephemeral frames across relays and re-emissions (accessed off multiple readers). */
private val observerSeen = Collections.synchronizedSet(HashSet<HexKey>())
private val observerSeen = ConcurrentSet<HexKey>()
/** Binds to [account] scoped to the community [relayUrl] — the console shows that community's fleet. */
fun bind(
@@ -226,7 +226,7 @@ class AgentConsoleViewModel : ViewModel() {
// Observer frames are ephemeral and unbounded over a long session; keep the
// dedup set from growing without limit. We only render the newest ~200 rows,
// so dropping the seen-history occasionally risks at most a stale duplicate.
if (observerSeen.size > MAX_OBSERVER_ROWS * 4) observerSeen.clear()
if (observerSeen.size() > MAX_OBSERVER_ROWS * 4) observerSeen.clear()
val fresh =
events
.filterIsInstance<ObserverFrameEvent>()
@@ -46,6 +46,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -55,7 +56,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.ConcurrentHashMap
/**
* Backing ViewModel for [BuzzDmListScreen] — the user's Buzz direct-message inbox.
@@ -87,7 +87,7 @@ class BuzzDmListViewModel : ViewModel() {
private var liveJob: Job? = null
/** channelId -> relay it was discovered on (from the 44100 provenance). */
private val memberChannels = ConcurrentHashMap<String, NormalizedRelayUrl>()
private val memberChannels = ConcurrentMap<String, NormalizedRelayUrl>()
private val _rows = MutableStateFlow<List<DmRow>>(emptyList())
val rows: StateFlow<List<DmRow>> = _rows.asStateFlow()
@@ -217,7 +217,9 @@ class BuzzDmListViewModel : ViewModel() {
/** Fetch the NIP-29 directory (39000-39003) of every discovered channel so its `t`/roster load. */
private suspend fun fetchMetadata(account: Account) {
val byRelay =
memberChannels.entries
memberChannels
.asMap()
.entries
.groupBy({ it.value }, { it.key })
.mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) }
if (byRelay.isEmpty()) return
@@ -233,7 +235,9 @@ class BuzzDmListViewModel : ViewModel() {
val myPubkey = account.userProfile().pubkeyHex
val hidden = BuzzDmRegistry.hiddenFor(myPubkey)
val (hiddenDms, visibleDms) =
memberChannels.entries
memberChannels
.asMap()
.entries
.mapNotNull { (channelId, relay) ->
val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(channelId, relay))
val metadata = channel.event ?: return@mapNotNull null
@@ -323,7 +327,8 @@ class BuzzDmListViewModel : ViewModel() {
.filterValues { it in scoped }
var changed = false
memberships.forEach { (channelId, relay) ->
if (memberChannels.put(channelId, relay) == null) changed = true
if (memberChannels[channelId] == null) changed = true
memberChannels[channelId] = relay
}
// A kind-44101 takes the membership away: drop the row rather than leaving a
// conversation the relay no longer lets us read.
@@ -338,7 +343,7 @@ class BuzzDmListViewModel : ViewModel() {
.let { BuzzChannelInvites.latestPerChannel(it) }
.filterValues { it.removed }
.keys
val gone = memberChannels.keys.filter { it in withdrawn }
val gone = memberChannels.asMap().keys.filter { it in withdrawn }
if (gone.isNotEmpty()) {
gone.forEach { memberChannels.remove(it) }
changed = true
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -143,6 +142,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest
import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -57,7 +56,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.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -103,6 +101,7 @@ import com.vitorpamplona.amethyst.commons.resources.copy_to_clipboard
import com.vitorpamplona.amethyst.commons.resources.leave
import com.vitorpamplona.amethyst.commons.resources.more_options
import com.vitorpamplona.amethyst.commons.resources.quick_action_share
import com.vitorpamplona.amethyst.commons.ui.components.rememberTextSharer
import com.vitorpamplona.amethyst.commons.ui.components.util.setText
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.ShorterTopAppBar
@@ -749,7 +748,7 @@ private fun InviteLinkDialog(
val concordInviteTitleStr = stringRes(Res.string.concord_invite_title)
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
val textSharer = rememberTextSharer()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(Res.string.concord_invite_title)) },
@@ -768,13 +767,7 @@ private fun InviteLinkDialog(
},
confirmButton = {
TextButton(onClick = {
val send =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, link)
}
context.startActivity(Intent.createChooser(send, concordInviteTitleStr))
textSharer.share(link, null, concordInviteTitleStr)
onDismiss()
}) {
Text(stringRes(Res.string.quick_action_share))
@@ -74,12 +74,12 @@ import com.vitorpamplona.amethyst.commons.resources.copy_to_clipboard
import com.vitorpamplona.amethyst.commons.resources.more_options
import com.vitorpamplona.amethyst.commons.ui.components.util.setText
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.DateTimeStyle
import com.vitorpamplona.amethyst.commons.ui.note.formatDateTime
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
import kotlinx.coroutines.launch
import java.text.DateFormat
import java.util.Date
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/** What the screen is currently showing. The unreadable case is deliberately not "empty" — see below. */
@@ -258,7 +258,7 @@ private fun InviteLinkRow(
// a long prefix, so they are useless as labels until well past where the row wraps.
Text(link.token.take(8), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge)
Text(
stringRes(Res.string.concord_invite_links_created, DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(link.createdAt * 1000))),
stringRes(Res.string.concord_invite_links_created, formatDateTime(link.createdAt * 1000, DateTimeStyle.MEDIUM, DateTimeStyle.NONE)),
style = MaterialTheme.typography.bodySmall,
)
Text(link.url, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -225,7 +224,7 @@ fun ChessRelaySettingsSheet(
}
}
@SuppressLint("StateFlowValueCalledInComposition")
@Suppress("StateFlowValueCalledInComposition")
@Composable
fun StatRow(chessViewModel: ChessViewModel) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
@@ -22,10 +22,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessDismissedGamesStore
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessPollingDefaults
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessViewModel
import kotlin.reflect.KClass
/**
* Factory for the shared [ChessViewModel], wired with the Android adapters.
@@ -34,8 +36,11 @@ class ChessViewModelFactory(
private val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ChessViewModel::class.java)) {
override fun <T : ViewModel> create(
modelClass: KClass<T>,
extras: CreationExtras,
): T {
if (modelClass == ChessViewModel::class) {
return ChessViewModel(
userPubkey = account.userProfile().pubkeyHex,
publisher = AndroidChessPublisher(account),
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -97,7 +96,7 @@ import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.quartz.nip99Classifieds.tags.ConditionTag
import kotlinx.collections.immutable.toImmutableList
@SuppressLint("ViewModelConstructorInComposable")
@Suppress("ViewModelConstructorInComposable")
@Preview
@Composable
fun SellProductPreview() {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import android.annotation.SuppressLint
import android.content.Context
import android.os.SystemClock
import android.text.Editable
@@ -52,7 +51,7 @@ import kotlinx.serialization.json.put
* It covers the soft keyboard, hardware keyboards, autofill, paste, and context-menu edits uniformly,
* because every one of them mutates the same [Editable] and we flush the resulting state.
*/
@SuppressLint("ViewConstructor", "AppCompatCustomView")
@Suppress("ViewConstructor", "AppCompatCustomView")
class RemoteImeView(
context: Context,
) : EditText(context) {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -106,7 +105,7 @@ fun FollowPackFeedScreen(
}
}
@SuppressLint("StateFlowValueCalledInComposition")
@Suppress("StateFlowValueCalledInComposition")
@Composable
fun PrepareViewModelsFollowPackScreen(
note: AddressableNote,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
@@ -64,7 +63,7 @@ fun GeoHashScreen(
PrepareViewModelsGeoHashScreen(tag, accountViewModel, nav)
}
@SuppressLint("StateFlowValueCalledInComposition")
@Suppress("StateFlowValueCalledInComposition")
@Composable
fun PrepareViewModelsGeoHashScreen(
tag: Route.Geohash,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.navigation.Route
@@ -28,7 +27,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.geohash.GeohashQueryState
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@SuppressLint("StateFlowValueCalledInComposition")
@Suppress("StateFlowValueCalledInComposition")
@Composable
fun GeoHashFilterAssemblerSubscription(
tag: Route.Geohash,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag
import android.annotation.SuppressLint
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
@@ -81,7 +80,7 @@ fun HashtagScreen(
PrepareViewModelsHashtagScreen(tag, accountViewModel, nav)
}
@SuppressLint("StateFlowValueCalledInComposition")
@Suppress("StateFlowValueCalledInComposition")
@Composable
fun PrepareViewModelsHashtagScreen(
tag: Route.Hashtag,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.BackHandler
@@ -953,7 +952,7 @@ private fun BottomRowActions(
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Suppress("ViewModelConstructorInComposable")
@Preview
@Composable
private fun BottomRowActionsPreview() {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
@@ -59,7 +58,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
@@ -87,6 +85,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.commons.ui.components.rememberTextSharer
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -433,30 +432,14 @@ private fun ListActionsMenuButton(
}
if (isActionListOpen.value) {
val context = LocalContext.current
val textSharer = rememberTextSharer()
M3ActionDialog(
title = stringRes(Res.string.list_actions_dialog_title),
onDismiss = { isActionListOpen.value = false },
) {
M3ActionSection {
M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(Res.string.quick_action_share)) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note()),
)
putExtra(
Intent.EXTRA_TITLE,
quickActionShareBrowserLinkStr,
)
}
val shareIntent =
Intent.createChooser(sendIntent, quickActionShareStr)
context.startActivity(shareIntent)
textSharer.share(externalLinkForNote(note()), quickActionShareBrowserLinkStr, quickActionShareStr)
isActionListOpen.value = false
}
M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(Res.string.follow_set_edit_list_metadata)) {
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
@@ -52,7 +51,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
@@ -76,6 +74,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.commons.ui.components.rememberTextSharer
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -284,30 +283,14 @@ private fun ListActionsMenuButton(
}
if (isActionListOpen.value) {
val context = LocalContext.current
val textSharer = rememberTextSharer()
M3ActionDialog(
title = stringRes(Res.string.pack_actions_dialog_title),
onDismiss = { isActionListOpen.value = false },
) {
M3ActionSection {
M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(Res.string.quick_action_share)) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note()),
)
putExtra(
Intent.EXTRA_TITLE,
quickActionShareBrowserLinkStr,
)
}
val shareIntent =
Intent.createChooser(sendIntent, quickActionShareStr)
context.startActivity(shareIntent)
textSharer.share(externalLinkForNote(note()), quickActionShareBrowserLinkStr, quickActionShareStr)
isActionListOpen.value = false
}
M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(Res.string.follow_pack_edit_list_metadata)) {
@@ -84,7 +84,15 @@ import com.vitorpamplona.amethyst.commons.napplet.ui.PolicyCard
import com.vitorpamplona.amethyst.commons.napplet.ui.symbol
import com.vitorpamplona.amethyst.commons.nip46RemoteSigner.ui.Nip46ActivityCard
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.browser_permission_allowed
import com.vitorpamplona.amethyst.commons.resources.browser_permission_ask
import com.vitorpamplona.amethyst.commons.resources.browser_permission_blocked
import com.vitorpamplona.amethyst.commons.resources.browser_permission_camera
import com.vitorpamplona.amethyst.commons.resources.browser_permission_location
import com.vitorpamplona.amethyst.commons.resources.browser_permission_microphone
import com.vitorpamplona.amethyst.commons.resources.browser_permission_section
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.confirm
import com.vitorpamplona.amethyst.commons.resources.napplet_connected_app_capabilities
import com.vitorpamplona.amethyst.commons.resources.napplet_connected_app_forget
import com.vitorpamplona.amethyst.commons.resources.napplet_connected_app_op_overrides
@@ -106,6 +114,7 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_policy_paranoid_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_reasonable
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_reasonable_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_signer_permissions_revoke_all
import com.vitorpamplona.amethyst.commons.resources.napplet_untitled
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_activity_title
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_app_relays_inbox
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_app_relays_own_hint
@@ -116,6 +125,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.platform.rememberManifestIconModel
import com.vitorpamplona.amethyst.commons.ui.platform.rememberWebAppIconModel
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.napplet.NappletBrokerService
import com.vitorpamplona.amethyst.napplet.WebSitePermissionRegistry
@@ -129,13 +139,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46Reconne
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46StatusDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46AppOnline
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46ClientSubtitle
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.vitorpamplona.amethyst.commons.R as CommonsR
import org.jetbrains.compose.resources.StringResource
private data class ConnectedAppDetailState(
val title: String,
@@ -154,7 +163,7 @@ fun ConnectedAppDetailScreen(
) {
val capabilityLedger = Amethyst.instance.nappletPermissionLedger
val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
val untitled = stringRes(CommonsR.string.napplet_untitled)
val untitled = stringRes(Res.string.napplet_untitled)
var state by remember { mutableStateOf<ConnectedAppDetailState?>(null) }
var reload by remember { mutableIntStateOf(0) }
@@ -557,7 +566,7 @@ private const val BROWSER_PREFIX = "browser:"
private fun SitePermissionsSection(origin: String) {
val all by WebSitePermissionRegistry.decisions.collectAsStateWithLifecycle()
val decisions = all[origin].orEmpty()
SectionHeader(stringRes(CommonsR.string.browser_permission_section))
SectionHeader(stringRes(Res.string.browser_permission_section))
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
@@ -575,9 +584,9 @@ private fun SitePermissionsSection(origin: String) {
Text(
stringRes(
when (permission) {
BrowserSitePermission.CAMERA -> CommonsR.string.browser_permission_camera
BrowserSitePermission.MICROPHONE -> CommonsR.string.browser_permission_microphone
BrowserSitePermission.LOCATION -> CommonsR.string.browser_permission_location
BrowserSitePermission.CAMERA -> Res.string.browser_permission_camera
BrowserSitePermission.MICROPHONE -> Res.string.browser_permission_microphone
BrowserSitePermission.LOCATION -> Res.string.browser_permission_location
},
),
style = MaterialTheme.typography.bodyMedium,
@@ -603,11 +612,11 @@ private fun SitePermissionsSection(origin: String) {
}
}
private fun BrowserSitePermission.Decision.labelRes(): Int =
private fun BrowserSitePermission.Decision.labelRes(): StringResource =
when (this) {
BrowserSitePermission.Decision.ASK -> CommonsR.string.browser_permission_ask
BrowserSitePermission.Decision.ALLOW -> CommonsR.string.browser_permission_allowed
BrowserSitePermission.Decision.BLOCK -> CommonsR.string.browser_permission_blocked
BrowserSitePermission.Decision.ASK -> Res.string.browser_permission_ask
BrowserSitePermission.Decision.ALLOW -> Res.string.browser_permission_allowed
BrowserSitePermission.Decision.BLOCK -> Res.string.browser_permission_blocked
}
@Composable
@@ -800,7 +809,7 @@ private fun CapabilityPermissionDialog(
TextButton(
onClick = { onSetGrant(if (selected == GrantState.ASK) null else selected) },
) {
Text(stringRes(android.R.string.ok))
Text(stringRes(Res.string.confirm))
}
},
dismissButton = {
@@ -50,7 +50,6 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -68,7 +67,7 @@ fun LoadRedirectScreen(
eventId: String?,
isPrivate: Boolean,
accountViewModel: AccountViewModel,
nav: Nav,
nav: INav,
) {
if (eventId == null) return
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relay
import android.annotation.SuppressLint
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -59,7 +58,7 @@ fun RelayFeedScreen(
PrepareViewModelsRelayFeedScreen(tag, normalizedUrl, accountViewModel, nav)
}
@SuppressLint("StateFlowValueCalledInComposition")
@Suppress("StateFlowValueCalledInComposition")
@Composable
fun PrepareViewModelsRelayFeedScreen(
tag: Route.RelayFeed,
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.ui.platform.UriHandler
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.Route
@@ -54,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.resources.event_sync_title
import com.vitorpamplona.amethyst.commons.resources.favorite_dvms_search_keywords
import com.vitorpamplona.amethyst.commons.resources.favorite_dvms_title
import com.vitorpamplona.amethyst.commons.resources.home_tabs_search_keywords
import com.vitorpamplona.amethyst.commons.resources.ic_tor
import com.vitorpamplona.amethyst.commons.resources.import_follows_search_keywords
import com.vitorpamplona.amethyst.commons.resources.media_servers
import com.vitorpamplona.amethyst.commons.resources.media_servers_search_keywords
@@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_auth_search_keywords
import com.vitorpamplona.amethyst.commons.resources.relay_auth_settings_title
import com.vitorpamplona.amethyst.commons.resources.relay_setup
import com.vitorpamplona.amethyst.commons.resources.relay_setup_search_keywords
import com.vitorpamplona.amethyst.commons.resources.relays
import com.vitorpamplona.amethyst.commons.resources.request_to_vanish
import com.vitorpamplona.amethyst.commons.resources.request_to_vanish_search_keywords
import com.vitorpamplona.amethyst.commons.resources.reset_marmot_search_keywords
@@ -143,7 +144,7 @@ fun buildSettingsCatalog(
listOf(
SettingsEntry(
titleRes = Res.string.relay_setup,
icon = SettingsIcon.Painter(R.drawable.relays, 4),
icon = SettingsIcon.Painter(Res.drawable.relays, 4),
keywordsRes = Res.string.relay_setup_search_keywords,
) { nav.nav(Route.EditRelays) },
symEntry(Res.string.event_sync_title, MaterialSymbols.Sync, Res.string.event_sync_search_keywords, Route.EventSync),
@@ -185,7 +186,7 @@ fun buildSettingsCatalog(
listOf(
SettingsEntry(
titleRes = Res.string.privacy_options,
icon = SettingsIcon.Painter(R.drawable.ic_tor, 1),
icon = SettingsIcon.Painter(Res.drawable.ic_tor, 1),
keywordsRes = Res.string.privacy_options_search_keywords,
) { nav.nav(Route.PrivacyOptions) },
symEntry(Res.string.ui_preferences, MaterialSymbols.Settings, Res.string.ui_preferences_search_keywords, Route.Settings),
@@ -51,8 +51,9 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.painterRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.painterRes
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.StringResource
import androidx.compose.material3.Icon as Material3Icon
@@ -130,7 +131,7 @@ internal fun SettingsItem(
@Composable
internal fun SettingsItem(
title: StringResource,
iconPainter: Int,
iconPainter: DrawableResource,
iconPainterRef: Int,
isDanger: Boolean = false,
trailing: @Composable () -> Unit = {},
@@ -96,23 +96,23 @@ open class StringFeedViewModel(
}
init {
Log.d("Init", this.javaClass.simpleName)
Log.d("Init") { "${this::class.simpleName}" }
viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
Log.d("Rendering Metrics") { "Update feeds: ${this@StringFeedViewModel.javaClass.simpleName} with ${newNotes.size}" }
Log.d("Rendering Metrics") { "Update feeds: ${this@StringFeedViewModel::class.simpleName} with ${newNotes.size}" }
invalidateData()
}
}
viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.deletedEventBundles.collect { newNotes ->
Log.d("Rendering Metrics") { "Delete feeds: ${this@StringFeedViewModel.javaClass.simpleName} with ${newNotes.size}" }
Log.d("Rendering Metrics") { "Delete feeds: ${this@StringFeedViewModel::class.simpleName} with ${newNotes.size}" }
invalidateData()
}
}
}
override fun onCleared() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
Log.d("Init") { "OnCleared: ${this::class.simpleName}" }
bundler.cancel()
}
}
@@ -58,7 +58,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.VideoButtonLocation
@@ -89,6 +88,7 @@ import com.vitorpamplona.amethyst.commons.resources.video_player_settings_reorde
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.platform.LocalAppPlatform
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
@@ -125,8 +125,9 @@ fun VideoPlayerSettingsScreen(
@Composable
fun VideoPlayerSettingsContent(accountViewModel: AccountViewModel) {
val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle()
val isCastingAvailable = LocalAppPlatform.current.isCastingAvailable
val displayedItems =
if (BuildConfig.IS_CASTING_AVAILABLE) {
if (isCastingAvailable) {
buttonItems
} else {
buttonItems.filter { it.action != VideoPlayerAction.Cast }
@@ -22,8 +22,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
import kotlin.reflect.KClass
class HiddenAccountsFeedViewModel(
val account: Account,
@@ -31,12 +33,15 @@ class HiddenAccountsFeedViewModel(
class Factory(
val account: Account,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(HiddenAccountsFeedViewModel::class.java)) {
override fun <T : ViewModel> create(
modelClass: KClass<T>,
extras: CreationExtras,
): T {
if (modelClass == HiddenAccountsFeedViewModel::class) {
@Suppress("UNCHECKED_CAST")
return HiddenAccountsFeedViewModel(account) as T
}
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.simpleName}")
}
}
}
@@ -22,7 +22,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview
import android.annotation.SuppressLint
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
@@ -1286,7 +1285,7 @@ private fun RenderApprovalIfNeeded(
}
}
@SuppressLint("StateFlowValueCalledInComposition")
@Suppress("StateFlowValueCalledInComposition")
@Composable
private fun RenderClassifiedsReaderForThread(
noteEvent: ClassifiedsEvent,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.url
import android.annotation.SuppressLint
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -59,7 +58,7 @@ fun UrlScreen(
PrepareViewModelsUrlScreen(url, accountViewModel, nav)
}
@SuppressLint("StateFlowValueCalledInComposition")
@Suppress("StateFlowValueCalledInComposition")
@Composable
fun PrepareViewModelsUrlScreen(
url: String,
@@ -20,14 +20,13 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.url.datasource
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.commons.relayClient.url.UrlQueryState
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@SuppressLint("StateFlowValueCalledInComposition")
@Suppress("StateFlowValueCalledInComposition")
@Composable
fun UrlFilterAssemblerSubscription(
url: String,
@@ -159,9 +159,11 @@ import com.vitorpamplona.amethyst.commons.resources.wallet_sats
import com.vitorpamplona.amethyst.commons.ui.components.util.getText
import com.vitorpamplona.amethyst.commons.ui.components.util.setText
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.DateTimeStyle
import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
import com.vitorpamplona.amethyst.commons.ui.note.UserPicture
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.commons.ui.note.formatDateTime
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.CashuMeltFlowState
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.CashuMintFlowState
@@ -170,6 +172,7 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.CashuRedeemF
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.CashuSendTokenFlowState
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.CashuWalletViewModel
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.util.formatGrouped
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
@@ -180,9 +183,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import java.text.DateFormat
import java.text.NumberFormat
import java.util.Date
import androidx.compose.material3.Icon as Material3Icon
@OptIn(ExperimentalMaterial3Api::class)
@@ -882,7 +882,7 @@ private fun EvacuateMintDialog(
private fun BalanceCard(balanceSats: Long) {
val formatted =
remember(balanceSats) {
NumberFormat.getIntegerInstance().format(balanceSats)
formatGrouped(balanceSats)
}
Card(
modifier = Modifier.fillMaxWidth(),
@@ -990,7 +990,7 @@ private fun MintRow(
) {
val formattedBalance =
remember(balanceSats) {
NumberFormat.getIntegerInstance().format(balanceSats)
formatGrouped(balanceSats)
}
Card(
modifier = Modifier.fillMaxWidth(),
@@ -1151,9 +1151,7 @@ private fun HistoryRow(
}
Text(
text =
DateFormat
.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
.format(Date(entry.createdAt * 1000)),
formatDateTime(entry.createdAt * 1000, DateTimeStyle.MEDIUM, DateTimeStyle.SHORT),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -1163,7 +1161,7 @@ private fun HistoryRow(
val amount = decoded?.amount
if (amount != null) {
Text(
text = (if (isIncoming) "+" else "−") + NumberFormat.getIntegerInstance().format(amount),
text = (if (isIncoming) "+" else "−") + formatGrouped(amount),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color =
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcSignerState
@@ -132,11 +131,7 @@ private const val NWC_TIMEOUT_MS = 30_000L
class WalletViewModel : ViewModel() {
// Error text is resolved here rather than in the screens because a single _error
// flow feeds several of them. WalletViewModel only ever runs in the main process,
// so Amethyst.instance is always initialised (see AccountViewModel for the same
// pattern); the :napplet process never builds a wallet screen.
private val appContext get() = Amethyst.instance.appContext
// flow feeds several of them.
private suspend fun text(id: StringResource) = loadStringRes(id)
private suspend fun text(
@@ -372,12 +367,13 @@ class WalletViewModel : ViewModel() {
) {
val acc = account ?: return
val pointer = _debitWallets.value.firstOrNull { it.id == walletId }?.pointer ?: return
val moneyOpRelays = accountViewModel?.host?.moneyOpRelays ?: return
viewModelScope.launch {
// A malformed budget (e.g. an out-of-spec frequency unit) makes requestBudget throw;
// treat it as "no response" so the dialog dismisses instead of hanging on a spinner.
val response =
try {
ClinkDebitPayer.requestBudget(acc, Amethyst.instance.torEvaluatorFlow, pointer, amountSats, frequency)
ClinkDebitPayer.requestBudget(acc, moneyOpRelays, pointer, amountSats, frequency)
} catch (_: IllegalArgumentException) {
null
}
@@ -0,0 +1,27 @@
/*
* 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.model.chats
/**
* Formats a paging reach point (epoch seconds) to a short, locale-aware month-year label, e.g.
* "Jun 2026", for the shared [com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard].
*/
expect fun formatHistoryReachDate(epochSeconds: Long): String
@@ -0,0 +1,32 @@
/*
* 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.model.chats
import platform.Foundation.NSDate
import platform.Foundation.NSDateFormatter
import platform.Foundation.dateWithTimeIntervalSince1970
// A formatter per call: this runs once per history-card render, and NSDateFormatter is not
// safe to share across threads.
actual fun formatHistoryReachDate(epochSeconds: Long): String =
NSDateFormatter()
.apply { setLocalizedDateFormatFromTemplate("MMMyyyy") }
.stringFromDate(NSDate.dateWithTimeIntervalSince1970(epochSeconds.toDouble()))
@@ -24,9 +24,4 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* The Android locale date formatter for the shared [com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard]
* — passed in so the shared (KMP) card carries no `java.time` dependency. Formats a paging reach point
* (epoch seconds) to a short month-year label, e.g. "Jun 2026".
*/
fun formatHistoryReachDate(epochSeconds: Long): String = SimpleDateFormat("MMM yyyy", Locale.getDefault()).format(Date(epochSeconds * 1000))
actual fun formatHistoryReachDate(epochSeconds: Long): String = SimpleDateFormat("MMM yyyy", Locale.getDefault()).format(Date(epochSeconds * 1000))
@@ -0,0 +1,25 @@
/*
* 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.platform
import android.os.Build
actual val supportsEmbeddedAppTabs: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
@@ -6245,4 +6245,11 @@
<string name="cordn_voice_stop">Stop and send</string>
<string name="browser_address_hint">Search or enter address</string>
<string name="napplet_untitled">Untitled nApplet</string>
<string name="browser_permission_section">Site permissions</string>
<string name="browser_permission_camera">Use your camera</string>
<string name="browser_permission_microphone">Use your microphone</string>
<string name="browser_permission_location">Know your approximate location</string>
<string name="browser_permission_ask">Ask</string>
<string name="browser_permission_allowed">Allowed</string>
<string name="browser_permission_blocked">Blocked</string>
</resources>
@@ -39,6 +39,9 @@ import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
*/
@Stable
interface AppPlatform {
/** Whether this build can cast media to a TV (the Play flavour's Cast SDK). */
val isCastingAvailable: Boolean get() = false
@Composable
fun AppBottomBar(
selectedRoute: Route?,
@@ -0,0 +1,27 @@
/*
* 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.platform
/**
* Whether favorite apps can be pinned as inline tabs. On Android they render on a cross-process
* surface (SurfaceControlViewHost), which needs API 30; other front ends have no such surface yet.
*/
expect val supportsEmbeddedAppTabs: Boolean
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.ui.settings
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.StringResource
/** Leading-icon representation mirroring the two [SettingsItem] overloads. */
@@ -30,7 +31,7 @@ sealed interface SettingsIcon {
) : SettingsIcon
data class Painter(
val iconPainter: Int,
val iconPainter: DrawableResource,
val iconPainterRef: Int,
) : SettingsIcon
}
@@ -0,0 +1,23 @@
/*
* 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.platform
actual val supportsEmbeddedAppTabs: Boolean = false
@@ -0,0 +1,23 @@
/*
* 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.platform
actual val supportsEmbeddedAppTabs: Boolean = false
@@ -59,7 +59,7 @@ class SettingsCatalogFilterTest {
isDanger: Boolean = false,
) = SettingsEntry(
titleRes = titleRes,
icon = SettingsIcon.Painter(0, 0),
icon = SettingsIcon.Painter(Res.drawable.relays, 0),
keywordsRes = keywordsRes,
isDanger = isDanger,
onClick = {},
@@ -38,6 +38,8 @@ expect class ConcurrentSet<E : Any>() {
fun size(): Int
fun clear()
/** A point-in-time copy — safe to iterate or diff against without a lock. */
fun snapshot(): Set<E>
}
@@ -31,5 +31,7 @@ actual class ConcurrentSet<E : Any> {
actual fun size(): Int = set.size
actual fun clear() = set.clear()
actual fun snapshot(): Set<E> = HashSet(set)
}
@@ -42,5 +42,7 @@ actual class ConcurrentSet<E : Any> {
actual fun size(): Int = ref.load().size
actual fun clear() = ref.store(HashSet())
actual fun snapshot(): Set<E> = HashSet(ref.load())
}