Introduce navigationViewModel

This commit is contained in:
Kgothatso Ngako
2026-03-26 02:52:08 +02:00
parent 3dea688eea
commit 4f9465a204
22 changed files with 447 additions and 66 deletions

View File

@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "61eb893d34ad04f95f8b007f8448ad7f",
"identityHash": "80c7032502d17a1893ea8dc67debd365",
"entities": [
{
"tableName": "BroadcastNostrEventReceipt",
@@ -221,7 +221,27 @@
"columnNames": [
"id"
]
}
},
"indices": [
{
"name": "index_NostrEvent_kind",
"unique": false,
"columnNames": [
"kind"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_kind` ON `${TABLE_NAME}` (`kind`)"
},
{
"name": "index_NostrEvent_pubKey",
"unique": false,
"columnNames": [
"pubKey"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)"
}
]
},
{
"tableName": "Post",
@@ -1075,7 +1095,7 @@
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '61eb893d34ad04f95f8b007f8448ad7f')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '80c7032502d17a1893ea8dc67debd365')"
]
}
}

View File

@@ -7,6 +7,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.quartz.utils.platform
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -17,7 +18,12 @@ class MainActivity : ComponentActivity() {
val navController = rememberNavController()
AuxApp(
navController
navController = navController,
auxGlobal = AuxGlobal(
platformContext = PlatformContext(
applicationContext = applicationContext
)
)
)
}
}

View File

@@ -12,17 +12,16 @@ import androidx.navigation.NavHostController
@Composable
fun AuxApp(
navController: NavHostController,
auxGlobal: AuxGlobal
) {
AuxTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
AuxNavHost(
navController = navController,
startDestination = LandingRoute
auxGlobal = auxGlobal,
navController = navController
)
}
}
}

View File

@@ -23,6 +23,8 @@ import ac.aux.compose.database.model.Zap
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.immediateTransaction
import androidx.room.useWriterConnection
@Database(
entities = [
@@ -52,4 +54,12 @@ abstract class AuxDatabase: RoomDatabase() {
abstract fun unsignedNostrEventDao(): UnsignedNostrEventDao
abstract fun zapDao(): ZapDao
suspend fun wipeData() {
useWriterConnection { transactor ->
transactor.immediateTransaction {
// TODO: Actually delete the data...
}
}
}
}

View File

@@ -14,6 +14,7 @@ import androidx.room.Transaction
abstract class NostrDao(
private val database: AuxDatabase
) {
@Transaction
open suspend fun insertNostrEvent(
nostrEvent: NostrEvent,

View File

@@ -3,10 +3,14 @@ package ac.aux.compose.database.dao
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Post
import ac.aux.compose.database.model.Profile
import ac.aux.compose.database.model.UnsignedNostrEvent
import ac.aux.compose.database.model.intermdiate.LocalProfile
import androidx.room.Dao
import androidx.room.Embedded
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Relation
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@@ -16,7 +20,7 @@ interface ProfileDao {
fun getAllPosts(): List<Profile>
@Query("SELECT * FROM Profile WHERE publicKey = :publicKey")
fun getProfileByPublicKey(publicKey: String): Flow<Profile?>
fun getProfileByPublicKey(publicKey: String): Profile?
@Insert(
onConflict = OnConflictStrategy.IGNORE
@@ -25,4 +29,8 @@ interface ProfileDao {
@Upsert
suspend fun upsert(profile: Profile)
@Query("SELECT * FROM NostrEvent WHERE kind = 0 AND pubKey = :publicKey")
fun observeProfile(publicKey: String): Flow<LocalProfile?>
}

View File

@@ -6,6 +6,7 @@ import ac.aux.compose.database.model.traits.SoftDeletableEntity
import ac.aux.compose.database.model.traits.TimestampedEntity
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.Index
import androidx.room.PrimaryKey
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -21,7 +22,12 @@ import kotlin.String
import kotlin.time.Clock
import kotlin.time.Instant
@Entity
@Entity(
indices = [
Index("kind"),
Index("pubKey"),
]
)
data class NostrEvent(
@PrimaryKey
val id: String,

View File

@@ -0,0 +1,16 @@
package ac.aux.compose.database.model.intermdiate
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Profile
import androidx.room.Embedded
import androidx.room.Relation
data class LocalProfile(
@Embedded val nostrEvent: NostrEvent,
@Relation(
parentColumn = "pubKey",
entityColumn = "publicKey"
)
val profile: Profile?
)

View File

@@ -4,15 +4,21 @@ import ac.aux.compose.database.AuxDatabase
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Profile
import ac.aux.compose.database.model.UnsignedNostrEvent
import ac.aux.compose.database.model.intermdiate.LocalProfile
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.ui.view.state.CreateProfileUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
class DatabaseNostrRepository(
private val database: AuxDatabase
): NostrRepository {
override suspend fun observeProfile(publicKey: HexKey): Flow<LocalProfile?> {
return database.profileDao().observeProfile(publicKey)
}
override suspend fun createNewProfile(
name: String,
biography: String,
@@ -76,4 +82,8 @@ class DatabaseNostrRepository(
null
}
override suspend fun wipeDatabase() {
database.wipeData()
}
}

View File

@@ -0,0 +1,18 @@
package ac.aux.compose.managers
import ac.aux.compose.AuxGlobal
import ac.aux.compose.database.AuxDatabaseConstructor
import ac.aux.compose.database.builder.PlatformDatabaseBuilder
import ac.aux.compose.database.builder.getRoomDatabase
class DatabaseManager(
auxGlobal: AuxGlobal
) {
val auxDatabase by lazy {
getRoomDatabase(
PlatformDatabaseBuilder.getDatabaseBuilder(
auxGlobal.platformContext
)
)
}
}

View File

@@ -1,11 +1,17 @@
package ac.aux.compose.repository
import ac.aux.compose.database.dao.ProfileDao
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Profile
import ac.aux.compose.database.model.UnsignedNostrEvent
import ac.aux.compose.database.model.intermdiate.LocalProfile
import ac.aux.compose.ui.view.state.CreateProfileUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.Flow
interface NostrRepository {
suspend fun observeProfile(publicKey: HexKey): Flow<LocalProfile?>
suspend fun createNewProfile(
name: String,
biography: String,
@@ -16,4 +22,31 @@ interface NostrRepository {
)
suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent?
suspend fun wipeDatabase()
companion object {
val NO_OP_NOSTR_REPOSITORY = object : NostrRepository {
override suspend fun observeProfile(publicKey: HexKey): Flow<LocalProfile?> {
TODO("")
}
override suspend fun createNewProfile(
name: String,
biography: String,
onError: () -> Unit,
onUnsignProfile: (UnsignedNostrEvent) -> Unit,
onUnbroadcastedProfile: (NostrEvent) -> Unit,
onProfileReady: (Profile) -> Unit
) {
}
override suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent? {
return null
}
override suspend fun wipeDatabase() {
}
}
}
}

View File

@@ -46,7 +46,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
fun CreateProfileScreen(
initialCreateProfileUIState: CreateProfileUIState = CreateProfileUIState.Declaration,
onNavigateToSocialPreconditionRoute: () -> Unit,
onEndThis: () -> Unit,
onNavigateToEndThis: () -> Unit,
nostrRepository: NostrRepository
) {
val createProfileViewModel: CreateProfileViewModel = viewModel (
@@ -386,7 +386,9 @@ fun CreateProfileScreen(
),
onClick = {
// TODO: Delete everything and close the app
onEndThis.invoke()
createProfileViewModel.wipeDatabase {
onNavigateToEndThis.invoke()
}
}
) {
Text(
@@ -450,23 +452,8 @@ fun CreateAccountScreenPreview() {
)
),
onNavigateToSocialPreconditionRoute = {},
onEndThis = {},
nostrRepository = object : NostrRepository {
override suspend fun createNewProfile(
name: String,
biography: String,
onError: () -> Unit,
onUnsignProfile: (UnsignedNostrEvent) -> Unit,
onUnbroadcastedProfile: (NostrEvent) -> Unit,
onProfileReady: (Profile) -> Unit
) {
TODO("Not yet implemented")
}
override suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent? {
TODO("Not yet implemented")
}
})
onNavigateToEndThis = {},
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY)
}
}
}

View File

@@ -0,0 +1,48 @@
package ac.aux.compose.ui.composable
import ac.aux.compose.ui.composable.widgets.LoadingDataIndicator
import ac.aux.compose.ui.theme.AuxTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun LoadingScreen(
) {
Scaffold { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
LoadingDataIndicator()
}
}
}
@Preview
@Composable
private fun LoadingScreenPreview() {
AuxTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
LoadingScreen(
)
}
}
}

View File

@@ -1,19 +1,21 @@
package ac.aux.compose.ui.composable.navigation
import ac.aux.compose.AuxGlobal
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Profile
import ac.aux.compose.database.model.UnsignedNostrEvent
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.database.repository.DatabaseNostrRepository
import ac.aux.compose.managers.DatabaseManager
import ac.aux.compose.ui.composable.CreateProfileScreen
import ac.aux.compose.ui.composable.FeedScreen
import ac.aux.compose.ui.composable.ImplementationPendingScreen
import ac.aux.compose.ui.composable.LandingScreen
import ac.aux.compose.ui.composable.LoadingScreen
import ac.aux.compose.ui.composable.SocialPreconditionScreen
import ac.aux.compose.ui.composable.navigation.routes.BlankRoute
import ac.aux.compose.ui.composable.navigation.routes.CreateProfileRoute
import ac.aux.compose.ui.composable.navigation.routes.FeedRoute
import ac.aux.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import ac.aux.compose.ui.composable.navigation.routes.LandingRoute
import ac.aux.compose.ui.composable.navigation.routes.LoadingRoute
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
@@ -22,24 +24,98 @@ import androidx.navigation.toRoute
import ac.aux.compose.ui.composable.navigation.routes.Route
import ac.aux.compose.ui.composable.navigation.routes.SocialPreconditionRoute
import ac.aux.compose.ui.composable.widgets.feed.DUMMY_EVENTS
import ac.aux.compose.ui.view.model.NavigationViewModel
import ac.aux.compose.ui.view.state.CreateProfileUIState
import ac.aux.compose.ui.view.state.FeedListUIState
import androidx.compose.foundation.layout.Column
import ac.aux.compose.ui.view.state.NavigationUIState
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.delay
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun AuxNavHost(
navController: NavHostController,
startDestination: Route
auxGlobal: AuxGlobal,
navController: NavHostController
) {
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
val databaseManager = DatabaseManager(auxGlobal)
val nostrRepository = DatabaseNostrRepository(
database = databaseManager.auxDatabase
)
val navigationViewModel: NavigationViewModel = viewModel (
factory = NavigationViewModel.factory(
NavigationUIState.Landing,
nostrRepository
)
)
LaunchedEffect(lifecycleOwner) {
navigationViewModel.navigationUIState.collect { state ->
when (state) {
is NavigationUIState.Error -> {
navController.navigate(
route = ImplementationPendingRoute("Error screen")
) {
popUpTo(0)
}
}
is NavigationUIState.Loading -> {
navController.navigate(
route = LoadingRoute
) {
popUpTo(0)
}
}
is NavigationUIState.Landing -> {
navController.navigate(
route = LandingRoute
) {
popUpTo(0)
}
}
is NavigationUIState.UnbroadcastedProfile -> {
navController.navigate(
route = CreateProfileRoute(
nostrEventId = state.nostrEvent.id
)
) {
popUpTo(0)
}
}
is NavigationUIState.ProfileLoaded -> {
navController.navigate(
route = FeedRoute
) {
popUpTo(0)
}
}
else -> {
navController.navigate(
route = ImplementationPendingRoute("Unsupported $state")
) {
popUpTo(0)
}
}
}
}
}
NavHost(
navController = navController,
startDestination = startDestination
startDestination = LoadingRoute
) {
composable<LoadingRoute> {
LoadingScreen()
}
composable<LandingRoute> {
LandingScreen(
onNavigateToLearnMore = {
@@ -54,43 +130,34 @@ fun AuxNavHost(
},
onNavigateToCreateProfile = {
navController.navigate(
route = CreateProfileRoute
route = CreateProfileRoute()
)
}
)
}
composable<CreateProfileRoute> {
composable<CreateProfileRoute> { backStackEntry ->
val route = backStackEntry.toRoute<CreateProfileRoute>()
CreateProfileScreen(
initialCreateProfileUIState = route.toPlaceholderNostrEvent()?.let { placeholderNostrEvent ->
CreateProfileUIState.UnbroadcastedProfile(
placeholderNostrEvent
)
} ?: CreateProfileUIState.Declaration,
onNavigateToSocialPreconditionRoute = {
navController.navigate(
route = SocialPreconditionRoute
)
},
onEndThis = {
// TODO: Delete everything and close the app
onNavigateToEndThis = {
navController.navigate(
route = BlankRoute
) {
popUpTo(0)
}
},
nostrRepository = object : NostrRepository {
override suspend fun createNewProfile(
name: String,
biography: String,
onError: () -> Unit,
onUnsignProfile: (UnsignedNostrEvent) -> Unit,
onUnbroadcastedProfile: (NostrEvent) -> Unit,
onProfileReady: (Profile) -> Unit
) {
}
override suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent? {
delay(2_100)
return unsignedNostrEvent
}
})
nostrRepository = nostrRepository
)
}
composable<SocialPreconditionRoute> {
SocialPreconditionScreen(

View File

@@ -1,7 +1,24 @@
package ac.aux.compose.ui.composable.navigation.routes
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Profile
import kotlinx.serialization.Serializable
import kotlin.time.Clock
@Serializable
object CreateProfileRoute: Route() {
data class CreateProfileRoute(
val nostrEventId: String? = null,
): Route() {
fun toPlaceholderNostrEvent(): NostrEvent? = nostrEventId?.let {
NostrEvent(
id = nostrEventId,
pubKey = "",
createdAt = Clock.System.now(),
tags = emptyArray(),
kind = 0,
sig = "",
content = ""
)
}
}

View File

@@ -0,0 +1,6 @@
package ac.aux.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
@Serializable
object LoadingRoute: Route()

View File

@@ -90,8 +90,6 @@ class CreateProfileViewModel(
onUnbroadcastedProfile = { unbroadcastedNostrEvent ->
createProfileUIState.value = CreateProfileUIState.UnbroadcastedProfile(
nostrEvent = unbroadcastedNostrEvent,
name = createProfileFormState.nameField.text.value,
bio = createProfileFormState.biographyField.text.value
)
},
onProfileReady = { profile ->
@@ -102,4 +100,13 @@ class CreateProfileViewModel(
)
}
}
fun wipeDatabase(
onNavigateToEndThis: () -> Unit
) {
viewModelScope.launch {
nostrRepository.wipeDatabase()
onNavigateToEndThis.invoke()
}
}
}

View File

@@ -0,0 +1,89 @@
package ac.aux.compose.ui.view.model
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.ui.view.state.NavigationUIState
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.random.Random
class NavigationViewModel(
initialNavigationUIState: NavigationUIState,
val nostrRepository: NostrRepository
): ViewModel() {
companion object {
private const val TAG = "NavigationViewModel"
fun factory(
initialNavigationUIState: NavigationUIState,
nostrRepository: NostrRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
NavigationViewModel(
initialNavigationUIState = initialNavigationUIState,
nostrRepository = nostrRepository
)
}
}
}
val tempDevelopmentKeyPair = KeyPair(
privKey = Random(21_000_000L).nextBytes(32)
)
private val _navigationUIState = MutableStateFlow(
initialNavigationUIState
)
val navigationUIState = _navigationUIState.asStateFlow()
init {
observeProfile()
}
private val logger = Logger.withTag(TAG)
private fun observeProfile() {
viewModelScope.launch {
nostrRepository.observeProfile(tempDevelopmentKeyPair.pubKey.toHexKey()).map { localProfile ->
_navigationUIState.getAndUpdate {
if (localProfile == null) {
NavigationUIState.Landing
} else if (localProfile.profile != null) {
NavigationUIState.ProfileLoaded(
profile = localProfile.profile
)
} else if (localProfile.nostrEvent != null) {
NavigationUIState.UnbroadcastedProfile(
nostrEvent = localProfile.nostrEvent
)
// TODO: Support unsignedNostrEvent with pubKey so we can have multi account support...
// } else if (localProfile.nostrEvent != null) {
// NavigationUIState.UnbroadcastedProfile(
// nostrEvent = localProfile.nostrEvent
// )
} else {
NavigationUIState.Error
}
}
}.catch {
NavigationUIState.Error
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NavigationUIState.Landing)
}
}
}

View File

@@ -24,8 +24,6 @@ abstract class CreateProfileUIState {
data class UnbroadcastedProfile(
val nostrEvent: NostrEvent,
val name: String,
val bio: String
) : CreateProfileUIState()
data class ProfileReady(

View File

@@ -0,0 +1,25 @@
package ac.aux.compose.ui.view.state
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Profile
import ac.aux.compose.database.model.UnsignedNostrEvent
abstract class NavigationUIState {
data object Error: NavigationUIState()
data object Loading: NavigationUIState()
data object Landing: NavigationUIState()
data class UnsignedProfile(
val unsignedNostrEvent: UnsignedNostrEvent,
) : NavigationUIState()
data class UnbroadcastedProfile(
val nostrEvent: NostrEvent,
) : NavigationUIState()
data class ProfileLoaded(
val profile: Profile
) : NavigationUIState()
}

View File

@@ -6,5 +6,10 @@ import androidx.navigation.compose.rememberNavController
fun MainViewController() = ComposeUIViewController {
val navController = rememberNavController()
AuxApp(navController)
AuxApp(
navController = navController,
auxGlobal = AuxGlobal(
platformContext = PlatformContext()
)
)
}

View File

@@ -10,6 +10,11 @@ fun main() = application {
title = "Aux",
) {
val navController = rememberNavController()
AuxApp(navController)
AuxApp(
navController = navController,
auxGlobal = AuxGlobal(
platformContext = PlatformContext()
)
)
}
}