diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 1db42c0..2a03a37 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -23,6 +23,7 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") implementation("androidx.datastore:datastore-preferences:1.2.1") implementation("androidx.datastore:datastore-preferences-core:1.2.1") + implementation("org.jetbrains.compose.material3:material3*:1.10.0-alpha05") } commonMain.dependencies { implementation(libs.compose.runtime) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 42222e4..f9aeb8f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -1,72 +1,53 @@ package su.reya.coop -import android.content.Context import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +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.ui.platform.LocalContext -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.preferencesDataStore +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch - -private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") -private val FIRST_TIME_KEY = booleanPreferencesKey("first_time") +import kotlinx.coroutines.flow.flow +import su.reya.coop.coop.storage.SecretStore @Composable -fun App() { +fun App(dbPath: String) { + val context = LocalContext.current + val nostr = remember { Nostr() } + val secretStore = remember { SecretStore(context) } + val viewModel: NostrViewModel = viewModel { NostrViewModel(nostr, secretStore) } + + LaunchedEffect(Unit) { + viewModel.initAndConnect(dbPath) + } + MaterialTheme { - val context = LocalContext.current - val scope = rememberCoroutineScope() + rememberCoroutineScope() val navController = rememberNavController() - val isFirstTimeFlow = remember { - context.dataStore.data.map { preferences -> - preferences[FIRST_TIME_KEY] ?: true + // Get user's signer status + val hasSecretFlow = remember { + flow { + emit(secretStore.has("user_signer")) } } - val isFirstTime by isFirstTimeFlow.collectAsState(initial = null) + val hasSecret by hasSecretFlow.collectAsState(initial = null) - if (isFirstTime == null) { + if (hasSecret == null) { // Loading state return@MaterialTheme } NavHost( navController = navController, - startDestination = if (isFirstTime == true) Screen.Welcome else Screen.Home + startDestination = if (hasSecret == true) Screen.Onboarding else Screen.Home ) { - composable { backStackEntry -> - WelcomeScreen(onContinue = { - scope.launch { - context.dataStore.edit { settings -> - settings[FIRST_TIME_KEY] = false - } - navController.navigate(Screen.Home) { - popUpTo { inclusive = true } - } - } - }) - } - composable { backStackEntry -> - HomeScreen( - onOpenChat = { id -> navController.navigate(Screen.Chat(id)) } - ) - } - composable { backStackEntry -> - val chat: Screen.Chat = backStackEntry.toRoute() - ChatScreen(id = chat.id) - } composable { backStackEntry -> OnboardingScreen( onOpenImport = { navController.navigate(Screen.Import) }, @@ -79,6 +60,15 @@ fun App() { composable { backStackEntry -> NewScreen() } + composable { backStackEntry -> + HomeScreen( + onOpenChat = { id -> navController.navigate(Screen.Chat(id)) } + ) + } + composable { backStackEntry -> + val chat: Screen.Chat = backStackEntry.toRoute() + ChatScreen(id = chat.id) + } } } } \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 2ba9751..d2240f7 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -6,13 +6,9 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.launch import java.io.File class MainActivity : ComponentActivity() { - private val nostr = Nostr() - override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -21,16 +17,8 @@ class MainActivity : ComponentActivity() { val dbDir = File(filesDir, "nostr") dbDir.mkdirs() - // Initialize nostr client - nostr.init(dbDir.absolutePath) - - // Connect to bootstrap relays - lifecycleScope.launch { - nostr.connect() - } - setContent { - App() + App(dbDir.absolutePath) } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt index 47ff239..fdf32ad 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt @@ -14,9 +14,6 @@ import androidx.compose.ui.unit.dp import kotlinx.serialization.Serializable sealed interface Screen { - @Serializable - data object Welcome : Screen - @Serializable data object Home : Screen @@ -33,19 +30,6 @@ sealed interface Screen { data object New : Screen } -@Composable -fun WelcomeScreen(onContinue: () -> Unit) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Welcome Screen") - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = onContinue) { - Text("Get Started") - } - } - } -} - @Composable fun HomeScreen(onOpenChat: (String) -> Unit) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretStore.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretStore.kt index e376167..37ffc47 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretStore.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/storage/SecretStore.kt @@ -5,13 +5,14 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.first +import su.reya.coop.storage.SecretStorage private val Context.dataStore by preferencesDataStore("secret_store") -class SecretStore(private val context: Context) { +class SecretStore(private val context: Context) : SecretStorage { private val crypto = SecretCrypto() - suspend fun set(key: String, value: String) { + override suspend fun set(key: String, value: String) { val entry = crypto.encrypt(value) context.dataStore.edit { prefs -> @@ -20,7 +21,7 @@ class SecretStore(private val context: Context) { } } - suspend fun get(key: String): String? { + override suspend fun get(key: String): String? { val prefs = context.dataStore.data.first() val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null @@ -28,15 +29,15 @@ class SecretStore(private val context: Context) { return crypto.decrypt(SecretEntry(encrypted, iv)) } - suspend fun clear(name: String) { + override suspend fun clear(key: String) { context.dataStore.edit { prefs -> - prefs.remove(stringPreferencesKey("${name}_encrypted")) - prefs.remove(stringPreferencesKey("${name}_iv")) + prefs.remove(stringPreferencesKey("${key}_encrypted")) + prefs.remove(stringPreferencesKey("${key}_iv")) } } - suspend fun has(name: String): Boolean { + override suspend fun has(key: String): Boolean { val prefs = context.dataStore.data.first() - return prefs[stringPreferencesKey("${name}_encrypted")] != null + return prefs[stringPreferencesKey("${key}_encrypted")] != null } } \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 84dfc7b..c410d30 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -24,6 +24,8 @@ kotlin { sourceSets { commonMain.dependencies { + implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("org.rust-nostr:nostr-sdk-kmp:0.44.3") } commonTest.dependencies { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Greeting.kt b/shared/src/commonMain/kotlin/su/reya/coop/Greeting.kt deleted file mode 100644 index de9e7c4..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/Greeting.kt +++ /dev/null @@ -1,9 +0,0 @@ -package su.reya.coop - -class Greeting { - private val platform = getPlatform() - - fun greet(): String { - return "Hello, ${platform.name}!" - } -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 360aa97..95777ca 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -25,4 +25,8 @@ class Nostr { this.client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) this.client?.connect() } + + suspend fun disconnect() { + this.client?.shutdown() + } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt new file mode 100644 index 0000000..c56f4b0 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -0,0 +1,44 @@ +package su.reya.coop + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import su.reya.coop.storage.SecretStorage + +class NostrViewModel( + private val nostr: Nostr, + private val secretStore: SecretStorage +) : ViewModel() { + private val _isConnected = MutableStateFlow(false) + val isConnected = _isConnected.asStateFlow() + + fun initAndConnect(dbPath: String) { + // Initialize nostr client + nostr.init(dbPath) + + viewModelScope.launch { + try { + // Connect to bootstrap relays + nostr.connect() + _isConnected.value = true + } catch (e: Exception) { + _isConnected.value = false + println(e) + } + } + } + + override fun onCleared() { + super.onCleared() + // Ensure all relays are disconnect + viewModelScope.launch { + withContext(NonCancellable) { + nostr.disconnect() + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Platform.kt b/shared/src/commonMain/kotlin/su/reya/coop/Platform.kt deleted file mode 100644 index 55add4c..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/Platform.kt +++ /dev/null @@ -1,7 +0,0 @@ -package su.reya.coop - -interface Platform { - val name: String -} - -expect fun getPlatform(): Platform \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/storage/SecretStorage.kt b/shared/src/commonMain/kotlin/su/reya/coop/storage/SecretStorage.kt new file mode 100644 index 0000000..177dc00 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/storage/SecretStorage.kt @@ -0,0 +1,8 @@ +package su.reya.coop.storage + +interface SecretStorage { + suspend fun get(key: String): String? + suspend fun set(key: String, value: String) + suspend fun clear(key: String) + suspend fun has(key: String): Boolean +} \ No newline at end of file