diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 08b3b24..ec9e031 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -26,6 +26,7 @@ kotlin { implementation("org.jetbrains.compose.material3:material3:1.11.0-alpha07") 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.1.2") } commonMain.dependencies { implementation(libs.compose.runtime) diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_avatar.xml b/composeApp/src/androidMain/composeResources/drawable/ic_avatar.xml new file mode 100644 index 0000000..36234ee --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_avatar.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_search.xml b/composeApp/src/androidMain/composeResources/drawable/ic_search.xml new file mode 100644 index 0000000..4aab985 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 9ed4aa7..672f1ed 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -6,13 +6,15 @@ import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.expressiveLightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost @@ -26,6 +28,10 @@ import su.reya.coop.screens.ImportScreen import su.reya.coop.screens.NewIdentityScreen import su.reya.coop.screens.OnboardingScreen +val LocalNostrViewModel = staticCompositionLocalOf { + error("No NostrViewModel provided") +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun App(dbPath: String) { @@ -44,7 +50,7 @@ fun App(dbPath: String) { } darkMode -> darkColorScheme() - else -> lightColorScheme() + else -> expressiveLightColorScheme() } LaunchedEffect(Unit) { @@ -55,65 +61,67 @@ fun App(dbPath: String) { MaterialExpressiveTheme( colorScheme = colorScheme, ) { - rememberCoroutineScope() - val navController = rememberNavController() - val hasSecret by viewModel.hasSecret.collectAsState(initial = null) + CompositionLocalProvider(LocalNostrViewModel provides viewModel) { + rememberCoroutineScope() + val navController = rememberNavController() + val hasSecret by viewModel.hasSecret.collectAsState(initial = null) - LaunchedEffect(hasSecret) { - // Navigate to the home screen if the secret is already set - if (hasSecret == true) { - // Start a background notification handler - viewModel.startNotificationHandler() - // Get chat rooms - viewModel.getChatRooms() - // Navigate to the home screen - navController.navigate(Screen.Home) { - popUpTo(Screen.Onboarding) { inclusive = true } + LaunchedEffect(hasSecret) { + // Navigate to the home screen if the secret is already set + if (hasSecret == true) { + // Start a background notification handler + viewModel.startNotificationHandler() + // Get chat rooms + viewModel.getChatRooms() + // Navigate to the home screen + navController.navigate(Screen.Home) { + popUpTo(Screen.Onboarding) { inclusive = true } + } } } - } - // Show loading screen while initializing - if (hasSecret == null) return@MaterialExpressiveTheme + // Show loading screen while initializing + if (hasSecret == null) return@CompositionLocalProvider - NavHost( - navController = navController, - startDestination = if (hasSecret == true) Screen.Home else Screen.Onboarding - ) { - composable { backStackEntry -> - OnboardingScreen( - onOpenImport = { navController.navigate(Screen.Import) }, - onOpenNew = { navController.navigate(Screen.NewIdentity) } - ) - } - composable { backStackEntry -> - val isCreating by viewModel.isCreating.collectAsState() + NavHost( + navController = navController, + startDestination = if (hasSecret == true) Screen.Home else Screen.Onboarding + ) { + composable { backStackEntry -> + OnboardingScreen( + onOpenImport = { navController.navigate(Screen.Import) }, + onOpenNew = { navController.navigate(Screen.NewIdentity) } + ) + } + composable { backStackEntry -> + val isCreating by viewModel.isCreating.collectAsState() - ImportScreen( - isLoading = isCreating, - onSave = { secret -> - viewModel.import(secret) - } - ) - } - composable { backStackEntry -> - val isCreating by viewModel.isCreating.collectAsState() + ImportScreen( + isLoading = isCreating, + onSave = { secret -> + viewModel.importIdentity(secret) + } + ) + } + composable { backStackEntry -> + val isCreating by viewModel.isCreating.collectAsState() - NewIdentityScreen( - isLoading = isCreating, - onSave = { name, bio, uri -> - viewModel.createIdentity(name, bio, uri?.toString()) - } - ) - } - composable { backStackEntry -> - HomeScreen( - onOpenChat = { id -> navController.navigate(Screen.Chat(id)) } - ) - } - composable { backStackEntry -> - val chat: Screen.Chat = backStackEntry.toRoute() - ChatScreen(id = chat.id) + NewIdentityScreen( + isLoading = isCreating, + onSave = { name, bio, uri -> + viewModel.createIdentity(name, bio, uri?.toString()) + } + ) + } + composable { backStackEntry -> + HomeScreen( + onOpenChat = { id -> navController.navigate(Screen.Chat(id)) } + ) + } + composable { backStackEntry -> + val chat: Screen.Chat = backStackEntry.toRoute() + ChatScreen(id = chat.id) + } } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt index e08a11c..521babf 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt @@ -7,7 +7,7 @@ sealed interface Screen { data object Home : Screen @Serializable - data class Chat(val id: String) : Screen + data class Chat(val id: Long) : Screen @Serializable data object Onboarding : Screen 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 5677e4b..bc9f2f3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @Composable -fun ChatScreen(id: String) { +fun ChatScreen(id: Long) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Chat Screen (ID: $id)") } 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 4173f86..195a12e 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -1,76 +1,151 @@ package su.reya.coop.screens +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.material3.AppBarWithSearch +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberSearchBarState +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch +import coil3.compose.AsyncImage +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_avatar +import coop.composeapp.generated.resources.ic_search +import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalNostrViewModel +import su.reya.coop.Room @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable -fun HomeScreen(onOpenChat: (String) -> Unit) { - val scope = rememberCoroutineScope() - val searchState = rememberSearchBarState() - val textState = rememberTextFieldState() - - val scrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior() - - val inputField = - @Composable { - SearchBarDefaults.InputField( - textFieldState = textState, - searchBarState = searchState, - onSearch = { scope.launch { searchState.animateToCollapsed() } }, - placeholder = { - Text( - modifier = Modifier.clearAndSetSemantics() {}, - text = "Find or start a conversation" - ) - }, - ) - } +fun HomeScreen(onOpenChat: (Long) -> Unit) { + val viewModel = LocalNostrViewModel.current + val userProfile by viewModel.getUserProfile().collectAsState(initial = null) + val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList()) Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.surfaceContainer, topBar = { - AppBarWithSearch( - state = searchState, - inputField = inputField, - scrollBehavior = scrollBehavior, + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + title = { + Text( + text = "Coop", + style = MaterialTheme.typography.titleMediumEmphasized + ) + }, + actions = { + // Search + IconButton(onClick = { /* TODO: Open search */ }) { + Icon( + painter = painterResource(Res.drawable.ic_search), + contentDescription = "Search" + ) + } + // User + IconButton(onClick = { /* TODO: Open profile */ }) { + if (userProfile?.asRecord()?.picture != null) { + AsyncImage( + model = userProfile?.asRecord()?.picture, + contentDescription = "User Avatar", + modifier = Modifier + .size(32.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } else { + Icon( + painter = painterResource(Res.drawable.ic_avatar), + contentDescription = "User" + ) + } + } + } ) }, content = { innerPadding -> - LazyColumn( + Surface( modifier = Modifier .fillMaxSize() - .padding(innerPadding), + .padding(top = innerPadding.calculateTopPadding()), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), ) { - items(count = 100) { index -> + if (chatRooms.isEmpty()) { Box( - modifier = Modifier - .fillMaxWidth() - .height(50.dp) + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { - Text("Chat $index") + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "No chats yet", + style = MaterialTheme.typography.titleLargeEmphasized, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Your conversations will appear here.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items(chatRooms.toList(), key = { it.id }) { room -> + ChatRoom( + room = room, + onClick = { onOpenChat(room.id) } + ) + } } } } }, ) } + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ChatRoom(room: Room, onClick: () -> Unit) { + val title = room.subject ?: "Room" + + ListItem( + modifier = Modifier.clickable { onClick }, + headlineContent = { + Text( + text = title, + style = MaterialTheme.typography.titleMediumEmphasized + ) + }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) +} + diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index dfa53e4..75169ba 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -39,6 +39,8 @@ class Nostr { private set var deviceSigner: NostrSigner? = null private set + var userPubkey: PublicKey? = null + private set var contactList: List = emptyList() private set @@ -82,13 +84,23 @@ class Nostr { } suspend fun setKeySigner(keys: Keys) { - signer = NostrSigner.keys(keys) - getUserMetadata() + try { + signer = NostrSigner.keys(keys) + userPubkey = signer?.getPublicKey() + getUserMetadata() + } catch (e: Exception) { + println("Failed to set signer: ${e.message}") + } } suspend fun setRemoteSigner(remote: NostrConnect) { - signer = NostrSigner.nostrConnect(remote) - getUserMetadata() + try { + signer = NostrSigner.nostrConnect(remote) + userPubkey = signer?.getPublicKey() + getUserMetadata() + } catch (e: Exception) { + println("Failed to set remote signer: ${e.message}") + } } suspend fun isSignedByUser(event: Event): Boolean { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 3447a9e..c75ac0c 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -90,6 +90,14 @@ class NostrViewModel( _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata } + fun getUserProfile(): StateFlow { + return try { + getMetadata(nostr.userPubkey!!) + } catch (e: Exception) { + MutableStateFlow(null) + } + } + fun initAndConnect(dbPath: String) { viewModelScope.launch { try { @@ -175,10 +183,11 @@ class NostrViewModel( } } - fun import(secret: String) { + fun importIdentity(secret: String) { // TODO: Implement import } + fun getChatRooms() { viewModelScope.launch { try {