From a3ab489d445cc63f2ea68fcba1580681a8783c09 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 31 May 2026 01:28:09 +0000 Subject: [PATCH] feat: migrate to navigation3 (#7) Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/7 --- composeApp/build.gradle.kts | 6 +- .../androidMain/kotlin/su/reya/coop/App.kt | 262 ++++++++++-------- .../kotlin/su/reya/coop/Navigation.kt | 19 +- .../su/reya/coop/NostrForegroundService.kt | 9 +- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 13 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 42 ++- .../su/reya/coop/screens/ImportScreen.kt | 26 +- .../kotlin/su/reya/coop/screens/MyQrScreen.kt | 8 +- .../su/reya/coop/screens/NewChatScreen.kt | 42 +-- .../su/reya/coop/screens/NewIdentityScreen.kt | 12 +- .../su/reya/coop/screens/OnboardingScreen.kt | 10 +- .../su/reya/coop/screens/ProfileScreen.kt | 13 +- .../su/reya/coop/screens/RelayScreen.kt | 8 +- .../kotlin/su/reya/coop/screens/ScanScreen.kt | 20 +- gradle/libs.versions.toml | 5 +- .../kotlin/su/reya/coop/NostrViewModel.kt | 108 ++++---- 16 files changed, 318 insertions(+), 285 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 8c3c317..6c1297a 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -19,12 +19,14 @@ kotlin { androidMain.dependencies { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.activity.compose) - implementation(libs.androidx.navigation.compose) 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("su.reya:nostr-sdk-kmp:0.2.3") implementation("io.coil-kt.coil3:coil-compose: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.alexzhirkevich:qrose:1.1.2") } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 22bf5d6..0d7751c 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -1,5 +1,9 @@ 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.layout.Column import androidx.compose.foundation.layout.Spacer @@ -29,8 +33,10 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment 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.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navDeepLink -import androidx.navigation.toRoute +import androidx.core.util.Consumer +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay import kotlinx.coroutines.launch import su.reya.coop.screens.ChatScreen import su.reya.coop.screens.HomeScreen @@ -65,26 +73,41 @@ val LocalSnackbarHostState = staticCompositionLocalOf { error("No SnackbarHostState provided") } -val LocalNavController = staticCompositionLocalOf { - error("No NavController provided") +val LocalNavigator = staticCompositionLocalOf { + error("No Navigator provided") +} + +val LocalScanResult = staticCompositionLocalOf { + error("No QrScanResult provided") } @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun App(viewModel: NostrViewModel) { val context = LocalContext.current - val navController = rememberNavController() + val activity = context as? ComponentActivity 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 val snackbarHostState = remember { SnackbarHostState() } + // Check if dark theme enabled + val darkMode = isSystemInDarkTheme() + // Enabled the dynamic color scheme val colorScheme = when { // Enable the dynamic color scheme for Android 12+ 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 darkMode -> darkColorScheme() @@ -92,12 +115,48 @@ fun App(viewModel: NostrViewModel) { else -> expressiveLightColorScheme() } + BackHandler(enabled = backStack.size > 1) { + navigator.goBack() + } + LaunchedEffect(Unit) { viewModel.errorEvents.collect { 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 -> 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( colorScheme = colorScheme, typography = Typography(), @@ -106,109 +165,70 @@ fun App(viewModel: NostrViewModel) { CompositionLocalProvider( LocalNostrViewModel provides viewModel, LocalSnackbarHostState provides snackbarHostState, - LocalNavController provides navController, + LocalNavigator provides navigator, + LocalScanResult provides qrScanResult, ) { - val signerRequired by viewModel.signerRequired.collectAsState(initial = null) - val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState() - val sheetState = rememberModalBottomSheetState() - - LaunchedEffect(signerRequired) { - // Navigate to the home screen if the secret is already set - if (signerRequired == false) { - navController.navigate(Screen.Home) { - popUpTo(Screen.Onboarding) { inclusive = true } + NavDisplay( + backStack = backStack, + onBack = { + if (backStack.size > 1) { + backStack.removeLastOrNull() + } else { + (context as? Activity)?.finish() + } + }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { + entry { + HomeScreen() + } + entry { + OnboardingScreen() + } + entry { + ImportScreen( + onSave = { secret -> + viewModel.importIdentity(secret) + } + ) + } + entry { + 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 { key -> + ChatScreen(id = key.id) + } + entry { + NewChatScreen() + } + entry { key -> + ProfileScreen(pubkey = key.pubkey) + } + entry { + ScanScreen() + } + entry { + MyQrScreen() + } + entry { + 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 { backStackEntry -> - OnboardingScreen( - onOpenImport = { navController.navigate(Screen.Import) }, - onOpenNew = { navController.navigate(Screen.NewIdentity) } - ) - } - composable { backStackEntry -> - val isCreating by viewModel.isCreating.collectAsState() - - ImportScreen( - isLoading = isCreating, - onBack = { navController.popBackStack() }, - onSave = { secret -> - viewModel.importIdentity(secret) - } - ) - } - composable { 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 { backStackEntry -> - HomeScreen( - onOpenChat = { id -> navController.navigate(Screen.Chat(id)) }, - onNewChat = { navController.navigate(Screen.NewChat) } - ) - } - composable( - deepLinks = listOf( - navDeepLink(basePath = "coop://chat") - ) - ) { backStackEntry -> - val chat: Screen.Chat = backStackEntry.toRoute() - ChatScreen( - id = chat.id, - onBack = { navController.popBackStack() }, - ) - } - composable { backStackEntry -> - val profile: Screen.Profile = backStackEntry.toRoute() - ProfileScreen( - pubkey = profile.pubkey, - onBack = { navController.popBackStack() }, - ) - } - composable { backStackEntry -> - NewChatScreen( - onBack = { navController.popBackStack() }, - ) - } - composable { backStackEntry -> - ScanScreen( - onBack = { navController.popBackStack() }, - ) - } - composable { backStackEntry -> - MyQrScreen( - onBack = { navController.popBackStack() }, - ) - } - composable { backStackEntry -> - RelayScreen( - onBack = { navController.popBackStack() }, - ) - } - } + ) // Show the relay setup dialog if the msg relay list is empty if (isRelayListEmpty) { @@ -267,3 +287,23 @@ fun App(viewModel: NostrViewModel) { } } } + +class Navigator(private val backStack: NavBackStack) { + fun navigate(route: NavKey) { + backStack.add(route) + } + + fun goBack() { + if (backStack.size > 1) { + backStack.removeAt(backStack.lastIndex) + } + } +} + +class QrScanResult { + var content by mutableStateOf(null) + + fun clear() { + content = null + } +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt index dbf5bc3..68f19dd 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt @@ -1,8 +1,25 @@ package su.reya.coop +import android.content.Intent +import androidx.navigation3.runtime.NavKey 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 data object Home : Screen diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt index 48e9bc3..41e99f8 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt @@ -30,9 +30,10 @@ class NostrForegroundService : Service() { return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) } - @RequiresApi(Build.VERSION_CODES.O) override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - createNotificationChannel() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel() + } val notification = createNotification() startForeground(1, notification) @@ -78,7 +79,7 @@ class NostrForegroundService : Service() { val manager = getSystemService(NotificationManager::class.java) val serviceChannel = NotificationChannel( - "nostr_service_silent", + "nostr_service", "Nostr Background Status", NotificationManager.IMPORTANCE_MIN ).apply { @@ -127,7 +128,7 @@ class NostrForegroundService : Service() { intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - + val notification = NotificationCompat.Builder(this, "nostr_messages") .setSmallIcon(R.drawable.ic_notification) .setContentTitle("You received a new message") diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index a3ab8a8..8300e02 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -54,7 +54,7 @@ import coop.composeapp.generated.resources.ic_send import kotlinx.coroutines.flow.first import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.UnsignedEvent -import su.reya.coop.LocalNavController +import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen @@ -66,12 +66,9 @@ import su.reya.coop.shared.pictureFlow import su.reya.coop.short @Composable -fun ChatScreen( - id: Long, - onBack: () -> Unit, -) { +fun ChatScreen(id: Long) { val snackbarHostState = LocalSnackbarHostState.current - val navController = LocalNavController.current + val navigator = LocalNavigator.current val viewModel = LocalNostrViewModel.current val listState = rememberLazyListState() @@ -153,7 +150,7 @@ fun ChatScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { 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( painter = painterResource(Res.drawable.ic_arrow_back), contentDescription = "Back" diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index a5db59f..66294cb 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -70,8 +70,9 @@ import coop.composeapp.generated.resources.ic_scanner import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey -import su.reya.coop.LocalNavController +import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room import su.reya.coop.Screen @@ -83,11 +84,9 @@ import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable -fun HomeScreen( - onOpenChat: (Long) -> Unit, - onNewChat: () -> Unit, -) { - val navController = LocalNavController.current +fun HomeScreen() { + val navigator = LocalNavigator.current + val qrScanResult = LocalScanResult.current val snackbarHostState = LocalSnackbarHostState.current val clipboardManager = LocalClipboard.current val viewModel = LocalNostrViewModel.current @@ -107,33 +106,24 @@ fun HomeScreen( var showBottomSheet by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) } - val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle - val qrResult by savedStateHandle - ?.getStateFlow("qr_result", null) - ?.collectAsState() - ?: remember { mutableStateOf(null) } - LaunchedEffect(Unit) { - if (qrResult == null) { - viewModel.getChatRooms() - } + viewModel.getChatRooms() } - LaunchedEffect(qrResult) { - qrResult?.let { result -> + LaunchedEffect(qrScanResult.content) { + qrScanResult.content?.let { result -> runCatching { PublicKey.parse(result) } .onSuccess { pubkey -> try { val roomId = viewModel.createChatRoom(listOf(pubkey)) - navController.navigate(Screen.Chat(roomId)) + navigator.navigate(Screen.Chat(roomId)) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } } } .onFailure { e -> println("Failed to parse QR: ${e.message}") } - // Clear the nav state - navController.currentBackStackEntry?.savedStateHandle?.remove("qr_result") + qrScanResult.clear() } } @@ -153,7 +143,7 @@ fun HomeScreen( }, actions = { // QR Scanner - IconButton(onClick = { navController.navigate(Screen.Scan) }) { + IconButton(onClick = { navigator.navigate(Screen.Scan) }) { Icon( painter = painterResource(Res.drawable.ic_scanner), contentDescription = "Scanner" @@ -184,7 +174,7 @@ fun HomeScreen( state = rememberTooltipState(), ) { ExtendedFloatingActionButton( - onClick = onNewChat, + onClick = { navigator.navigate(Screen.NewChat) }, expanded = expandedFab, icon = { Icon( @@ -261,7 +251,7 @@ fun HomeScreen( items(chatRooms.toList(), key = { it.id }) { room -> ChatRoom( room = room, - onClick = { onOpenChat(room.id) } + onClick = { navigator.navigate(Screen.Chat(room.id)) } ) } } @@ -339,7 +329,7 @@ fun HomeScreen( } FilledIconButton( onClick = { - dismissAndRun { navController.navigate(Screen.MyQr) } + dismissAndRun { navigator.navigate(Screen.MyQr) } }, shape = MaterialShapes.Square.toShape() ) { @@ -408,11 +398,11 @@ fun ChatRoom(room: Room, onClick: () -> Unit) { fun BottomMenuList( onDismiss: (suspend () -> Unit) -> Unit ) { - val navController = LocalNavController.current + val navigator = LocalNavigator.current val viewModel = LocalNostrViewModel.current val defaultMenuList = listOf( - "Relay Management" to { navController.navigate(Screen.Relay) }, + "Relay Management" to { navigator.navigate(Screen.Relay) }, "Spams & Blocks" to { }, "Contacts" to { }, "Settings" to { } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt index ae54729..695d8d5 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -58,8 +58,9 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Keys import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.PublicKey -import su.reya.coop.LocalNavController +import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar @@ -69,12 +70,11 @@ import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ImportScreen( - isLoading: Boolean, - onBack: () -> Unit, onSave: (secret: String) -> Unit ) { val snackbarHostState = LocalSnackbarHostState.current - val navController = LocalNavController.current + val navigator = LocalNavigator.current + val qrScanResult = LocalScanResult.current val focusManager = LocalFocusManager.current val viewModel = LocalNostrViewModel.current val scope = rememberCoroutineScope() @@ -89,19 +89,14 @@ fun ImportScreen( } }.collectAsState(null) - val profile = metadata?.asRecord() val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown" val picture = profile?.picture - val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle - val qrResult by savedStateHandle - ?.getStateFlow("qr_result", null) - ?.collectAsState() - ?: remember { mutableStateOf(null) } + val isLoading by viewModel.isCreating.collectAsState() - LaunchedEffect(qrResult) { - qrResult?.let { result -> + LaunchedEffect(qrScanResult.content) { + qrScanResult.content?.let { result -> runCatching { if (result.startsWith("nsec")) { Keys.parse(result) @@ -113,8 +108,9 @@ fun ImportScreen( } .onSuccess { it -> secret = result } .onFailure { e -> println("Failed to parse QR: ${e.message}") } + // Clear the nav state - navController.currentBackStackEntry?.savedStateHandle?.remove("qr_result") + qrScanResult.clear() } } @@ -133,7 +129,7 @@ fun ImportScreen( containerColor = MaterialTheme.colorScheme.surfaceContainer, ), navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = { navigator.goBack() }) { Icon( painter = painterResource(Res.drawable.ic_arrow_back), contentDescription = "Back" @@ -141,7 +137,7 @@ fun ImportScreen( } }, actions = { - IconButton(onClick = { navController.navigate(Screen.Scan) }) { + IconButton(onClick = { navigator.navigate(Screen.Scan) }) { Icon( painter = painterResource(Res.drawable.ic_scanner), contentDescription = "Scanner" diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt index e8cd521..1428253 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt @@ -19,13 +19,13 @@ import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import io.github.alexzhirkevich.qrose.rememberQrCodePainter import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState @Composable -fun MyQrScreen( - onBack: () -> Unit -) { +fun MyQrScreen() { + val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current val viewModel = LocalNostrViewModel.current val currentUser = viewModel.currentUser() ?: return @@ -41,7 +41,7 @@ fun MyQrScreen( ) }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = { navigator.goBack() }) { Icon( painter = painterResource(Res.drawable.ic_arrow_back), contentDescription = "Back" diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt index e80ce27..f2dc1d7 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -54,8 +54,9 @@ import coop.composeapp.generated.resources.ic_scanner import kotlinx.coroutines.delay import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey -import su.reya.coop.LocalNavController +import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar @@ -63,11 +64,10 @@ import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun NewChatScreen( - onBack: () -> Unit, -) { +fun NewChatScreen() { val snackbarHostState = LocalSnackbarHostState.current - val navController = LocalNavController.current + val navigator = LocalNavigator.current + val qrScanResult = LocalScanResult.current val viewModel = LocalNostrViewModel.current val contactList by viewModel.contactList.collectAsState(initial = emptySet()) @@ -76,12 +76,6 @@ fun NewChatScreen( val selectedReceivers = remember { mutableStateListOf() } var query by remember { mutableStateOf("") } - val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle - val qrResult by savedStateHandle - ?.getStateFlow("qr_result", null) - ?.collectAsState() - ?: remember { mutableStateOf(null) } - LaunchedEffect(query) { if (query.length >= 3) { delay(500) // 500ms debounce @@ -111,13 +105,19 @@ fun NewChatScreen( } } - LaunchedEffect(qrResult) { - qrResult?.let { result -> + LaunchedEffect(qrScanResult.content) { + qrScanResult.content?.let { result -> + // Verify the content runCatching { PublicKey.parse(result) } - .onSuccess { pubkey -> selectedReceivers.add(pubkey) } - .onFailure { e -> println("Failed to parse QR: ${e.message}") } + .onSuccess { pubkey -> + selectedReceivers.add(pubkey) + } + .onFailure { e -> + println("Failed to parse QR: ${e.message}") + } + // Clear the nav state - navController.currentBackStackEntry?.savedStateHandle?.remove("qr_result") + qrScanResult.clear() } } @@ -136,7 +136,7 @@ fun NewChatScreen( containerColor = MaterialTheme.colorScheme.surfaceContainer, ), navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = { navigator.goBack() }) { Icon( painter = painterResource(Res.drawable.ic_arrow_back), contentDescription = "Back" @@ -144,7 +144,7 @@ fun NewChatScreen( } }, actions = { - IconButton(onClick = { navController.navigate(Screen.Scan) }) { + IconButton(onClick = { navigator.navigate(Screen.Scan) }) { Icon( painter = painterResource(Res.drawable.ic_scanner), contentDescription = "Scanner" @@ -168,7 +168,7 @@ fun NewChatScreen( ExtendedFloatingActionButton( onClick = { val roomId = viewModel.createChatRoom(selectedReceivers.toList()) - navController.navigate(Screen.Chat(roomId)) + navigator.navigate(Screen.Chat(roomId)) }, expanded = false, icon = { @@ -259,7 +259,7 @@ fun NewChatScreen( selectedReceivers = selectedReceivers, onContactClick = { pubkey -> val roomId = viewModel.createChatRoom(listOf(pubkey)) - navController.navigate(Screen.Chat(roomId)) + navigator.navigate(Screen.Chat(roomId)) }, ) Spacer(modifier = Modifier.size(16.dp)) @@ -270,7 +270,7 @@ fun NewChatScreen( selectedReceivers = selectedReceivers, onContactClick = { pubkey -> val roomId = viewModel.createChatRoom(listOf(pubkey)) - navController.navigate(Screen.Chat(roomId)) + navigator.navigate(Screen.Chat(roomId)) } ) Spacer(modifier = Modifier.size(16.dp)) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index f9b8183..0cdc0e7 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.toShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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_plus import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalNavigator +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun NewIdentityScreen( - isLoading: Boolean, - onBack: () -> Unit, onSave: (name: String, bio: String?, picture: Uri?) -> Unit ) { + val snackbarHostState = LocalSnackbarHostState.current val focusManager = LocalFocusManager.current + val navigator = LocalNavigator.current + val viewModel = LocalNostrViewModel.current var name by remember { mutableStateOf("") } var bio by remember { mutableStateOf("") } var picture by remember { mutableStateOf(null) } + val isLoading by viewModel.isCreating.collectAsState() + val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() ) { uri: Uri? -> @@ -88,7 +94,7 @@ fun NewIdentityScreen( ) }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = { navigator.goBack() }) { Icon( painter = painterResource(Res.drawable.ic_arrow_back), contentDescription = "Back" diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt index edbfefd..0c53b94 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -37,13 +37,17 @@ import androidx.compose.ui.unit.dp import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.coop import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalNavigator import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.Screen import su.reya.coop.shared.getExpressiveFontFamily @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) { +fun OnboardingScreen() { val snackbarHostState = LocalSnackbarHostState.current + val navigator = LocalNavigator.current + val logoPainter = painterResource(Res.drawable.coop) val expressiveFont = getExpressiveFontFamily() val annotatedText = buildAnnotatedString { @@ -127,7 +131,7 @@ fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) { ) Spacer(modifier = Modifier.size(24.dp)) Button( - onClick = onOpenNew, + onClick = { navigator.navigate(Screen.NewIdentity) }, modifier = Modifier .fillMaxWidth() .size(ButtonDefaults.MediumContainerHeight), @@ -139,7 +143,7 @@ fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) { } Spacer(modifier = Modifier.size(8.dp)) OutlinedButton( - onClick = onOpenImport, + onClick = { navigator.navigate(Screen.Import) }, modifier = Modifier .fillMaxWidth() .height(ButtonDefaults.MediumContainerHeight), diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt index 5c0f924..8721410 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -44,7 +44,7 @@ import coop.composeapp.generated.resources.ic_share import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey -import su.reya.coop.LocalNavController +import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen @@ -54,15 +54,12 @@ import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ProfileScreen( - onBack: () -> Unit, - pubkey: String -) { +fun ProfileScreen(pubkey: String) { val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current - val navController = LocalNavController.current + val navigator = LocalNavigator.current val viewModel = LocalNostrViewModel.current val scope = rememberCoroutineScope() @@ -88,7 +85,7 @@ fun ProfileScreen( TopAppBar( title = { }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = { navigator.goBack() }) { Icon( painter = painterResource(Res.drawable.ic_arrow_back), contentDescription = "Back" @@ -162,7 +159,7 @@ fun ProfileScreen( scope.launch { try { val roomId = viewModel.createChatRoom(listOf(pubkey)) - navController.navigate(Screen.Chat(roomId)) + navigator.navigate(Screen.Chat(roomId)) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt index 4a25f8d..847df9d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt @@ -34,14 +34,14 @@ import coop.composeapp.generated.resources.ic_arrow_back import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayUrl +import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun RelayScreen( - onBack: () -> Unit -) { +fun RelayScreen() { + val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current val viewModel = LocalNostrViewModel.current @@ -80,7 +80,7 @@ fun RelayScreen( ) }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = { navigator.goBack() }) { Icon( painter = painterResource(Res.drawable.ic_arrow_back), contentDescription = "Back" diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt index 5a21f4a..c785a1d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt @@ -30,21 +30,16 @@ import org.jetbrains.compose.resources.painterResource import org.publicvalue.multiplatform.qrcode.CameraPosition import org.publicvalue.multiplatform.qrcode.CodeType 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 @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Composable -fun ScanScreen( - onBack: () -> Unit -) { - val navController = LocalNavController.current +fun ScanScreen() { + val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - - val onResult: (String) -> Unit = { result -> - navController.previousBackStackEntry?.savedStateHandle?.set("qr_result", result) - navController.popBackStack() - } + val qrScanResult = LocalScanResult.current Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -57,7 +52,7 @@ fun ScanScreen( ) }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = { navigator.goBack() }) { Icon( painter = painterResource(Res.drawable.ic_arrow_back), contentDescription = "Back" @@ -76,7 +71,8 @@ fun ScanScreen( ScannerWithPermissions( modifier = Modifier.fillMaxSize(), onScanned = { - onResult(it) + qrScanResult.content = it + navigator.goBack() true }, types = listOf(CodeType.QR), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c9add5f..a11816a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,6 @@ androidx-appcompat = "1.7.1" androidx-core = "1.18.0" androidx-espresso = "3.7.0" androidx-lifecycle = "2.10.0" -androidx-navigation = "2.9.8" androidx-testExt = "1.3.0" androidx-splashscreen = "1.2.0" composeMultiplatform = "1.11.0" @@ -17,6 +16,7 @@ junit = "4.13.2" kotlin = "2.3.21" kotlinx-serialization = "1.11.0" material3 = "1.11.0-alpha07" +multiplatform-nav3-ui = "1.1.1" ktor = "3.5.0" [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-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" } 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" } 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" } @@ -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-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" } +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] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 1141252..b78eb72 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json +import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.EventBuilder import rust.nostr.sdk.EventId import rust.nostr.sdk.Keys @@ -33,7 +34,7 @@ import rust.nostr.sdk.UnsignedEvent import su.reya.coop.blossom.BlossomClient import su.reya.coop.storage.SecretStorage import kotlin.time.Clock -import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds class NostrViewModel( private val nostr: Nostr, @@ -201,34 +202,26 @@ class NostrViewModel( private fun login() { viewModelScope.launch { - // Get user's signer secret - val secret = secretStore.get("user_signer") + try { + val secret = secretStore.get("user_signer") - // If no secret is found, show onboarding screen - if (secret == null) { - _signerRequired.value = true - 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}") + if (secret == null) { + _signerRequired.value = true + return@launch } - } 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 } + 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( name: String, bio: String?, @@ -371,46 +378,25 @@ class NostrViewModel( } suspend fun verifyIdentity(secret: String): PublicKey? { - if (secret.startsWith("nsec1")) { - val keys = Keys.parse(secret) - return keys.publicKey() - } else if (secret.startsWith("bunker://")) { - val appKeys = getOrInitAppKeys() - val bunker = NostrConnectUri.parse(secret) - val timeout = Duration.parse("50s") // 50 seconds timeout - 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") - } + return runCatching { + val signer = createSigner(secret) + if (secret.startsWith("bunker://")) { + showError("Please approve the connection.") + } + signer.getPublicKeyAsync() + }.getOrNull() } fun importIdentity(secret: String) { viewModelScope.launch { - if (secret.startsWith("nsec1")) { - val keys = Keys.parse(secret) - nostr.setSigner(keys) + runCatching { + val signer = createSigner(secret) + nostr.setSigner(signer) secretStore.set("user_signer", secret) - // Set an empty secret state + }.onSuccess { _signerRequired.value = false - } 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, 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.") + }.onFailure { e -> + showError(e.message ?: "Invalid Secret or Bunker URI") } } }