feat(login): QR scan + watch-only mode for npub/nprofile
- QR scan button on the Nostr login sheet (eye icon first, QR second)
scans nsec/npub/nprofile and auto-logs in; scanner rendered at
Activity level so CameraX binds to the correct lifecycle owner
- nprofile1 login support: decodes TLV, extracts pubkey, saves as
READ_ONLY alongside existing npub and hex-pubkey paths
- Watch-only onboarding: READ_ONLY logins skip the profile-setup flow
and go to a two-step WatchOnlyOnboardingScreen (info + relay-wait
spinner) before landing on the feed
- LocalCanSign CompositionLocal propagates signing capability through
the entire nav tree; consumed by:
- PostCard: hides full ActionBar (reply/react/repost/zap)
- FeedScreen: hides compose FAB; reply callback is no-op
- WispBottomBar: filters out Messages and Wallet tabs
- WispDrawerContent: hides Messages and Wallet rows
- UserProfileScreen: hides edit-profile, follow, zap, DM buttons
- Sidebar account picker shows eye icon next to watch-only accounts
- Keys screen branches: READ_ONLY shows "No private key is stored on
this device" instead of the reveal button; LOCAL shows QR and copy
buttons for both npub and nsec (nsec QR requires biometric reveal
first; user avatar overlaid on both QR codes when available)
- Logout dialog swaps private-key backup warning for
"Sign back in with your npub anytime" on watch-only accounts
This commit is contained in:
@@ -80,6 +80,7 @@ import com.wisp.app.ui.screen.KeysScreen
|
||||
import com.wisp.app.ui.screen.ListScreen
|
||||
import com.wisp.app.ui.screen.LiveStreamScreen
|
||||
import com.wisp.app.ui.screen.ExistingUserOnboardingScreen
|
||||
import com.wisp.app.ui.screen.WatchOnlyOnboardingScreen
|
||||
import com.wisp.app.ui.screen.LoadingScreen
|
||||
import com.wisp.app.ui.screen.ListsHubScreen
|
||||
import com.wisp.app.ui.screen.InterfaceScreen
|
||||
@@ -125,6 +126,8 @@ import com.wisp.app.viewmodel.LiveStreamViewModel
|
||||
import com.wisp.app.viewmodel.WalletViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import com.wisp.app.ui.util.LocalCanSign
|
||||
|
||||
object Routes {
|
||||
const val SPLASH = "splash"
|
||||
@@ -163,6 +166,7 @@ object Routes {
|
||||
const val HASHTAG_FEED = "hashtag/{tag}"
|
||||
const val HASHTAG_SET_FEED = "hashtag_set/{name}/{tags}"
|
||||
const val EXISTING_USER_ONBOARDING = "onboarding/existing"
|
||||
const val WATCH_ONLY_ONBOARDING = "onboarding/watch-only"
|
||||
const val DRAFTS = "drafts"
|
||||
const val SOCIAL_GRAPH = "social_graph"
|
||||
const val POW_SETTINGS = "pow_settings"
|
||||
@@ -380,7 +384,7 @@ fun WispNavHost(
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
val nonAppRoutes = setOf(Routes.SPLASH, Routes.AUTH, Routes.GOOGLE_AUTH, Routes.LOADING, Routes.ONBOARDING_PROFILE, Routes.ONBOARDING_SUGGESTIONS, Routes.ONBOARDING_TOPICS, Routes.ONBOARDING_FIRST_POST, Routes.EXISTING_USER_ONBOARDING)
|
||||
val nonAppRoutes = setOf(Routes.SPLASH, Routes.AUTH, Routes.GOOGLE_AUTH, Routes.LOADING, Routes.ONBOARDING_PROFILE, Routes.ONBOARDING_SUGGESTIONS, Routes.ONBOARDING_TOPICS, Routes.ONBOARDING_FIRST_POST, Routes.EXISTING_USER_ONBOARDING, Routes.WATCH_ONLY_ONBOARDING)
|
||||
val hideBottomBarRoutes = nonAppRoutes + Routes.DM_CONVERSATION + Routes.DM_CONVERSATION_GROUP + Routes.CONTACT_PICKER + Routes.GROUP_ROOM + Routes.GROUP_DETAIL + Routes.LIVE_STREAM
|
||||
val socialGraphDiscoveryState by feedViewModel.extendedNetworkRepo.discoveryState.collectAsState()
|
||||
val socialGraphComputing = currentRoute == Routes.SOCIAL_GRAPH && (
|
||||
@@ -589,6 +593,7 @@ fun WispNavHost(
|
||||
isZapAnimating = isZapAnimating,
|
||||
isReplyAnimating = isReplyAnimating,
|
||||
notifSoundEnabled = notifSoundEnabled,
|
||||
isReadOnly = signingMode == SigningMode.READ_ONLY,
|
||||
onTabSelected = { tab ->
|
||||
if (currentRoute == tab.route) {
|
||||
scrollToTopTrigger++
|
||||
@@ -614,6 +619,7 @@ fun WispNavHost(
|
||||
var pipFullScreenAspectRatio by remember { mutableStateOf(16f / 9f) }
|
||||
|
||||
Box(modifier = Modifier.padding(innerPadding)) {
|
||||
CompositionLocalProvider(LocalCanSign provides (signingMode != SigningMode.READ_ONLY)) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination
|
||||
@@ -719,7 +725,7 @@ fun WispNavHost(
|
||||
relayViewModel.reload()
|
||||
feedViewModel.initRelays()
|
||||
authViewModel.keyRepo.markOnboardingComplete()
|
||||
navController.navigate(Routes.LOADING) {
|
||||
navController.navigate(Routes.WATCH_ONLY_ONBOARDING) {
|
||||
popUpTo(Routes.AUTH) { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
@@ -778,19 +784,32 @@ fun WispNavHost(
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.WATCH_ONLY_ONBOARDING) {
|
||||
WatchOnlyOnboardingScreen(
|
||||
feedViewModel = feedViewModel,
|
||||
onReady = {
|
||||
navController.navigate(Routes.FEED) {
|
||||
popUpTo(Routes.WATCH_ONLY_ONBOARDING) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.FEED) {
|
||||
FeedScreen(
|
||||
viewModel = feedViewModel,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onToggleTheme = onToggleTheme,
|
||||
scrollToTopTrigger = scrollToTopTrigger,
|
||||
onCompose = {
|
||||
replyTarget = null
|
||||
quoteTarget = null
|
||||
composeViewModel.clear()
|
||||
navController.navigate(Routes.COMPOSE)
|
||||
onCompose = if (signingMode == SigningMode.READ_ONLY) null else {
|
||||
{
|
||||
replyTarget = null
|
||||
quoteTarget = null
|
||||
composeViewModel.clear()
|
||||
navController.navigate(Routes.COMPOSE)
|
||||
}
|
||||
},
|
||||
onReply = { event ->
|
||||
onReply = if (signingMode == SigningMode.READ_ONLY) { _ -> } else { event ->
|
||||
replyTarget = event
|
||||
quoteTarget = null
|
||||
composeViewModel.clear()
|
||||
@@ -2790,9 +2809,12 @@ fun WispNavHost(
|
||||
}
|
||||
|
||||
composable(Routes.KEYS) {
|
||||
val pubkeyHex = authViewModel.keyRepo.getPubkeyHex()
|
||||
val avatarUrl = pubkeyHex?.let { feedViewModel.eventRepo.getProfileData(it)?.picture }
|
||||
KeysScreen(
|
||||
keyRepository = authViewModel.keyRepo,
|
||||
onBack = { navController.popBackStack() }
|
||||
onBack = { navController.popBackStack() },
|
||||
avatarUrl = avatarUrl
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3405,6 +3427,7 @@ fun WispNavHost(
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
} // CompositionLocalProvider
|
||||
} // Box
|
||||
|
||||
} // Scaffold
|
||||
|
||||
@@ -59,8 +59,14 @@ fun WispBottomBar(
|
||||
isZapAnimating: Boolean = false,
|
||||
isReplyAnimating: Boolean = false,
|
||||
notifSoundEnabled: Boolean = true,
|
||||
isReadOnly: Boolean = false,
|
||||
onTabSelected: (BottomTab) -> Unit
|
||||
) {
|
||||
val visibleTabs = if (isReadOnly)
|
||||
BottomTab.entries.filter { it != BottomTab.WALLET && it != BottomTab.MESSAGES }
|
||||
else
|
||||
BottomTab.entries
|
||||
|
||||
Column {
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
@@ -70,7 +76,7 @@ fun WispBottomBar(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
windowInsets = NavigationBarDefaults.windowInsets
|
||||
) {
|
||||
BottomTab.entries.forEach { tab ->
|
||||
visibleTabs.forEach { tab ->
|
||||
val selected = currentRoute == tab.route
|
||||
val hasUnread = when (tab) {
|
||||
BottomTab.HOME -> hasUnreadHome
|
||||
|
||||
@@ -90,6 +90,7 @@ import com.wisp.app.repo.Nip05Status
|
||||
import com.wisp.app.repo.TranslationState
|
||||
import com.wisp.app.repo.TranslationStatus
|
||||
import com.wisp.app.ui.theme.WispThemeColors
|
||||
import com.wisp.app.ui.util.LocalCanSign
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
@@ -788,42 +789,45 @@ fun PostCard(
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ActionBar(
|
||||
onReply = onReply,
|
||||
onReact = onReact,
|
||||
userReactionEmojis = userReactionEmojis,
|
||||
onRepost = onRepost,
|
||||
onQuote = onQuote,
|
||||
hasUserReposted = hasUserReposted,
|
||||
repostCount = repostCount,
|
||||
onZap = onZap,
|
||||
hasUserZapped = hasUserZapped,
|
||||
onAddToList = onAddToList,
|
||||
isInList = isInList,
|
||||
likeCount = likeCount,
|
||||
replyCount = replyCount,
|
||||
zapSats = zapSats,
|
||||
isZapAnimating = isZapAnimating,
|
||||
isZapInProgress = isZapInProgress,
|
||||
reactionEmojiUrls = reactionEmojiUrls,
|
||||
resolvedEmojis = resolvedEmojis,
|
||||
unicodeEmojis = unicodeEmojis,
|
||||
onOpenEmojiLibrary = onOpenEmojiLibrary,
|
||||
isPrivate = isPrivate,
|
||||
zapEnabled = zapEnabled,
|
||||
onZapDisabledTap = onZapDisabledTap,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (expandedDetails) Icons.Filled.KeyboardArrowUp
|
||||
else Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = if (expandedDetails) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clickable { expandedDetails = !expandedDetails }
|
||||
)
|
||||
val canSign = LocalCanSign.current
|
||||
if (canSign) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ActionBar(
|
||||
onReply = onReply,
|
||||
onReact = onReact,
|
||||
userReactionEmojis = userReactionEmojis,
|
||||
onRepost = onRepost,
|
||||
onQuote = onQuote,
|
||||
hasUserReposted = hasUserReposted,
|
||||
repostCount = repostCount,
|
||||
onZap = onZap,
|
||||
hasUserZapped = hasUserZapped,
|
||||
onAddToList = onAddToList,
|
||||
isInList = isInList,
|
||||
likeCount = likeCount,
|
||||
replyCount = replyCount,
|
||||
zapSats = zapSats,
|
||||
isZapAnimating = isZapAnimating,
|
||||
isZapInProgress = isZapInProgress,
|
||||
reactionEmojiUrls = reactionEmojiUrls,
|
||||
resolvedEmojis = resolvedEmojis,
|
||||
unicodeEmojis = unicodeEmojis,
|
||||
onOpenEmojiLibrary = onOpenEmojiLibrary,
|
||||
isPrivate = isPrivate,
|
||||
zapEnabled = zapEnabled,
|
||||
onZapDisabledTap = onZapDisabledTap,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (expandedDetails) Icons.Filled.KeyboardArrowUp
|
||||
else Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = if (expandedDetails) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clickable { expandedDetails = !expandedDetails }
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = expandedDetails,
|
||||
|
||||
@@ -45,6 +45,8 @@ import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Shield
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.Visibility
|
||||
import com.wisp.app.repo.SigningMode
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -74,6 +76,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.wisp.app.nostr.Nip05
|
||||
import com.wisp.app.nostr.ProfileData
|
||||
import com.wisp.app.repo.AccountInfo
|
||||
import com.wisp.app.ui.util.LocalCanSign
|
||||
|
||||
|
||||
@Composable
|
||||
@@ -114,6 +117,7 @@ fun WispDrawerContent(
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val canSign = LocalCanSign.current
|
||||
Column(modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.statusBarsPadding()
|
||||
@@ -310,6 +314,15 @@ fun WispDrawerContent(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (account.signingMode == SigningMode.READ_ONLY) {
|
||||
Icon(
|
||||
Icons.Outlined.Visibility,
|
||||
contentDescription = "Watch-only",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
if (isActive) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
@@ -389,34 +402,38 @@ fun WispDrawerContent(
|
||||
onClick = onSearch,
|
||||
modifier = Modifier.height(48.dp).padding(horizontal = 12.dp)
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Outlined.Email, contentDescription = null) },
|
||||
label = { Text(stringResource(R.string.nav_messages)) },
|
||||
selected = false,
|
||||
onClick = onMessages,
|
||||
modifier = Modifier.height(48.dp).padding(horizontal = 12.dp)
|
||||
)
|
||||
if (canSign) {
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Outlined.Email, contentDescription = null) },
|
||||
label = { Text(stringResource(R.string.nav_messages)) },
|
||||
selected = false,
|
||||
onClick = onMessages,
|
||||
modifier = Modifier.height(48.dp).padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
val useZapBolt = com.wisp.app.ui.util.useBoltIcon()
|
||||
val fiatMode = com.wisp.app.ui.util.isFiatMode()
|
||||
NavigationDrawerItem(
|
||||
icon = {
|
||||
if (fiatMode) {
|
||||
Icon(Icons.Outlined.AccountBalanceWallet, contentDescription = null)
|
||||
} else if (useZapBolt) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_bolt),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.Outlined.CurrencyBitcoin, contentDescription = null)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.nav_wallet)) },
|
||||
selected = false,
|
||||
onClick = onWallet,
|
||||
modifier = Modifier.height(48.dp).padding(horizontal = 12.dp)
|
||||
)
|
||||
if (canSign) {
|
||||
NavigationDrawerItem(
|
||||
icon = {
|
||||
if (fiatMode) {
|
||||
Icon(Icons.Outlined.AccountBalanceWallet, contentDescription = null)
|
||||
} else if (useZapBolt) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_bolt),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.Outlined.CurrencyBitcoin, contentDescription = null)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.nav_wallet)) },
|
||||
selected = false,
|
||||
onClick = onWallet,
|
||||
modifier = Modifier.height(48.dp).padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(Icons.Outlined.FormatListBulleted, contentDescription = null) },
|
||||
label = { Text(stringResource(R.string.drawer_lists)) },
|
||||
@@ -555,19 +572,23 @@ fun WispDrawerContent(
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Icon(
|
||||
Icons.Outlined.Key,
|
||||
if (canSign) Icons.Outlined.Key else Icons.Outlined.Visibility,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
tint = if (canSign) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
"Back up your private key before logging out. Without it, your Nostr account cannot be recovered.",
|
||||
if (canSign)
|
||||
"Back up your private key before logging out. Without it, your Nostr account cannot be recovered."
|
||||
else
|
||||
"Sign back in with your npub anytime.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
if (hasEmbeddedWallet) {
|
||||
if (canSign && hasEmbeddedWallet) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Icon(
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
import androidx.compose.material.icons.outlined.Visibility
|
||||
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
@@ -31,6 +32,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.wisp.app.ui.component.QrScanner
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -68,6 +70,20 @@ fun AuthScreen(
|
||||
val nsecInput by viewModel.nsecInput.collectAsState()
|
||||
val error by viewModel.error.collectAsState()
|
||||
var nsecVisible by remember { mutableStateOf(false) }
|
||||
var showQrScanner by remember { mutableStateOf(false) }
|
||||
|
||||
if (showQrScanner) {
|
||||
QrScanner(
|
||||
onResult = { raw ->
|
||||
showQrScanner = false
|
||||
viewModel.updateNsecInput(raw.trim())
|
||||
if (viewModel.logIn()) onAuthenticated(false)
|
||||
},
|
||||
modifier = androidx.compose.ui.Modifier.fillMaxSize(),
|
||||
promptText = "Scan nsec, npub, or nprofile QR"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -147,11 +163,19 @@ fun AuthScreen(
|
||||
visualTransformation = if (nsecVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { nsecVisible = !nsecVisible }) {
|
||||
Icon(
|
||||
imageVector = if (nsecVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||
contentDescription = if (nsecVisible) stringResource(R.string.auth_hide_key) else stringResource(R.string.auth_show_key)
|
||||
)
|
||||
Row {
|
||||
IconButton(onClick = { nsecVisible = !nsecVisible }) {
|
||||
Icon(
|
||||
imageVector = if (nsecVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||
contentDescription = if (nsecVisible) stringResource(R.string.auth_hide_key) else stringResource(R.string.auth_show_key)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showQrScanner = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.QrCodeScanner,
|
||||
contentDescription = "Scan QR code"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
|
||||
@@ -149,7 +149,7 @@ fun FeedScreen(
|
||||
viewModel: FeedViewModel,
|
||||
isDarkTheme: Boolean = true,
|
||||
onToggleTheme: () -> Unit = {},
|
||||
onCompose: () -> Unit,
|
||||
onCompose: (() -> Unit)? = null,
|
||||
onReply: (NostrEvent) -> Unit,
|
||||
onRelays: () -> Unit,
|
||||
onProfileEdit: () -> Unit = {},
|
||||
@@ -993,20 +993,22 @@ fun FeedScreen(
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
val isScrolling = listState.isScrollInProgress
|
||||
val fabAlpha by animateFloatAsState(
|
||||
targetValue = if (isScrolling) 0.3f else 1f,
|
||||
animationSpec = tween(
|
||||
durationMillis = if (isScrolling) 150 else 400
|
||||
),
|
||||
label = "fabAlpha"
|
||||
)
|
||||
FloatingActionButton(
|
||||
onClick = onCompose,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.graphicsLayer { alpha = fabAlpha }
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "New post")
|
||||
if (onCompose != null) {
|
||||
val isScrolling = listState.isScrollInProgress
|
||||
val fabAlpha by animateFloatAsState(
|
||||
targetValue = if (isScrolling) 0.3f else 1f,
|
||||
animationSpec = tween(
|
||||
durationMillis = if (isScrolling) 150 else 400
|
||||
),
|
||||
label = "fabAlpha"
|
||||
)
|
||||
FloatingActionButton(
|
||||
onClick = onCompose,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.graphicsLayer { alpha = fabAlpha }
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "New post")
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
|
||||
@@ -15,10 +15,21 @@ 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 android.graphics.Bitmap
|
||||
import android.graphics.Color as AndroidColor
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import coil3.compose.AsyncImage
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.ContentCopy
|
||||
import androidx.compose.material.icons.outlined.QrCode
|
||||
import androidx.compose.material.icons.outlined.Visibility
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -27,7 +38,17 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -48,6 +69,7 @@ import com.wisp.app.R
|
||||
import com.wisp.app.nostr.Nip19
|
||||
import com.wisp.app.nostr.hexToByteArray
|
||||
import com.wisp.app.repo.KeyRepository
|
||||
import com.wisp.app.ui.component.QrCodeDialog
|
||||
|
||||
private fun android.content.Context.findFragmentActivity(): FragmentActivity? {
|
||||
var ctx = this
|
||||
@@ -62,7 +84,8 @@ private fun android.content.Context.findFragmentActivity(): FragmentActivity? {
|
||||
@Composable
|
||||
fun KeysScreen(
|
||||
keyRepository: KeyRepository,
|
||||
onBack: () -> Unit
|
||||
onBack: () -> Unit,
|
||||
avatarUrl: String? = null
|
||||
) {
|
||||
val pubkeyHex = remember { keyRepository.getPubkeyHex() }
|
||||
val keypair = remember { keyRepository.getKeypair() }
|
||||
@@ -71,6 +94,7 @@ fun KeysScreen(
|
||||
?: keypair?.let { Nip19.npubEncode(it.pubkey) }
|
||||
}
|
||||
var nsec by remember { mutableStateOf<String?>(null) }
|
||||
var showNpubQr by remember { mutableStateOf(false) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -127,7 +151,14 @@ fun KeysScreen(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
IconButton(onClick = { if (pubkeyHex != null) showNpubQr = true }) {
|
||||
Icon(
|
||||
Icons.Outlined.QrCode,
|
||||
contentDescription = "Show QR code",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
npub?.let {
|
||||
clipboardManager.setText(AnnotatedString(it))
|
||||
@@ -143,15 +174,38 @@ fun KeysScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (showNpubQr && pubkeyHex != null) {
|
||||
QrCodeDialog(
|
||||
pubkeyHex = pubkeyHex,
|
||||
avatarUrl = avatarUrl,
|
||||
onDismiss = { showNpubQr = false }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
PrivateKeySection(
|
||||
nsec = nsec,
|
||||
onReveal = { nsec = it },
|
||||
keypair = keypair,
|
||||
revealPrivateKeyTitle = revealPrivateKeyTitle,
|
||||
revealPrivateKeyDescription = revealPrivateKeyDescription
|
||||
)
|
||||
if (keyRepository.isReadOnly()) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_private_key),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "No private key is stored on this device.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
PrivateKeySection(
|
||||
nsec = nsec,
|
||||
onReveal = { nsec = it },
|
||||
keypair = keypair,
|
||||
revealPrivateKeyTitle = revealPrivateKeyTitle,
|
||||
revealPrivateKeyDescription = revealPrivateKeyDescription,
|
||||
avatarUrl = avatarUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,7 +216,8 @@ private fun PrivateKeySection(
|
||||
onReveal: (String) -> Unit,
|
||||
keypair: com.wisp.app.nostr.Keys.Keypair?,
|
||||
revealPrivateKeyTitle: String,
|
||||
revealPrivateKeyDescription: String
|
||||
revealPrivateKeyDescription: String,
|
||||
avatarUrl: String? = null
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -173,6 +228,8 @@ private fun PrivateKeySection(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
var showNsecQr by remember { mutableStateOf(false) }
|
||||
|
||||
if (nsec != null) {
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
@@ -188,7 +245,14 @@ private fun PrivateKeySection(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
IconButton(onClick = { showNsecQr = true }) {
|
||||
Icon(
|
||||
Icons.Outlined.QrCode,
|
||||
contentDescription = "Show QR code",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
val clip = ClipData.newPlainText("", nsec)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
@@ -208,6 +272,10 @@ private fun PrivateKeySection(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showNsecQr) {
|
||||
NsecQrDialog(nsec = nsec, avatarUrl = avatarUrl, onDismiss = { showNsecQr = false })
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
@@ -262,3 +330,72 @@ private fun PrivateKeySection(
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NsecQrDialog(nsec: String, avatarUrl: String? = null, onDismiss: () -> Unit) {
|
||||
val qrBitmap = remember(nsec) {
|
||||
val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M)
|
||||
val writer = QRCodeWriter()
|
||||
val size = 512
|
||||
val matrix = writer.encode(nsec, BarcodeFormat.QR_CODE, size, size, hints)
|
||||
val bmp = Bitmap.createBitmap(matrix.width, matrix.height, Bitmap.Config.RGB_565)
|
||||
for (x in 0 until matrix.width)
|
||||
for (y in 0 until matrix.height)
|
||||
bmp.setPixel(x, y, if (matrix[x, y]) AndroidColor.BLACK else AndroidColor.WHITE)
|
||||
bmp
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Private key QR") },
|
||||
text = {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Only show this to devices you trust. Anyone who scans it gains full control of your account.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(240.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(androidx.compose.ui.graphics.Color.White)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Image(
|
||||
bitmap = qrBitmap.asImageBitmap(),
|
||||
contentDescription = "nsec QR code",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.matchParentSize()
|
||||
)
|
||||
if (avatarUrl != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(androidx.compose.ui.graphics.Color.White)
|
||||
.padding(3.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = avatarUrl,
|
||||
contentDescription = "Avatar",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.clip(CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Done") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
import androidx.compose.material.icons.outlined.Visibility
|
||||
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||
import com.wisp.app.ui.component.QrScanner
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
@@ -90,6 +92,7 @@ fun SplashScreen(
|
||||
val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
|
||||
|
||||
var showNostrSheet by remember { mutableStateOf(false) }
|
||||
var showQrScanner by remember { mutableStateOf(false) }
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize().background(backgroundColor)) {
|
||||
val cols = ((maxWidth + AVATAR_GAP) / (AVATAR_SIZE + AVATAR_GAP)).toInt().coerceAtLeast(1)
|
||||
@@ -278,9 +281,40 @@ fun SplashScreen(
|
||||
onLoggedIn = {
|
||||
showNostrSheet = false
|
||||
onLoggedIn()
|
||||
},
|
||||
onScanQr = {
|
||||
showNostrSheet = false
|
||||
showQrScanner = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showQrScanner) {
|
||||
androidx.compose.ui.window.Dialog(
|
||||
onDismissRequest = { showQrScanner = false },
|
||||
properties = androidx.compose.ui.window.DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(androidx.compose.ui.graphics.Color.Black)
|
||||
) {
|
||||
QrScanner(
|
||||
onResult = { raw ->
|
||||
showQrScanner = false
|
||||
authViewModel.updateNsecInput(raw.trim())
|
||||
if (authViewModel.logIn()) onLoggedIn()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
promptText = "Scan nsec, npub, or nprofile QR"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -289,7 +323,8 @@ private fun NostrLoginSheet(
|
||||
authViewModel: AuthViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onAccountCreated: () -> Unit,
|
||||
onLoggedIn: () -> Unit
|
||||
onLoggedIn: () -> Unit,
|
||||
onScanQr: () -> Unit = {}
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val context = LocalContext.current
|
||||
@@ -342,11 +377,19 @@ private fun NostrLoginSheet(
|
||||
visualTransformation = if (nsecVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { nsecVisible = !nsecVisible }) {
|
||||
Icon(
|
||||
imageVector = if (nsecVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||
contentDescription = if (nsecVisible) stringResource(R.string.auth_hide_key) else stringResource(R.string.auth_show_key)
|
||||
)
|
||||
Row {
|
||||
IconButton(onClick = { nsecVisible = !nsecVisible }) {
|
||||
Icon(
|
||||
imageVector = if (nsecVisible) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||
contentDescription = if (nsecVisible) stringResource(R.string.auth_hide_key) else stringResource(R.string.auth_show_key)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onScanQr) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.QrCodeScanner,
|
||||
contentDescription = "Scan QR code"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isCreating,
|
||||
|
||||
@@ -108,6 +108,7 @@ import com.wisp.app.ui.component.ProfileQrSheet
|
||||
import com.wisp.app.ui.component.ProfilePicture
|
||||
import com.wisp.app.ui.component.RichContent
|
||||
import com.wisp.app.ui.component.ZapDialog
|
||||
import com.wisp.app.ui.util.LocalCanSign
|
||||
import com.wisp.app.viewmodel.ProfileSortMode
|
||||
import com.wisp.app.viewmodel.UserProfileViewModel
|
||||
import android.content.ClipData
|
||||
@@ -1137,6 +1138,7 @@ private fun ProfileHeader(
|
||||
isBlocked: Boolean = false
|
||||
) {
|
||||
var fullScreenImageUrl by remember { mutableStateOf<String?>(null) }
|
||||
val canSign = LocalCanSign.current
|
||||
|
||||
if (fullScreenImageUrl != null) {
|
||||
FullScreenImageViewer(
|
||||
@@ -1181,11 +1183,11 @@ private fun ProfileHeader(
|
||||
onClick = profile?.picture?.let { url -> { fullScreenImageUrl = url } }
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (isOwnProfile) {
|
||||
if (canSign && isOwnProfile) {
|
||||
OutlinedButton(onClick = onEditProfile) {
|
||||
Text(stringResource(R.string.profile_edit))
|
||||
}
|
||||
} else {
|
||||
} else if (canSign) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (onSendDm != null) {
|
||||
IconButton(onClick = onSendDm) {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package com.wisp.app.ui.screen
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Visibility
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.wisp.app.viewmodel.FeedViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private enum class WatchOnlyStep { INFO, WAITING }
|
||||
|
||||
@Composable
|
||||
fun WatchOnlyOnboardingScreen(
|
||||
feedViewModel: FeedViewModel,
|
||||
onReady: () -> Unit
|
||||
) {
|
||||
BackHandler { /* disable back during onboarding */ }
|
||||
|
||||
val feed by feedViewModel.feed.collectAsState()
|
||||
val backgroundReady = feed.size >= 5
|
||||
|
||||
var stepIndex by rememberSaveable { mutableIntStateOf(0) }
|
||||
val currentStep = WatchOnlyStep.entries[stepIndex]
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = currentStep,
|
||||
transitionSpec = {
|
||||
fadeIn(animationSpec = tween(300)) togetherWith fadeOut(animationSpec = tween(200))
|
||||
},
|
||||
label = "watch-only-step"
|
||||
) { step ->
|
||||
when (step) {
|
||||
WatchOnlyStep.INFO -> WatchOnlyInfoStep(
|
||||
onContinue = { stepIndex = WatchOnlyStep.WAITING.ordinal }
|
||||
)
|
||||
WatchOnlyStep.WAITING -> WatchOnlyWaitingStep(
|
||||
backgroundReady = backgroundReady,
|
||||
onReady = {
|
||||
feedViewModel.markLoadingComplete()
|
||||
onReady()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchOnlyInfoStep(onContinue: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Visibility,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "Watch-only mode",
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "You're signed in with a public key. You can read posts, follow accounts, and browse the network — but posting, reacting, and zapping require a private key.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 24.sp
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
Button(
|
||||
onClick = onContinue,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Start watching")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchOnlyWaitingStep(
|
||||
backgroundReady: Boolean,
|
||||
onReady: () -> Unit
|
||||
) {
|
||||
var minTimeElapsed by remember { mutableStateOf(false) }
|
||||
var hasNavigated by remember { mutableStateOf(false) }
|
||||
|
||||
val messages = listOf(
|
||||
"Finding relays…",
|
||||
"Mapping the network…",
|
||||
"Loading your feed…",
|
||||
"Almost there…"
|
||||
)
|
||||
var messageIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(1500)
|
||||
minTimeElapsed = true
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(2500)
|
||||
messageIndex = (messageIndex + 1) % messages.size
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(backgroundReady, minTimeElapsed) {
|
||||
if (hasNavigated) return@LaunchedEffect
|
||||
if (backgroundReady && minTimeElapsed) {
|
||||
hasNavigated = true
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(30_000)
|
||||
if (!hasNavigated) {
|
||||
hasNavigated = true
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (backgroundReady) {
|
||||
Text(
|
||||
text = "You're all set",
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Button(onClick = {
|
||||
if (!hasNavigated) {
|
||||
hasNavigated = true
|
||||
onReady()
|
||||
}
|
||||
}) {
|
||||
Text("Let's go")
|
||||
}
|
||||
} else {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "Setting things up…",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
AnimatedContent(
|
||||
targetState = messages[messageIndex],
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
label = "watch-waiting-msg"
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.wisp.app.ui.util
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
|
||||
/** True when the active account has a private key and can sign events. */
|
||||
val LocalCanSign = compositionLocalOf { true }
|
||||
@@ -74,6 +74,7 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
|
||||
return when {
|
||||
input.startsWith("nsec1") -> loginWithNsec(input)
|
||||
input.startsWith("npub1") -> loginWithNpub(input)
|
||||
input.startsWith("nprofile1") -> loginWithNprofile(input)
|
||||
input.length == 64 && input.all { it in '0'..'9' || it in 'a'..'f' } -> loginWithPubkeyHex(input)
|
||||
else -> {
|
||||
_error.value = "Invalid key format — enter an nsec or npub"
|
||||
@@ -116,6 +117,22 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun loginWithNprofile(nprofile: String): Boolean {
|
||||
return try {
|
||||
val profile = Nip19.nprofileDecode(nprofile)
|
||||
keyRepo.savePubkeyReadOnly(profile.pubkey)
|
||||
keyRepo.reloadPrefs(profile.pubkey)
|
||||
_npub.value = Nip19.npubEncode(profile.pubkey.hexToByteArray())
|
||||
_signingMode.value = SigningMode.READ_ONLY
|
||||
_nsecInput.value = ""
|
||||
_error.value = null
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Invalid nprofile: ${e.message}"
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun loginWithPubkeyHex(hex: String): Boolean {
|
||||
return try {
|
||||
keyRepo.savePubkeyReadOnly(hex)
|
||||
|
||||
Reference in New Issue
Block a user