1 Commits

Author SHA1 Message Date
a3ab489d44 feat: migrate to navigation3 (#7)
Reviewed-on: #7
2026-05-31 01:28:09 +00:00
16 changed files with 318 additions and 285 deletions

View File

@@ -19,12 +19,14 @@ kotlin {
androidMain.dependencies { androidMain.dependencies {
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation("su.reya:nostr-sdk-kmp:0.2.3")
implementation("io.coil-kt.coil3:coil-compose:3.4.0") implementation("io.coil-kt.coil3:coil-compose:3.4.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
implementation("su.reya:nostr-sdk-kmp:0.2.3")
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0") implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
implementation("io.github.alexzhirkevich:qrose:1.1.2") implementation("io.github.alexzhirkevich:qrose:1.1.2")
} }

View File

@@ -1,5 +1,9 @@
package su.reya.coop package su.reya.coop
import android.app.Activity
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -29,8 +33,10 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -39,12 +45,14 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.core.util.Consumer
import androidx.navigation.compose.NavHost import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation.compose.composable import androidx.navigation3.runtime.NavBackStack
import androidx.navigation.compose.rememberNavController import androidx.navigation3.runtime.NavKey
import androidx.navigation.navDeepLink import androidx.navigation3.runtime.entryProvider
import androidx.navigation.toRoute import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import su.reya.coop.screens.ChatScreen import su.reya.coop.screens.ChatScreen
import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.HomeScreen
@@ -65,26 +73,41 @@ val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided") error("No SnackbarHostState provided")
} }
val LocalNavController = staticCompositionLocalOf<NavController> { val LocalNavigator = staticCompositionLocalOf<Navigator> {
error("No NavController provided") error("No Navigator provided")
}
val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
error("No QrScanResult provided")
} }
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun App(viewModel: NostrViewModel) { fun App(viewModel: NostrViewModel) {
val context = LocalContext.current val context = LocalContext.current
val navController = rememberNavController() val activity = context as? ComponentActivity
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val darkMode = isSystemInDarkTheme() val sheetState = rememberModalBottomSheetState()
val backStack = rememberNavBackStack(Screen.Home)
val navigator = remember(backStack) { Navigator(backStack) }
val qrScanResult = remember { QrScanResult() }
val signerRequired by viewModel.signerRequired.collectAsState(initial = null)
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
// Snackbar // Snackbar
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Check if dark theme enabled
val darkMode = isSystemInDarkTheme()
// Enabled the dynamic color scheme // Enabled the dynamic color scheme
val colorScheme = when { val colorScheme = when {
// Enable the dynamic color scheme for Android 12+ // Enable the dynamic color scheme for Android 12+
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> { android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
context
)
} }
// When dark mode is enabled, use the dark color scheme // When dark mode is enabled, use the dark color scheme
darkMode -> darkColorScheme() darkMode -> darkColorScheme()
@@ -92,12 +115,48 @@ fun App(viewModel: NostrViewModel) {
else -> expressiveLightColorScheme() else -> expressiveLightColorScheme()
} }
BackHandler(enabled = backStack.size > 1) {
navigator.goBack()
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.errorEvents.collect { message -> viewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
} }
} }
LaunchedEffect(activity) {
activity?.let {
fun handleIntent(intent: Intent) {
val screen = Screen.fromIntent(intent)
// Prevent pushing the same screen
if (screen != null && backStack.lastOrNull() != screen) {
navigator.navigate(screen)
}
}
// Handle the intent that started the Activity
handleIntent(it.intent)
// Handle new intents while the Activity is running
val listener = Consumer<Intent> { intent -> handleIntent(intent) }
it.addOnNewIntentListener(listener)
}
}
LaunchedEffect(backStack.size) {
if (backStack.isEmpty()) {
(context as? Activity)?.finish()
}
}
LaunchedEffect(signerRequired) {
if (signerRequired == true && backStack.last() != Screen.Onboarding) {
backStack.clear()
backStack.add(Screen.Onboarding)
}
}
MaterialExpressiveTheme( MaterialExpressiveTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = Typography(), typography = Typography(),
@@ -106,109 +165,70 @@ fun App(viewModel: NostrViewModel) {
CompositionLocalProvider( CompositionLocalProvider(
LocalNostrViewModel provides viewModel, LocalNostrViewModel provides viewModel,
LocalSnackbarHostState provides snackbarHostState, LocalSnackbarHostState provides snackbarHostState,
LocalNavController provides navController, LocalNavigator provides navigator,
LocalScanResult provides qrScanResult,
) { ) {
val signerRequired by viewModel.signerRequired.collectAsState(initial = null) NavDisplay(
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState() backStack = backStack,
val sheetState = rememberModalBottomSheetState() onBack = {
if (backStack.size > 1) {
LaunchedEffect(signerRequired) { backStack.removeLastOrNull()
// Navigate to the home screen if the secret is already set } else {
if (signerRequired == false) { (context as? Activity)?.finish()
navController.navigate(Screen.Home) { }
popUpTo(Screen.Onboarding) { inclusive = true } },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Home> {
HomeScreen()
}
entry<Screen.Onboarding> {
OnboardingScreen()
}
entry<Screen.Import> {
ImportScreen(
onSave = { secret ->
viewModel.importIdentity(secret)
}
)
}
entry<Screen.NewIdentity> {
NewIdentityScreen(
onSave = { name, bio, uri ->
val contentType =
uri?.let { context.contentResolver.getType(it) }
val picture = uri?.let {
context.contentResolver.openInputStream(it)?.use { input ->
input.readBytes()
}
}
viewModel.createIdentity(name, bio, picture, contentType)
}
)
}
entry<Screen.Chat> { key ->
ChatScreen(id = key.id)
}
entry<Screen.NewChat> {
NewChatScreen()
}
entry<Screen.Profile> { key ->
ProfileScreen(pubkey = key.pubkey)
}
entry<Screen.Scan> {
ScanScreen()
}
entry<Screen.MyQr> {
MyQrScreen()
}
entry<Screen.Relay> {
RelayScreen()
} }
} }
} )
// Keep the splash screen visible until the secret check is complete
if (signerRequired == null) {
return@CompositionLocalProvider
}
NavHost(
navController = navController,
startDestination = if (signerRequired!!) Screen.Onboarding else Screen.Home
) {
composable<Screen.Onboarding> { backStackEntry ->
OnboardingScreen(
onOpenImport = { navController.navigate(Screen.Import) },
onOpenNew = { navController.navigate(Screen.NewIdentity) }
)
}
composable<Screen.Import> { backStackEntry ->
val isCreating by viewModel.isCreating.collectAsState()
ImportScreen(
isLoading = isCreating,
onBack = { navController.popBackStack() },
onSave = { secret ->
viewModel.importIdentity(secret)
}
)
}
composable<Screen.NewIdentity> { backStackEntry ->
val isCreating by viewModel.isCreating.collectAsState()
NewIdentityScreen(
isLoading = isCreating,
onBack = { navController.popBackStack() },
onSave = { name, bio, uri ->
val contentType = uri?.let { context.contentResolver.getType(it) }
val picture = uri?.let {
context.contentResolver.openInputStream(it)?.use { input ->
input.readBytes()
}
}
viewModel.createIdentity(name, bio, picture, contentType)
}
)
}
composable<Screen.Home> { backStackEntry ->
HomeScreen(
onOpenChat = { id -> navController.navigate(Screen.Chat(id)) },
onNewChat = { navController.navigate(Screen.NewChat) }
)
}
composable<Screen.Chat>(
deepLinks = listOf(
navDeepLink<Screen.Chat>(basePath = "coop://chat")
)
) { backStackEntry ->
val chat: Screen.Chat = backStackEntry.toRoute()
ChatScreen(
id = chat.id,
onBack = { navController.popBackStack() },
)
}
composable<Screen.Profile> { backStackEntry ->
val profile: Screen.Profile = backStackEntry.toRoute()
ProfileScreen(
pubkey = profile.pubkey,
onBack = { navController.popBackStack() },
)
}
composable<Screen.NewChat> { backStackEntry ->
NewChatScreen(
onBack = { navController.popBackStack() },
)
}
composable<Screen.Scan> { backStackEntry ->
ScanScreen(
onBack = { navController.popBackStack() },
)
}
composable<Screen.MyQr> { backStackEntry ->
MyQrScreen(
onBack = { navController.popBackStack() },
)
}
composable<Screen.Relay> { backStackEntry ->
RelayScreen(
onBack = { navController.popBackStack() },
)
}
}
// Show the relay setup dialog if the msg relay list is empty // Show the relay setup dialog if the msg relay list is empty
if (isRelayListEmpty) { if (isRelayListEmpty) {
@@ -267,3 +287,23 @@ fun App(viewModel: NostrViewModel) {
} }
} }
} }
class Navigator(private val backStack: NavBackStack<NavKey>) {
fun navigate(route: NavKey) {
backStack.add(route)
}
fun goBack() {
if (backStack.size > 1) {
backStack.removeAt(backStack.lastIndex)
}
}
}
class QrScanResult {
var content by mutableStateOf<String?>(null)
fun clear() {
content = null
}
}

View File

@@ -1,8 +1,25 @@
package su.reya.coop package su.reya.coop
import android.content.Intent
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
sealed interface Screen { sealed interface Screen : NavKey {
companion object {
fun fromIntent(intent: Intent): Screen? {
val data = intent.data ?: return null
if (data.scheme != "coop") return null
return when (data.host) {
// Matches coop://chat/{id}
"chat" -> data.pathSegments.firstOrNull()?.toLongOrNull()?.let { Chat(it) }
// Matches coop://profile/{pubkey}
"profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) }
else -> null
}
}
}
@Serializable @Serializable
data object Home : Screen data object Home : Screen

View File

@@ -30,9 +30,10 @@ class NostrForegroundService : Service() {
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
} }
@RequiresApi(Build.VERSION_CODES.O)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
createNotificationChannel() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel()
}
val notification = createNotification() val notification = createNotification()
startForeground(1, notification) startForeground(1, notification)
@@ -78,7 +79,7 @@ class NostrForegroundService : Service() {
val manager = getSystemService(NotificationManager::class.java) val manager = getSystemService(NotificationManager::class.java)
val serviceChannel = NotificationChannel( val serviceChannel = NotificationChannel(
"nostr_service_silent", "nostr_service",
"Nostr Background Status", "Nostr Background Status",
NotificationManager.IMPORTANCE_MIN NotificationManager.IMPORTANCE_MIN
).apply { ).apply {
@@ -127,7 +128,7 @@ class NostrForegroundService : Service() {
intent, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
val notification = NotificationCompat.Builder(this, "nostr_messages") val notification = NotificationCompat.Builder(this, "nostr_messages")
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle("You received a new message") .setContentTitle("You received a new message")

View File

@@ -54,7 +54,7 @@ import coop.composeapp.generated.resources.ic_send
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavController import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
@@ -66,12 +66,9 @@ import su.reya.coop.shared.pictureFlow
import su.reya.coop.short import su.reya.coop.short
@Composable @Composable
fun ChatScreen( fun ChatScreen(id: Long) {
id: Long,
onBack: () -> Unit,
) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -153,7 +150,7 @@ fun ChatScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { modifier = Modifier.clickable {
room.members.firstOrNull()?.let { pubkey -> room.members.firstOrNull()?.let { pubkey ->
navController.navigate(Screen.Profile(pubkey.toBech32())) navigator.navigate(Screen.Profile(pubkey.toBech32()))
} }
} }
) { ) {
@@ -185,7 +182,7 @@ fun ChatScreen(
} }
} }
) { ) {
IconButton(onClick = onBack) { IconButton(onClick = { navigator.goBack() }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back" contentDescription = "Back"

View File

@@ -70,8 +70,9 @@ import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavController import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.Screen import su.reya.coop.Screen
@@ -83,11 +84,9 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen( fun HomeScreen() {
onOpenChat: (Long) -> Unit, val navigator = LocalNavigator.current
onNewChat: () -> Unit, val qrScanResult = LocalScanResult.current
) {
val navController = LocalNavController.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val clipboardManager = LocalClipboard.current val clipboardManager = LocalClipboard.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
@@ -107,33 +106,24 @@ fun HomeScreen(
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
val qrResult by savedStateHandle
?.getStateFlow<String?>("qr_result", null)
?.collectAsState()
?: remember { mutableStateOf(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (qrResult == null) { viewModel.getChatRooms()
viewModel.getChatRooms()
}
} }
LaunchedEffect(qrResult) { LaunchedEffect(qrScanResult.content) {
qrResult?.let { result -> qrScanResult.content?.let { result ->
runCatching { PublicKey.parse(result) } runCatching { PublicKey.parse(result) }
.onSuccess { pubkey -> .onSuccess { pubkey ->
try { try {
val roomId = viewModel.createChatRoom(listOf(pubkey)) val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
} catch (e: Exception) { } catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) } e.message?.let { snackbarHostState.showSnackbar(it) }
} }
} }
.onFailure { e -> println("Failed to parse QR: ${e.message}") } .onFailure { e -> println("Failed to parse QR: ${e.message}") }
// Clear the nav state // Clear the nav state
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result") qrScanResult.clear()
} }
} }
@@ -153,7 +143,7 @@ fun HomeScreen(
}, },
actions = { actions = {
// QR Scanner // QR Scanner
IconButton(onClick = { navController.navigate(Screen.Scan) }) { IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_scanner), painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner" contentDescription = "Scanner"
@@ -184,7 +174,7 @@ fun HomeScreen(
state = rememberTooltipState(), state = rememberTooltipState(),
) { ) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = onNewChat, onClick = { navigator.navigate(Screen.NewChat) },
expanded = expandedFab, expanded = expandedFab,
icon = { icon = {
Icon( Icon(
@@ -261,7 +251,7 @@ fun HomeScreen(
items(chatRooms.toList(), key = { it.id }) { room -> items(chatRooms.toList(), key = { it.id }) { room ->
ChatRoom( ChatRoom(
room = room, room = room,
onClick = { onOpenChat(room.id) } onClick = { navigator.navigate(Screen.Chat(room.id)) }
) )
} }
} }
@@ -339,7 +329,7 @@ fun HomeScreen(
} }
FilledIconButton( FilledIconButton(
onClick = { onClick = {
dismissAndRun { navController.navigate(Screen.MyQr) } dismissAndRun { navigator.navigate(Screen.MyQr) }
}, },
shape = MaterialShapes.Square.toShape() shape = MaterialShapes.Square.toShape()
) { ) {
@@ -408,11 +398,11 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
fun BottomMenuList( fun BottomMenuList(
onDismiss: (suspend () -> Unit) -> Unit onDismiss: (suspend () -> Unit) -> Unit
) { ) {
val navController = LocalNavController.current val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val defaultMenuList = listOf( val defaultMenuList = listOf(
"Relay Management" to { navController.navigate(Screen.Relay) }, "Relay Management" to { navigator.navigate(Screen.Relay) },
"Spams & Blocks" to { }, "Spams & Blocks" to { },
"Contacts" to { }, "Contacts" to { },
"Settings" to { } "Settings" to { }

View File

@@ -58,8 +58,9 @@ import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Keys import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavController import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
@@ -69,12 +70,11 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ImportScreen( fun ImportScreen(
isLoading: Boolean,
onBack: () -> Unit,
onSave: (secret: String) -> Unit onSave: (secret: String) -> Unit
) { ) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -89,19 +89,14 @@ fun ImportScreen(
} }
}.collectAsState(null) }.collectAsState(null)
val profile = metadata?.asRecord() val profile = metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown" val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
val picture = profile?.picture val picture = profile?.picture
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle val isLoading by viewModel.isCreating.collectAsState()
val qrResult by savedStateHandle
?.getStateFlow<String?>("qr_result", null)
?.collectAsState()
?: remember { mutableStateOf(null) }
LaunchedEffect(qrResult) { LaunchedEffect(qrScanResult.content) {
qrResult?.let { result -> qrScanResult.content?.let { result ->
runCatching { runCatching {
if (result.startsWith("nsec")) { if (result.startsWith("nsec")) {
Keys.parse(result) Keys.parse(result)
@@ -113,8 +108,9 @@ fun ImportScreen(
} }
.onSuccess { it -> secret = result } .onSuccess { it -> secret = result }
.onFailure { e -> println("Failed to parse QR: ${e.message}") } .onFailure { e -> println("Failed to parse QR: ${e.message}") }
// Clear the nav state // Clear the nav state
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result") qrScanResult.clear()
} }
} }
@@ -133,7 +129,7 @@ fun ImportScreen(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = { navigator.goBack() }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back" contentDescription = "Back"
@@ -141,7 +137,7 @@ fun ImportScreen(
} }
}, },
actions = { actions = {
IconButton(onClick = { navController.navigate(Screen.Scan) }) { IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_scanner), painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner" contentDescription = "Scanner"

View File

@@ -19,13 +19,13 @@ import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import io.github.alexzhirkevich.qrose.rememberQrCodePainter import io.github.alexzhirkevich.qrose.rememberQrCodePainter
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@Composable @Composable
fun MyQrScreen( fun MyQrScreen() {
onBack: () -> Unit val navigator = LocalNavigator.current
) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val currentUser = viewModel.currentUser() ?: return val currentUser = viewModel.currentUser() ?: return
@@ -41,7 +41,7 @@ fun MyQrScreen(
) )
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = { navigator.goBack() }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back" contentDescription = "Back"

View File

@@ -54,8 +54,9 @@ import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavController import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
@@ -63,11 +64,10 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun NewChatScreen( fun NewChatScreen() {
onBack: () -> Unit,
) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val contactList by viewModel.contactList.collectAsState(initial = emptySet()) val contactList by viewModel.contactList.collectAsState(initial = emptySet())
@@ -76,12 +76,6 @@ fun NewChatScreen(
val selectedReceivers = remember { mutableStateListOf<PublicKey>() } val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
val qrResult by savedStateHandle
?.getStateFlow<String?>("qr_result", null)
?.collectAsState()
?: remember { mutableStateOf(null) }
LaunchedEffect(query) { LaunchedEffect(query) {
if (query.length >= 3) { if (query.length >= 3) {
delay(500) // 500ms debounce delay(500) // 500ms debounce
@@ -111,13 +105,19 @@ fun NewChatScreen(
} }
} }
LaunchedEffect(qrResult) { LaunchedEffect(qrScanResult.content) {
qrResult?.let { result -> qrScanResult.content?.let { result ->
// Verify the content
runCatching { PublicKey.parse(result) } runCatching { PublicKey.parse(result) }
.onSuccess { pubkey -> selectedReceivers.add(pubkey) } .onSuccess { pubkey ->
.onFailure { e -> println("Failed to parse QR: ${e.message}") } selectedReceivers.add(pubkey)
}
.onFailure { e ->
println("Failed to parse QR: ${e.message}")
}
// Clear the nav state // Clear the nav state
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result") qrScanResult.clear()
} }
} }
@@ -136,7 +136,7 @@ fun NewChatScreen(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = { navigator.goBack() }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back" contentDescription = "Back"
@@ -144,7 +144,7 @@ fun NewChatScreen(
} }
}, },
actions = { actions = {
IconButton(onClick = { navController.navigate(Screen.Scan) }) { IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_scanner), painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner" contentDescription = "Scanner"
@@ -168,7 +168,7 @@ fun NewChatScreen(
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = { onClick = {
val roomId = viewModel.createChatRoom(selectedReceivers.toList()) val roomId = viewModel.createChatRoom(selectedReceivers.toList())
navController.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
}, },
expanded = false, expanded = false,
icon = { icon = {
@@ -259,7 +259,7 @@ fun NewChatScreen(
selectedReceivers = selectedReceivers, selectedReceivers = selectedReceivers,
onContactClick = { pubkey -> onContactClick = { pubkey ->
val roomId = viewModel.createChatRoom(listOf(pubkey)) val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
}, },
) )
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
@@ -270,7 +270,7 @@ fun NewChatScreen(
selectedReceivers = selectedReceivers, selectedReceivers = selectedReceivers,
onContactClick = { pubkey -> onContactClick = { pubkey ->
val roomId = viewModel.createChatRoom(listOf(pubkey)) val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
} }
) )
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))

View File

@@ -36,6 +36,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -54,22 +55,27 @@ import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_plus import coop.composeapp.generated.resources.ic_plus
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun NewIdentityScreen( fun NewIdentityScreen(
isLoading: Boolean,
onBack: () -> Unit,
onSave: (name: String, bio: String?, picture: Uri?) -> Unit onSave: (name: String, bio: String?, picture: Uri?) -> Unit
) { ) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var bio by remember { mutableStateOf("") } var bio by remember { mutableStateOf("") }
var picture by remember { mutableStateOf<Uri?>(null) } var picture by remember { mutableStateOf<Uri?>(null) }
val isLoading by viewModel.isCreating.collectAsState()
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent() contract = ActivityResultContracts.GetContent()
) { uri: Uri? -> ) { uri: Uri? ->
@@ -88,7 +94,7 @@ fun NewIdentityScreen(
) )
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = { navigator.goBack() }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back" contentDescription = "Back"

View File

@@ -37,13 +37,17 @@ import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.coop import coop.composeapp.generated.resources.coop
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) { fun OnboardingScreen() {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val logoPainter = painterResource(Res.drawable.coop) val logoPainter = painterResource(Res.drawable.coop)
val expressiveFont = getExpressiveFontFamily() val expressiveFont = getExpressiveFontFamily()
val annotatedText = buildAnnotatedString { val annotatedText = buildAnnotatedString {
@@ -127,7 +131,7 @@ fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
) )
Spacer(modifier = Modifier.size(24.dp)) Spacer(modifier = Modifier.size(24.dp))
Button( Button(
onClick = onOpenNew, onClick = { navigator.navigate(Screen.NewIdentity) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.size(ButtonDefaults.MediumContainerHeight), .size(ButtonDefaults.MediumContainerHeight),
@@ -139,7 +143,7 @@ fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
} }
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
OutlinedButton( OutlinedButton(
onClick = onOpenImport, onClick = { navigator.navigate(Screen.Import) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight), .height(ButtonDefaults.MediumContainerHeight),

View File

@@ -44,7 +44,7 @@ import coop.composeapp.generated.resources.ic_share
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavController import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
@@ -54,15 +54,12 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ProfileScreen( fun ProfileScreen(pubkey: String) {
onBack: () -> Unit,
pubkey: String
) {
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -88,7 +85,7 @@ fun ProfileScreen(
TopAppBar( TopAppBar(
title = { }, title = { },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = { navigator.goBack() }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back" contentDescription = "Back"
@@ -162,7 +159,7 @@ fun ProfileScreen(
scope.launch { scope.launch {
try { try {
val roomId = viewModel.createChatRoom(listOf(pubkey)) val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
} catch (e: Exception) { } catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) } e.message?.let { snackbarHostState.showSnackbar(it) }
} }

View File

@@ -34,14 +34,14 @@ import coop.composeapp.generated.resources.ic_arrow_back
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.RelayUrl
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun RelayScreen( fun RelayScreen() {
onBack: () -> Unit val navigator = LocalNavigator.current
) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
@@ -80,7 +80,7 @@ fun RelayScreen(
) )
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = { navigator.goBack() }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back" contentDescription = "Back"

View File

@@ -30,21 +30,16 @@ import org.jetbrains.compose.resources.painterResource
import org.publicvalue.multiplatform.qrcode.CameraPosition import org.publicvalue.multiplatform.qrcode.CameraPosition
import org.publicvalue.multiplatform.qrcode.CodeType import org.publicvalue.multiplatform.qrcode.CodeType
import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions
import su.reya.coop.LocalNavController import su.reya.coop.LocalNavigator
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable @Composable
fun ScanScreen( fun ScanScreen() {
onBack: () -> Unit val navigator = LocalNavigator.current
) {
val navController = LocalNavController.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val qrScanResult = LocalScanResult.current
val onResult: (String) -> Unit = { result ->
navController.previousBackStackEntry?.savedStateHandle?.set("qr_result", result)
navController.popBackStack()
}
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -57,7 +52,7 @@ fun ScanScreen(
) )
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = { navigator.goBack() }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back" contentDescription = "Back"
@@ -76,7 +71,8 @@ fun ScanScreen(
ScannerWithPermissions( ScannerWithPermissions(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onScanned = { onScanned = {
onResult(it) qrScanResult.content = it
navigator.goBack()
true true
}, },
types = listOf(CodeType.QR), types = listOf(CodeType.QR),

View File

@@ -8,7 +8,6 @@ androidx-appcompat = "1.7.1"
androidx-core = "1.18.0" androidx-core = "1.18.0"
androidx-espresso = "3.7.0" androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0" androidx-lifecycle = "2.10.0"
androidx-navigation = "2.9.8"
androidx-testExt = "1.3.0" androidx-testExt = "1.3.0"
androidx-splashscreen = "1.2.0" androidx-splashscreen = "1.2.0"
composeMultiplatform = "1.11.0" composeMultiplatform = "1.11.0"
@@ -17,6 +16,7 @@ junit = "4.13.2"
kotlin = "2.3.21" kotlin = "2.3.21"
kotlinx-serialization = "1.11.0" kotlinx-serialization = "1.11.0"
material3 = "1.11.0-alpha07" material3 = "1.11.0-alpha07"
multiplatform-nav3-ui = "1.1.1"
ktor = "3.5.0" ktor = "3.5.0"
[libraries] [libraries]
@@ -31,7 +31,6 @@ androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", vers
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
@@ -49,6 +48,8 @@ ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "kto
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" }
jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle" }
[plugins] [plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" } androidApplication = { id = "com.android.application", version.ref = "agp" }

View File

@@ -19,6 +19,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EventBuilder import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.EventId import rust.nostr.sdk.EventId
import rust.nostr.sdk.Keys import rust.nostr.sdk.Keys
@@ -33,7 +34,7 @@ import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.blossom.BlossomClient import su.reya.coop.blossom.BlossomClient
import su.reya.coop.storage.SecretStorage import su.reya.coop.storage.SecretStorage
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds
class NostrViewModel( class NostrViewModel(
private val nostr: Nostr, private val nostr: Nostr,
@@ -201,34 +202,26 @@ class NostrViewModel(
private fun login() { private fun login() {
viewModelScope.launch { viewModelScope.launch {
// Get user's signer secret try {
val secret = secretStore.get("user_signer") val secret = secretStore.get("user_signer")
// If no secret is found, show onboarding screen if (secret == null) {
if (secret == null) { _signerRequired.value = true
_signerRequired.value = true return@launch
return@launch
}
// Update the empty secret state
_signerRequired.value = false
// Handle different signer types
if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret)
nostr.setSigner(keys)
} else if (secret.startsWith("bunker://")) {
try {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout
val remote = NostrConnect(uri = bunker, appKeys, timeout, opts = null)
nostr.setSigner(remote)
} catch (e: Exception) {
showError("Error: ${e.message}")
} }
} else {
throw IllegalArgumentException("Invalid secret format: $secret") runCatching {
val signer = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_signerRequired.value = false
}.onFailure { e ->
showError("Login failed: ${e.message}")
_signerRequired.value = true
}
} catch (e: Exception) {
showError("Login failed: ${e.message}")
_signerRequired.value = true
} }
} }
} }
@@ -317,6 +310,20 @@ class NostrViewModel(
return keys return keys
} }
private suspend fun createSigner(secret: String): AsyncNostrSigner {
return when {
secret.startsWith("nsec1") -> Keys.parse(secret)
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds // or Duration.parse("50s")
NostrConnect(uri = bunker, appKeys, timeout, null)
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
fun createIdentity( fun createIdentity(
name: String, name: String,
bio: String?, bio: String?,
@@ -371,46 +378,25 @@ class NostrViewModel(
} }
suspend fun verifyIdentity(secret: String): PublicKey? { suspend fun verifyIdentity(secret: String): PublicKey? {
if (secret.startsWith("nsec1")) { return runCatching {
val keys = Keys.parse(secret) val signer = createSigner(secret)
return keys.publicKey() if (secret.startsWith("bunker://")) {
} else if (secret.startsWith("bunker://")) { showError("Please approve the connection.")
val appKeys = getOrInitAppKeys() }
val bunker = NostrConnectUri.parse(secret) signer.getPublicKeyAsync()
val timeout = Duration.parse("50s") // 50 seconds timeout }.getOrNull()
val remote = NostrConnect(uri = bunker, appKeys, timeout, null)
// Show toast to ask user to approve the connection
showError("Please approve the connection.")
return remote.getPublicKeyAsync()
} else {
throw IllegalArgumentException("Invalid secret: $secret")
}
} }
fun importIdentity(secret: String) { fun importIdentity(secret: String) {
viewModelScope.launch { viewModelScope.launch {
if (secret.startsWith("nsec1")) { runCatching {
val keys = Keys.parse(secret) val signer = createSigner(secret)
nostr.setSigner(keys) nostr.setSigner(signer)
secretStore.set("user_signer", secret) secretStore.set("user_signer", secret)
// Set an empty secret state }.onSuccess {
_signerRequired.value = false _signerRequired.value = false
} else if (secret.startsWith("bunker://")) { }.onFailure { e ->
try { showError(e.message ?: "Invalid Secret or Bunker URI")
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout
val remote = NostrConnect(uri = bunker, appKeys, timeout, null)
nostr.setSigner(remote)
secretStore.set("user_signer", secret)
_signerRequired.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
}
} else {
showError("Please enter a valid Secret or Bunker URI.")
} }
} }
} }