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 {