feat: Extract shared UI components to commons for Android/Desktop reuse
Extract reusable components from desktopApp to commons module: Utilities (commonMain): - NumberFormatters: countToHumanReadable, countToHumanReadableBytes - PubKeyFormatter: toShortDisplay, toDisplayHexKey for key formatting Utilities (jvmAndroid): - TimeAgoFormatter: Human-readable time formatting for Nostr timestamps - ZapFormatter: BigDecimal amount formatting with G/M/k suffixes UI Components (commonMain): - AppScreen enum for shared navigation - LoadingState, EmptyState, ErrorState with refresh/retry support - FeedHeader with relay status indicator - NoteCard for displaying Nostr notes - ActionButtons (AddButton, RemoveButton) - RobohashImage for avatar display - PlaceholderScreens (Search, Messages, Notifications) - RelayStatusColors and shared color definitions UI Components (jvmAndroid): - LoginCard with key input field - NewKeyWarningCard for new key backup warnings - KeyInputField and SelectableKeyText - ProfileInfoCard for account display - RelayStatusCard for relay management Core (jvmAndroid): - AccountManager with login/logout/key generation - RelayConnectionManager base class - RelayStatus and DefaultRelays 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,8 @@ kotlin {
|
||||
implementation(compose.ui)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.materialIconsExtended)
|
||||
|
||||
// LruCache (KMP-ready)
|
||||
implementation(libs.androidx.collection)
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.navigation
|
||||
|
||||
/**
|
||||
* Main application screens shared between Desktop and Android.
|
||||
* Each platform implements its own navigation using these identifiers.
|
||||
*/
|
||||
enum class AppScreen(
|
||||
val label: String,
|
||||
val route: String,
|
||||
) {
|
||||
Feed("Feed", "feed"),
|
||||
Search("Search", "search"),
|
||||
Messages("Messages", "messages"),
|
||||
Notifications("Notifications", "notifications"),
|
||||
Profile("Profile", "profile"),
|
||||
Settings("Settings", "settings"),
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary navigation destinations (shown in bottom bar on mobile, sidebar on desktop).
|
||||
*/
|
||||
val primaryScreens = listOf(
|
||||
AppScreen.Feed,
|
||||
AppScreen.Search,
|
||||
AppScreen.Messages,
|
||||
AppScreen.Notifications,
|
||||
AppScreen.Profile,
|
||||
)
|
||||
|
||||
/**
|
||||
* Secondary navigation destinations (settings, etc.)
|
||||
*/
|
||||
val secondaryScreens = listOf(
|
||||
AppScreen.Settings,
|
||||
)
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.components
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Standard button shape used for action buttons.
|
||||
*/
|
||||
val ActionButtonShape = RoundedCornerShape(20.dp)
|
||||
|
||||
/**
|
||||
* Standard content padding for action buttons.
|
||||
*/
|
||||
val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
|
||||
|
||||
/**
|
||||
* An "Add" action button with consistent styling.
|
||||
*
|
||||
* @param onClick Action to perform when clicked
|
||||
* @param modifier Modifier for the button
|
||||
* @param text Button text (default: "Add")
|
||||
* @param enabled Whether the button is enabled
|
||||
*/
|
||||
@Composable
|
||||
fun AddButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
text: String = "Add",
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
shape = ActionButtonShape,
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
Text(text = text, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A "Remove" action button with consistent styling.
|
||||
*
|
||||
* @param onClick Action to perform when clicked
|
||||
* @param modifier Modifier for the button
|
||||
* @param text Button text (default: "Remove")
|
||||
* @param enabled Whether the button is enabled
|
||||
*/
|
||||
@Composable
|
||||
fun RemoveButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
text: String = "Remove",
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
shape = ActionButtonShape,
|
||||
enabled = enabled,
|
||||
contentPadding = ActionButtonPadding,
|
||||
) {
|
||||
Text(text = text)
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A centered loading state with a progress indicator and message.
|
||||
* Can be used by both Desktop and Android apps.
|
||||
*/
|
||||
@Composable
|
||||
fun LoadingState(
|
||||
message: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
message,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A centered empty state with title, optional description, and optional refresh action.
|
||||
*/
|
||||
@Composable
|
||||
fun EmptyState(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
description: String? = null,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
refreshLabel: String = "Refresh",
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (description != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
if (onRefresh != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedButton(onClick = onRefresh) {
|
||||
Text(refreshLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A centered error state with message and optional retry action.
|
||||
*/
|
||||
@Composable
|
||||
fun ErrorState(
|
||||
message: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
retryLabel: String = "Try Again",
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
if (onRetry != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(onClick = onRetry) {
|
||||
Text(retryLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty feed state - a common pattern for feed views.
|
||||
*/
|
||||
@Composable
|
||||
fun FeedEmptyState(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Feed is empty",
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
) {
|
||||
EmptyState(
|
||||
title = title,
|
||||
modifier = modifier,
|
||||
onRefresh = onRefresh,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed error state - a common pattern for feed views.
|
||||
*/
|
||||
@Composable
|
||||
fun FeedErrorState(
|
||||
errorMessage: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
) {
|
||||
ErrorState(
|
||||
message = "Error loading feed: $errorMessage",
|
||||
modifier = modifier,
|
||||
onRetry = onRetry,
|
||||
)
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Face
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
|
||||
|
||||
/**
|
||||
* Determines if the current color scheme is light.
|
||||
* Uses the luminance of the background color.
|
||||
*/
|
||||
@Composable
|
||||
private fun isLightTheme(): Boolean {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
// Simple luminance check: if any RGB component > 0.5, consider it light
|
||||
return (background.red + background.green + background.blue) / 3 > 0.5f
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a robohash image based on a seed string (typically a public key).
|
||||
* Falls back to a generic face icon if robohash is disabled.
|
||||
*
|
||||
* @param robot The seed string for generating the robohash (e.g., pubkey hex)
|
||||
* @param modifier Modifier for the image
|
||||
* @param contentDescription Accessibility description
|
||||
* @param loadRobohash Whether to load the robohash or show a placeholder
|
||||
*/
|
||||
@Composable
|
||||
fun RobohashImage(
|
||||
robot: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
loadRobohash: Boolean = true,
|
||||
) {
|
||||
if (loadRobohash) {
|
||||
Image(
|
||||
imageVector = CachedRobohash.get(robot, isLightTheme()),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
imageVector = Icons.Default.Face,
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a robohash image with configurable alignment and content scale.
|
||||
*/
|
||||
@Composable
|
||||
fun RobohashImage(
|
||||
robot: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
colorFilter: ColorFilter? = null,
|
||||
loadRobohash: Boolean = true,
|
||||
) {
|
||||
if (loadRobohash) {
|
||||
Image(
|
||||
painter = rememberVectorPainter(CachedRobohash.get(robot, isLightTheme())),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
colorFilter = colorFilter,
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
imageVector = Icons.Default.Face,
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = colorFilter ?: ColorFilter.tint(MaterialTheme.colorScheme.onBackground),
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
)
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.feed
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.RelayStatusColors
|
||||
|
||||
/**
|
||||
* Header component for feed screens with title and relay connection status.
|
||||
*
|
||||
* @param title The feed title
|
||||
* @param connectedRelayCount Number of connected relays
|
||||
* @param onRefresh Callback when refresh button is clicked
|
||||
* @param modifier Modifier for the header row
|
||||
*/
|
||||
@Composable
|
||||
fun FeedHeader(
|
||||
title: String,
|
||||
connectedRelayCount: Int,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
RelayStatusIndicator(
|
||||
connectedCount = connectedRelayCount,
|
||||
onRefresh = onRefresh,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact relay connection status indicator with refresh button.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayStatusIndicator(
|
||||
connectedCount: Int,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
val statusColor = when {
|
||||
connectedCount == 0 -> RelayStatusColors.Disconnected
|
||||
connectedCount < 3 -> RelayStatusColors.Connecting
|
||||
else -> RelayStatusColors.Connected
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
tint = statusColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
"$connectedCount relay${if (connectedCount != 1) "s" else ""}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = "Reconnect",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Generic placeholder screen with title and description.
|
||||
*/
|
||||
@Composable
|
||||
fun PlaceholderScreen(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
description,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search screen placeholder.
|
||||
*/
|
||||
@Composable
|
||||
fun SearchPlaceholder(modifier: Modifier = Modifier) {
|
||||
PlaceholderScreen(
|
||||
title = "Search",
|
||||
description = "Search for users, notes, and hashtags.",
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages/DMs screen placeholder.
|
||||
*/
|
||||
@Composable
|
||||
fun MessagesPlaceholder(modifier: Modifier = Modifier) {
|
||||
PlaceholderScreen(
|
||||
title = "Messages",
|
||||
description = "Your encrypted direct messages will appear here.",
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifications screen placeholder.
|
||||
*/
|
||||
@Composable
|
||||
fun NotificationsPlaceholder(modifier: Modifier = Modifier) {
|
||||
PlaceholderScreen(
|
||||
title = "Notifications",
|
||||
description = "Mentions, replies, and reactions will appear here.",
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Primary brand colors
|
||||
val Primary50 = Color(red = 127, green = 103, blue = 190)
|
||||
val Primary60 = Color(red = 154, green = 130, blue = 219)
|
||||
val Primary70 = Color(red = 182, green = 157, blue = 248)
|
||||
val Primary80 = Color(red = 208, green = 188, blue = 255)
|
||||
val DefaultPrimary = Color(red = 208, green = 188, blue = 255)
|
||||
val LightPurple = Color(red = 187, green = 134, blue = 252)
|
||||
|
||||
// Material Design colors
|
||||
val Purple200 = Color(0xFFBB86FC)
|
||||
val Purple500 = Color(0xFF6200EE)
|
||||
val Purple700 = Color(0xFF3700B3)
|
||||
val Teal200 = Color(0xFF03DAC5)
|
||||
|
||||
// Bitcoin colors
|
||||
val BitcoinOrange = Color(0xFFF7931A)
|
||||
val BitcoinDark = Color(0xFFF7931A)
|
||||
val BitcoinLight = Color(0xFFB66605)
|
||||
|
||||
// Status colors
|
||||
val RoyalBlue = Color(0xFF4169E1)
|
||||
val Following = Color(0xFF03DAC5)
|
||||
val FollowsFollow = Color.Yellow
|
||||
val Nip05Verified = Color.Blue
|
||||
|
||||
// NIP-05 email colors
|
||||
val Nip05EmailColor = Color(0xFFb198ec)
|
||||
val Nip05EmailColorDark = Color(0xFF6e5490)
|
||||
val Nip05EmailColorLight = Color(0xFFa770f3)
|
||||
|
||||
// Feedback colors
|
||||
val DarkerGreen = Color.Green.copy(alpha = 0.32f)
|
||||
val LightRedColor = Color(0xFFC62828)
|
||||
val LighterRedColor = Color(0xFFFF0E0E)
|
||||
|
||||
// Warning colors
|
||||
val LightWarningColor = Color(0xFFffcc00)
|
||||
val DarkWarningColor = Color(0xFFF8DE22)
|
||||
|
||||
// Surface variant colors
|
||||
val LightRedColorOnSecondSurface = Color(0xFFC62828)
|
||||
val DarkRedColorOnSecondSurface = Color(0xFFF34747)
|
||||
val LightWarningColorOnSecondSurface = Color(0xFFC09B14)
|
||||
val DarkWarningColorOnSecondSurface = Color(0xFFE1C419)
|
||||
|
||||
// Success colors
|
||||
val LightAllGoodColor = Color(0xFF339900)
|
||||
val DarkAllGoodColor = Color(0xFF99cc33)
|
||||
|
||||
// Fundraiser colors
|
||||
val LightFundraiserProgressColor = Color(0xFF3DB601)
|
||||
val DarkFundraiserProgressColor = Color(0xFF61A229)
|
||||
|
||||
// Relay status colors
|
||||
object RelayStatusColors {
|
||||
val Connected = Color.Green
|
||||
val Connecting = Color.Yellow
|
||||
val Disconnected = Color.Red
|
||||
val Unknown = Color.Gray
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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 kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Formats a count to human-readable format with suffix (K, M, G).
|
||||
* Example: 1500 -> "1K items", 2500000 -> "2M items"
|
||||
*/
|
||||
fun countToHumanReadable(counter: Int, suffix: String): String =
|
||||
when {
|
||||
counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G $suffix"
|
||||
counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M $suffix"
|
||||
counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K $suffix"
|
||||
else -> "$counter $suffix"
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a count to human-readable format with suffix (K, M, G).
|
||||
*/
|
||||
fun countToHumanReadable(counter: Long, suffix: String): String =
|
||||
when {
|
||||
counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G $suffix"
|
||||
counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M $suffix"
|
||||
counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K $suffix"
|
||||
else -> "$counter $suffix"
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a count to human-readable format without suffix.
|
||||
* Example: 1500 -> "1K", 2500000 -> "2M"
|
||||
*/
|
||||
fun countToHumanReadable(counter: Int): String =
|
||||
when {
|
||||
counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G"
|
||||
counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M"
|
||||
counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K"
|
||||
else -> "$counter"
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a count to human-readable format without suffix.
|
||||
*/
|
||||
fun countToHumanReadable(counter: Long): String =
|
||||
when {
|
||||
counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G"
|
||||
counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M"
|
||||
counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K"
|
||||
else -> "$counter"
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats byte count to human-readable format (KB, MB, GB).
|
||||
* Example: 1500 -> "1 KB", 2500000 -> "2 MB"
|
||||
*/
|
||||
fun countToHumanReadableBytes(counter: Int): String =
|
||||
when {
|
||||
counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()} GB"
|
||||
counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()} MB"
|
||||
counter >= 1_000 -> "${(counter / 1_000f).roundToInt()} KB"
|
||||
else -> "$counter B"
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats byte count to human-readable format (KB, MB, GB).
|
||||
*/
|
||||
fun countToHumanReadableBytes(counter: Long): String =
|
||||
when {
|
||||
counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()} GB"
|
||||
counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()} MB"
|
||||
counter >= 1_000 -> "${(counter / 1_000f).roundToInt()} KB"
|
||||
else -> "$counter B"
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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 com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
|
||||
/**
|
||||
* Shortens a hex key or npub for display.
|
||||
* Example: "npub1abcdefghijklmnop..." -> "npub1abc…mnop"
|
||||
*
|
||||
* @return Shortened string with ellipsis in the middle, or original if <= 16 chars
|
||||
*/
|
||||
fun String.toShortDisplay(): String {
|
||||
if (length <= 16) return this
|
||||
return replaceRange(8, length - 8, "…")
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ByteArray to a shortened hex display.
|
||||
*/
|
||||
fun ByteArray.toHexShortDisplay(): String = toHexKey().toShortDisplay()
|
||||
|
||||
/**
|
||||
* Shortens a HexKey for display.
|
||||
*/
|
||||
fun HexKey.toDisplayHexKey(): String = this.toShortDisplay()
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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.account
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
sealed class AccountState {
|
||||
data object LoggedOut : AccountState()
|
||||
|
||||
data class LoggedIn(
|
||||
val signer: NostrSigner,
|
||||
val pubKeyHex: String,
|
||||
val npub: String,
|
||||
val nsec: String?,
|
||||
val isReadOnly: Boolean,
|
||||
) : AccountState()
|
||||
}
|
||||
|
||||
class AccountManager {
|
||||
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
|
||||
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
|
||||
|
||||
fun generateNewAccount(): AccountState.LoggedIn {
|
||||
val keyPair = KeyPair()
|
||||
val signer = NostrSignerInternal(keyPair)
|
||||
|
||||
val state =
|
||||
AccountState.LoggedIn(
|
||||
signer = signer,
|
||||
pubKeyHex = keyPair.pubKey.toHexKey(),
|
||||
npub = keyPair.pubKey.toNpub(),
|
||||
nsec = keyPair.privKey?.toNsec(),
|
||||
isReadOnly = false,
|
||||
)
|
||||
_accountState.value = state
|
||||
return state
|
||||
}
|
||||
|
||||
fun loginWithKey(keyInput: String): Result<AccountState.LoggedIn> {
|
||||
val trimmedInput = keyInput.trim()
|
||||
|
||||
// Try as private key first (nsec or hex)
|
||||
val privKeyHex = decodePrivateKeyAsHexOrNull(trimmedInput)
|
||||
if (privKeyHex != null) {
|
||||
return try {
|
||||
val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray())
|
||||
val signer = NostrSignerInternal(keyPair)
|
||||
|
||||
val state =
|
||||
AccountState.LoggedIn(
|
||||
signer = signer,
|
||||
pubKeyHex = keyPair.pubKey.toHexKey(),
|
||||
npub = keyPair.pubKey.toNpub(),
|
||||
nsec = keyPair.privKey?.toNsec(),
|
||||
isReadOnly = false,
|
||||
)
|
||||
_accountState.value = state
|
||||
Result.success(state)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(IllegalArgumentException("Invalid private key format"))
|
||||
}
|
||||
}
|
||||
|
||||
// Try as public key (npub or hex) - read-only mode
|
||||
val pubKeyHex = decodePublicKeyAsHexOrNull(trimmedInput)
|
||||
if (pubKeyHex != null) {
|
||||
return try {
|
||||
val keyPair = KeyPair(pubKey = pubKeyHex.hexToByteArray())
|
||||
val signer = NostrSignerInternal(keyPair)
|
||||
|
||||
val state =
|
||||
AccountState.LoggedIn(
|
||||
signer = signer,
|
||||
pubKeyHex = keyPair.pubKey.toHexKey(),
|
||||
npub = keyPair.pubKey.toNpub(),
|
||||
nsec = null,
|
||||
isReadOnly = true,
|
||||
)
|
||||
_accountState.value = state
|
||||
Result.success(state)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(IllegalArgumentException("Invalid public key format"))
|
||||
}
|
||||
}
|
||||
|
||||
return Result.failure(IllegalArgumentException("Invalid key format. Use nsec1, npub1, or hex format."))
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
_accountState.value = AccountState.LoggedOut
|
||||
}
|
||||
|
||||
fun isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn
|
||||
|
||||
fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.network
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
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.nip01Core.relay.sockets.WebsocketBuilder
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Manages Nostr relay connections, subscriptions, and status tracking.
|
||||
* Can be used by both Android and Desktop apps.
|
||||
*
|
||||
* @param websocketBuilder Platform-specific websocket builder (e.g., OkHttp-based)
|
||||
*/
|
||||
open class RelayConnectionManager(
|
||||
websocketBuilder: WebsocketBuilder,
|
||||
) : IRelayClientListener {
|
||||
private val client = NostrClient(websocketBuilder)
|
||||
|
||||
private val _relayStatuses = MutableStateFlow<Map<NormalizedRelayUrl, RelayStatus>>(emptyMap())
|
||||
val relayStatuses: StateFlow<Map<NormalizedRelayUrl, RelayStatus>> = _relayStatuses.asStateFlow()
|
||||
|
||||
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = client.connectedRelaysFlow()
|
||||
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = client.availableRelaysFlow()
|
||||
|
||||
init {
|
||||
client.subscribe(this)
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
client.connect()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
client.disconnect()
|
||||
}
|
||||
|
||||
fun addRelay(url: String): NormalizedRelayUrl? {
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null
|
||||
updateRelayStatus(normalized) { it.copy(connected = false, error = null) }
|
||||
return normalized
|
||||
}
|
||||
|
||||
fun removeRelay(url: NormalizedRelayUrl) {
|
||||
_relayStatuses.value = _relayStatuses.value - url
|
||||
}
|
||||
|
||||
fun addDefaultRelays() {
|
||||
DefaultRelays.RELAYS.forEach { addRelay(it) }
|
||||
}
|
||||
|
||||
fun subscribe(
|
||||
subId: String,
|
||||
filters: List<Filter>,
|
||||
relays: Set<NormalizedRelayUrl> = availableRelays.value,
|
||||
listener: IRequestListener? = null,
|
||||
) {
|
||||
val filterMap = relays.associateWith { filters }
|
||||
client.openReqSubscription(subId, filterMap, listener)
|
||||
}
|
||||
|
||||
fun unsubscribe(subId: String) {
|
||||
client.close(subId)
|
||||
}
|
||||
|
||||
fun send(event: Event, relays: Set<NormalizedRelayUrl> = connectedRelays.value) {
|
||||
client.send(event, relays)
|
||||
}
|
||||
|
||||
private fun updateRelayStatus(
|
||||
url: NormalizedRelayUrl,
|
||||
update: (RelayStatus) -> RelayStatus,
|
||||
) {
|
||||
_relayStatuses.value = _relayStatuses.value.toMutableMap().apply {
|
||||
val current = this[url] ?: RelayStatus(url, connected = false)
|
||||
this[url] = update(current)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnecting(relay: IRelayClient) {
|
||||
updateRelayStatus(relay.url) {
|
||||
it.copy(connected = false, error = null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) {
|
||||
updateRelayStatus(relay.url) {
|
||||
it.copy(connected = true, pingMs = pingMillis, compressed = compressed, error = null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
updateRelayStatus(relay.url) {
|
||||
it.copy(connected = false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCannotConnect(relay: IRelayClient, errorMessage: String) {
|
||||
updateRelayStatus(relay.url) {
|
||||
it.copy(connected = false, error = errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) {
|
||||
// Events are handled by subscription listeners
|
||||
}
|
||||
|
||||
override fun onSent(relay: IRelayClient, cmdStr: String, cmd: Command, success: Boolean) {
|
||||
// Command send tracking
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.network
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* Represents the connection status of a Nostr relay.
|
||||
* Used by both Android and Desktop apps.
|
||||
*/
|
||||
data class RelayStatus(
|
||||
val url: NormalizedRelayUrl,
|
||||
val connected: Boolean,
|
||||
val pingMs: Int? = null,
|
||||
val compressed: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Default relay URLs for Nostr connectivity.
|
||||
*/
|
||||
object DefaultRelays {
|
||||
val RELAYS = listOf(
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.nostr.band",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.snort.social",
|
||||
"wss://nostr.wine",
|
||||
)
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.auth
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Text field for entering Nostr keys (nsec or npub) with visibility toggle.
|
||||
*/
|
||||
@Composable
|
||||
fun KeyInputField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
label: String = "nsec or npub",
|
||||
placeholder: String = "nsec1... or npub1...",
|
||||
errorMessage: String? = null,
|
||||
) {
|
||||
var showKey by remember { mutableStateOf(false) }
|
||||
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
placeholder = { Text(placeholder) },
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = if (showKey) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showKey = !showKey }) {
|
||||
Icon(
|
||||
if (showKey) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = if (showKey) "Hide key" else "Show key",
|
||||
)
|
||||
}
|
||||
},
|
||||
isError = errorMessage != null,
|
||||
supportingText = errorMessage?.let {
|
||||
{ Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Card displaying a Nostr key that users can select/copy.
|
||||
*/
|
||||
@Composable
|
||||
fun SelectableKeyText(
|
||||
key: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = key,
|
||||
modifier = Modifier.padding(12.dp).fillMaxWidth(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Login card with Nostr key input field and action buttons.
|
||||
*
|
||||
* @param onLogin Callback when login is attempted with the key input
|
||||
* @param onGenerateNew Callback when "Generate New" is clicked
|
||||
* @param modifier Modifier for the card
|
||||
* @param cardWidth Width of the card (default 400.dp)
|
||||
* @param title Card title
|
||||
* @param subtitle Subtitle/hint text
|
||||
*/
|
||||
@Composable
|
||||
fun LoginCard(
|
||||
onLogin: (String) -> Result<Unit>,
|
||||
onGenerateNew: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
cardWidth: Dp = 400.dp,
|
||||
title: String = "Login with your Nostr key",
|
||||
subtitle: String = "Use nsec for full access or npub for read-only",
|
||||
) {
|
||||
var keyInput by remember { mutableStateOf("") }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Card(
|
||||
modifier = modifier.width(cardWidth),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
KeyInputField(
|
||||
value = keyInput,
|
||||
onValueChange = {
|
||||
keyInput = it
|
||||
errorMessage = null
|
||||
},
|
||||
errorMessage = errorMessage,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
onLogin(keyInput).fold(
|
||||
onSuccess = { /* handled by caller */ },
|
||||
onFailure = { errorMessage = it.message },
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = keyInput.isNotBlank(),
|
||||
) {
|
||||
Text("Login")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onGenerateNew,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Generate New")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Warning card displayed after generating a new Nostr key pair.
|
||||
* Reminds users to save their keys and shows both public and secret keys.
|
||||
*
|
||||
* @param npub The public key in npub format
|
||||
* @param nsec The secret key in nsec format (nullable for read-only accounts)
|
||||
* @param onContinue Callback when user acknowledges they've saved their keys
|
||||
* @param modifier Modifier for the card
|
||||
* @param cardWidth Width of the card (default 500.dp)
|
||||
*/
|
||||
@Composable
|
||||
fun NewKeyWarningCard(
|
||||
npub: String,
|
||||
nsec: String?,
|
||||
onContinue: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
cardWidth: Dp = 500.dp,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.width(cardWidth),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Text(
|
||||
"IMPORTANT: Save your keys!",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color.Red,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
"Your secret key (nsec) is the ONLY way to access your account. " +
|
||||
"If you lose it, your account is gone forever. Save it somewhere safe!",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
"Public Key (shareable):",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
SelectableKeyText(npub)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
nsec?.let { secretKey ->
|
||||
Text(
|
||||
"Secret Key (NEVER share this!):",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.Red,
|
||||
)
|
||||
SelectableKeyText(secretKey)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = onContinue,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("I've saved my keys, continue")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.note
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||
|
||||
/**
|
||||
* Data class for displaying a note card.
|
||||
*/
|
||||
data class NoteDisplayData(
|
||||
val id: String,
|
||||
val pubKeyDisplay: String,
|
||||
val content: String,
|
||||
val createdAt: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Reusable note card composable that displays a Nostr note.
|
||||
* Can be used by both Desktop and Android apps.
|
||||
*/
|
||||
@Composable
|
||||
fun NoteCard(
|
||||
note: NoteDisplayData,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val richTextParser = remember { RichTextParser() }
|
||||
val urls = remember(note.content) { richTextParser.parseValidUrls(note.content) }
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
onClick = onClick ?: {},
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Author (truncated)
|
||||
Text(
|
||||
text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
)
|
||||
|
||||
// Timestamp
|
||||
Text(
|
||||
text = note.createdAt.toTimeAgo(withDot = false),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
RichTextContent(
|
||||
content = note.content,
|
||||
urls = urls,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f))
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// Event ID (truncated)
|
||||
Text(
|
||||
text = "ID: ${note.id.take(12)}...",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders text content with highlighted URLs.
|
||||
* Uses RichTextParser from commons to detect and highlight links.
|
||||
*/
|
||||
@Composable
|
||||
fun RichTextContent(
|
||||
content: String,
|
||||
urls: Set<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
maxLines: Int = 10,
|
||||
) {
|
||||
if (urls.isEmpty()) {
|
||||
Text(
|
||||
text = content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
val annotatedText =
|
||||
buildAnnotatedString {
|
||||
var lastIndex = 0
|
||||
val sortedUrls = urls.sortedBy { content.indexOf(it) }
|
||||
|
||||
for (url in sortedUrls) {
|
||||
val startIndex = content.indexOf(url, lastIndex)
|
||||
if (startIndex == -1) continue
|
||||
|
||||
// Add text before URL
|
||||
if (startIndex > lastIndex) {
|
||||
append(content.substring(lastIndex, startIndex))
|
||||
}
|
||||
|
||||
// Add URL with styling
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
) {
|
||||
append(url)
|
||||
}
|
||||
|
||||
lastIndex = startIndex + url.length
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < content.length) {
|
||||
append(content.substring(lastIndex))
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = annotatedText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.profile
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Card displaying Nostr account key information.
|
||||
* Shows public key (npub), hex format, and optional read-only indicator.
|
||||
*/
|
||||
@Composable
|
||||
fun ProfileInfoCard(
|
||||
npub: String,
|
||||
pubKeyHex: String,
|
||||
isReadOnly: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
if (isReadOnly) {
|
||||
Text(
|
||||
"Read-only mode",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.Yellow
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
"Public Key",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
npub,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
"Hex",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
pubKeyHex,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.relay
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.network.RelayStatus
|
||||
|
||||
/**
|
||||
* Card displaying the status of a Nostr relay connection.
|
||||
* Shows connection status, ping time, compression, and errors.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayStatusCard(
|
||||
status: RelayStatus,
|
||||
onRemove: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
val statusColor = when {
|
||||
status.connected -> Color.Green
|
||||
status.error != null -> Color.Red
|
||||
else -> Color.Gray
|
||||
}
|
||||
|
||||
if (status.connected) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = "Connected",
|
||||
tint = statusColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
} else if (status.error != null) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Error",
|
||||
tint = statusColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
Text(
|
||||
status.url.url,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
if (status.connected && status.pingMs != null) {
|
||||
Text(
|
||||
"${status.pingMs}ms${if (status.compressed) " • compressed" else ""}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
status.error?.let { error ->
|
||||
Text(
|
||||
error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Red.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove relay",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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 com.vitorpamplona.amethyst.commons.ui.note.NoteDisplayData
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
|
||||
/**
|
||||
* Extension to convert Event to NoteDisplayData for the shared NoteCard.
|
||||
*/
|
||||
fun Event.toNoteDisplayData(): NoteDisplayData {
|
||||
val npub = try {
|
||||
pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..."
|
||||
} catch (e: Exception) {
|
||||
pubKey.take(16) + "..."
|
||||
}
|
||||
|
||||
return NoteDisplayData(
|
||||
id = id,
|
||||
pubKeyDisplay = npub,
|
||||
content = content,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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 com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
private const val YEAR_DATE_FORMAT = "MMM dd, yyyy"
|
||||
private const val MONTH_DATE_FORMAT = "MMM dd"
|
||||
|
||||
private var locale = Locale.getDefault()
|
||||
private var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
|
||||
private var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
|
||||
|
||||
private fun updateFormattersIfNeeded() {
|
||||
if (locale != Locale.getDefault()) {
|
||||
locale = Locale.getDefault()
|
||||
yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale)
|
||||
monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a Unix timestamp (seconds) as a human-readable time ago string.
|
||||
* Returns strings like " • 5m", " • 2h", " • Dec 12"
|
||||
*
|
||||
* @param time Unix timestamp in seconds, or null
|
||||
* @param withDot Whether to prefix with " • " (default true)
|
||||
* @return Formatted time ago string
|
||||
*/
|
||||
fun timeAgo(
|
||||
time: Long?,
|
||||
withDot: Boolean = true,
|
||||
): String {
|
||||
if (time == null) return " "
|
||||
if (time == 0L) return if (withDot) " • never" else "never"
|
||||
|
||||
val timeDifference = TimeUtils.now() - time
|
||||
val prefix = if (withDot) " • " else ""
|
||||
|
||||
return when {
|
||||
timeDifference > TimeUtils.ONE_YEAR -> {
|
||||
updateFormattersIfNeeded()
|
||||
prefix + yearFormatter.format(time * 1000)
|
||||
}
|
||||
timeDifference > TimeUtils.ONE_MONTH -> {
|
||||
updateFormattersIfNeeded()
|
||||
prefix + monthFormatter.format(time * 1000)
|
||||
}
|
||||
timeDifference > TimeUtils.ONE_DAY -> {
|
||||
prefix + (timeDifference / TimeUtils.ONE_DAY).toString() + "d"
|
||||
}
|
||||
timeDifference > TimeUtils.ONE_HOUR -> {
|
||||
prefix + (timeDifference / TimeUtils.ONE_HOUR).toString() + "h"
|
||||
}
|
||||
timeDifference > TimeUtils.ONE_MINUTE -> {
|
||||
prefix + (timeDifference / TimeUtils.ONE_MINUTE).toString() + "m"
|
||||
}
|
||||
else -> {
|
||||
prefix + "now"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a Unix timestamp as a date string.
|
||||
* For recent dates (< 1 day), returns the provided today string.
|
||||
*
|
||||
* @param time Unix timestamp in seconds
|
||||
* @param never String to show for time == 0
|
||||
* @param today String to show for today's date
|
||||
*/
|
||||
fun dateFormatter(
|
||||
time: Long?,
|
||||
never: String = "never",
|
||||
today: String = "today",
|
||||
): String {
|
||||
if (time == null) return " "
|
||||
if (time == 0L) return " $never"
|
||||
|
||||
val timeDifference = TimeUtils.now() - time
|
||||
|
||||
return when {
|
||||
timeDifference > TimeUtils.ONE_YEAR -> {
|
||||
updateFormattersIfNeeded()
|
||||
yearFormatter.format(time * 1000)
|
||||
}
|
||||
timeDifference > TimeUtils.ONE_DAY -> {
|
||||
updateFormattersIfNeeded()
|
||||
monthFormatter.format(time * 1000)
|
||||
}
|
||||
else -> today
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function for Long timestamps.
|
||||
*/
|
||||
fun Long.toTimeAgo(withDot: Boolean = true): String = timeAgo(this, withDot)
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.text.DecimalFormat
|
||||
|
||||
private val TenGiga = BigDecimal(10_000_000_000)
|
||||
private val OneGiga = BigDecimal(1_000_000_000)
|
||||
private val TenMega = BigDecimal(10_000_000)
|
||||
private val OneMega = BigDecimal(1_000_000)
|
||||
private val TenKilo = BigDecimal(10_000)
|
||||
private val OneKilo = BigDecimal(1_000)
|
||||
|
||||
private val dfGBig = ThreadLocal.withInitial { DecimalFormat("#.#G") }
|
||||
private val dfGSmall = ThreadLocal.withInitial { DecimalFormat("#.0G") }
|
||||
private val dfMBig = ThreadLocal.withInitial { DecimalFormat("#.#M") }
|
||||
private val dfMSmall = ThreadLocal.withInitial { DecimalFormat("#.0M") }
|
||||
private val dfK = ThreadLocal.withInitial { DecimalFormat("#.#k") }
|
||||
private val dfN = ThreadLocal.withInitial { DecimalFormat("#") }
|
||||
|
||||
/**
|
||||
* Formats a BigDecimal amount to human-readable format with G/M/K suffixes.
|
||||
* Returns empty string for null or very small amounts.
|
||||
*
|
||||
* Examples:
|
||||
* - 1500 -> "1.5k"
|
||||
* - 2500000 -> "2.5M"
|
||||
* - 10000000000 -> "10G"
|
||||
*/
|
||||
fun showAmount(amount: BigDecimal?): String {
|
||||
if (amount == null) return ""
|
||||
if (amount.abs() < BigDecimal(0.01)) return ""
|
||||
|
||||
return when {
|
||||
amount >= TenGiga -> dfGBig.get()!!.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP))
|
||||
amount >= OneGiga -> dfGSmall.get()!!.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP))
|
||||
amount >= TenMega -> dfMBig.get()!!.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP))
|
||||
amount >= OneMega -> dfMSmall.get()!!.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP))
|
||||
amount >= TenKilo -> dfK.get()!!.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP))
|
||||
else -> dfN.get()!!.format(amount)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a BigDecimal amount to human-readable format.
|
||||
* Returns "0" for null or very small amounts instead of empty string.
|
||||
*/
|
||||
fun showAmountWithZero(amount: BigDecimal?): String {
|
||||
if (amount == null) return "0"
|
||||
if (amount.abs() < BigDecimal(0.01)) return "0"
|
||||
return showAmount(amount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to format Long as zap amount.
|
||||
*/
|
||||
fun Long.toZapAmount(): String = showAmount(BigDecimal(this))
|
||||
|
||||
/**
|
||||
* Extension function to format Int as zap amount.
|
||||
*/
|
||||
fun Int.toZapAmount(): String = showAmount(BigDecimal(this))
|
||||
Reference in New Issue
Block a user