diff --git a/CHANGELOG.md b/CHANGELOG.md index b22b7608..de64b8a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 1.0.0-BETA27 * Improved watch query internals. Added the ability to throttle watched queries. +* Fixed `uploading` and `downloading` sync status indicators. ## 1.0.0-BETA26 diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt index def2843e..e66e0882 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt @@ -207,7 +207,14 @@ class SyncIntegrationTest { SyncLine.FullCheckpoint( Checkpoint( lastOpId = "4", - checksums = listOf(BucketChecksum(bucket = "bkt", priority = BucketPriority(1), checksum = 0)), + checksums = + listOf( + BucketChecksum( + bucket = "bkt", + priority = BucketPriority(1), + checksum = 0, + ), + ), ), ), ) @@ -228,4 +235,39 @@ class SyncIntegrationTest { database.close() syncLines.close() } + + @Test + fun setsDownloadingState() = + runTest { + val syncStream = syncStream() + database.connect(syncStream, 1000L) + + turbineScope(timeout = 10.0.seconds) { + val turbine = database.currentStatus.asFlow().testIn(this) + turbine.waitFor { it.connected && !it.downloading } + + syncLines.send( + SyncLine.FullCheckpoint( + Checkpoint( + lastOpId = "1", + checksums = + listOf( + BucketChecksum( + bucket = "bkt", + checksum = 0, + ), + ), + ), + ), + ) + turbine.waitFor { it.downloading } + + syncLines.send(SyncLine.CheckpointComplete(lastOpId = "1")) + turbine.waitFor { !it.downloading } + turbine.cancel() + } + + database.close() + syncLines.close() + } } diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index b5bb5b2f..9ec1edf4 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -128,6 +128,7 @@ internal class PowerSyncDatabaseImpl( currentStatus.update( connected = it.connected, connecting = it.connecting, + uploading = it.uploading, downloading = it.downloading, lastSyncedAt = it.lastSyncedAt, hasSynced = it.hasSynced, diff --git a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt index 5f2413d5..23b3e5ed 100644 --- a/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt +++ b/core/src/commonMain/kotlin/com/powersync/sync/SyncStream.kt @@ -129,7 +129,6 @@ internal class SyncStream( var checkedCrudItem: CrudEntry? = null while (true) { - status.update(uploading = true) /** * This is the first item in the FIFO CRUD queue. */ @@ -146,6 +145,7 @@ internal class SyncStream( } checkedCrudItem = nextCrudItem + status.update(uploading = true) uploadCrud() } else { // Uploading is completed @@ -256,6 +256,8 @@ internal class SyncStream( state = handleInstruction(line, value, state) } + status.update(downloading = false) + return state } @@ -268,7 +270,12 @@ internal class SyncStream( is SyncLine.FullCheckpoint -> handleStreamingSyncCheckpoint(line, state) is SyncLine.CheckpointDiff -> handleStreamingSyncCheckpointDiff(line, state) is SyncLine.CheckpointComplete -> handleStreamingSyncCheckpointComplete(state) - is SyncLine.CheckpointPartiallyComplete -> handleStreamingSyncCheckpointPartiallyComplete(line, state) + is SyncLine.CheckpointPartiallyComplete -> + handleStreamingSyncCheckpointPartiallyComplete( + line, + state, + ) + is SyncLine.KeepAlive -> handleStreamingKeepAlive(line, state) is SyncLine.SyncDataBucket -> handleStreamingSyncData(line, state) SyncLine.UnknownSyncLine -> { @@ -283,6 +290,8 @@ internal class SyncStream( ): SyncStreamState { val (checkpoint) = line state.targetCheckpoint = checkpoint + status.update(downloading = true) + val bucketsToDelete = state.bucketSet!!.toMutableList() val newBuckets = mutableSetOf() @@ -323,7 +332,12 @@ internal class SyncStream( } state.validatedCheckpoint = state.targetCheckpoint - status.update(lastSyncedAt = Clock.System.now(), hasSynced = true, clearDownloadError = true) + status.update( + lastSyncedAt = Clock.System.now(), + downloading = false, + hasSynced = true, + clearDownloadError = true, + ) return state } @@ -374,6 +388,8 @@ internal class SyncStream( throw Exception("Checkpoint diff without previous checkpoint") } + status.update(downloading = true) + val newBuckets = mutableMapOf() state.targetCheckpoint!!.checksums.forEach { checksum -> @@ -410,6 +426,7 @@ internal class SyncStream( data: SyncLine.SyncDataBucket, state: SyncStreamState, ): SyncStreamState { + status.update(downloading = true) bucketStorage.saveSyncData(SyncDataBatch(listOf(data))) return state } diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/App.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/App.kt index 2a19a406..765f3ea7 100644 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/App.kt +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/App.kt @@ -24,20 +24,28 @@ import com.powersync.demos.screens.HomeScreen import com.powersync.demos.screens.SignInScreen import com.powersync.demos.screens.SignUpScreen import com.powersync.demos.screens.TodosScreen +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.runBlocking - @Composable -fun App(factory: DatabaseDriverFactory, modifier: Modifier = Modifier) { - val supabase = remember { - SupabaseConnector( - powerSyncEndpoint = Config.POWERSYNC_URL, - supabaseUrl = Config.SUPABASE_URL, - supabaseKey = Config.SUPABASE_ANON_KEY - ) - } +fun App( + factory: DatabaseDriverFactory, + modifier: Modifier = Modifier, +) { + val supabase = + remember { + SupabaseConnector( + powerSyncEndpoint = Config.POWERSYNC_URL, + supabaseUrl = Config.SUPABASE_URL, + supabaseKey = Config.SUPABASE_ANON_KEY, + ) + } val db = remember { PowerSyncDatabase(factory, schema) } - val status by db.currentStatus.asFlow().collectAsState(initial = db.currentStatus) + // Debouncing the status flow prevents flicker + val status by db.currentStatus + .asFlow() + .debounce(200) + .collectAsState(initial = db.currentStatus) // This assumes that the buckets for lists has a priority of 1 (but it will work fine with sync // rules not defining any priorities at all too). When giving lists a higher priority than @@ -48,9 +56,10 @@ fun App(factory: DatabaseDriverFactory, modifier: Modifier = Modifier) { } val navController = remember { NavController(Screen.Home) } - val authViewModel = remember { - AuthViewModel(supabase, db, navController) - } + val authViewModel = + remember { + AuthViewModel(supabase, db, navController) + } val authState by authViewModel.authState.collectAsState() val currentScreen by navController.currentScreen.collectAsState() @@ -81,7 +90,7 @@ fun App(factory: DatabaseDriverFactory, modifier: Modifier = Modifier) { when (currentScreen) { is Screen.Home -> { - if(authState == AuthState.SignedOut) { + if (authState == AuthState.SignedOut) { navController.navigate(Screen.SignIn) } @@ -93,14 +102,13 @@ fun App(factory: DatabaseDriverFactory, modifier: Modifier = Modifier) { HomeScreen( modifier = modifier.background(MaterialTheme.colors.background), items = items, - isConnected = status.connected, onSignOutSelected = { handleSignOut() }, inputText = listsInputText, onItemClicked = handleOnItemClicked, onItemDeleteClicked = lists.value::onItemDeleteClicked, onAddItemClicked = lists.value::onAddItemClicked, onInputTextChanged = lists.value::onInputTextChanged, - hasSynced = hasSyncedLists + syncStatus = status, ) } @@ -113,7 +121,7 @@ fun App(factory: DatabaseDriverFactory, modifier: Modifier = Modifier) { modifier = modifier.background(MaterialTheme.colors.background), navController = navController, items = todoItems, - isConnected = status.connected, + syncStatus = status, inputText = todosInputText, onItemClicked = todos.value::onItemClicked, onItemDoneChanged = todos.value::onItemDoneChanged, @@ -133,24 +141,24 @@ fun App(factory: DatabaseDriverFactory, modifier: Modifier = Modifier) { } is Screen.SignIn -> { - if(authState == AuthState.SignedIn) { + if (authState == AuthState.SignedIn) { navController.navigate(Screen.Home) } SignInScreen( navController, - authViewModel + authViewModel, ) } is Screen.SignUp -> { - if(authState == AuthState.SignedIn) { + if (authState == AuthState.SignedIn) { navController.navigate(Screen.Home) } SignUpScreen( navController, - authViewModel + authViewModel, ) } } diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/components/WifiIcon.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/components/WifiIcon.kt index 8985fd2a..892e6023 100644 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/components/WifiIcon.kt +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/components/WifiIcon.kt @@ -2,20 +2,29 @@ package com.powersync.demos.components import androidx.compose.material.Icon import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.CloudSync +import androidx.compose.material.icons.filled.LeakAdd +import androidx.compose.material.icons.filled.Thunderstorm import androidx.compose.runtime.Composable +import com.powersync.sync.SyncStatusData @Composable -fun WifiIcon(isConnected: Boolean) { - val icon = if (isConnected) { - Icons.Filled.Wifi - } else { - Icons.Filled.WifiOff - } +fun WifiIcon(status: SyncStatusData) { + val icon = + when { + status.downloading || status.uploading -> Icons.Filled.CloudSync + status.connected -> Icons.Filled.Cloud + !status.connected -> Icons.Filled.CloudOff + status.connecting -> Icons.Filled.LeakAdd + else -> { + Icons.Filled.Thunderstorm + } + } Icon( imageVector = icon, - contentDescription = if (isConnected) "Online" else "Offline", + contentDescription = status.toString(), ) -} \ No newline at end of file +} diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/HomeScreen.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/HomeScreen.kt index c2f17f5e..c543be58 100644 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/HomeScreen.kt +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/HomeScreen.kt @@ -22,14 +22,14 @@ import com.powersync.demos.components.ListContent import com.powersync.demos.components.Menu import com.powersync.demos.components.WifiIcon import com.powersync.demos.powersync.ListItem +import com.powersync.sync.SyncStatusData @Composable internal fun HomeScreen( modifier: Modifier = Modifier, items: List, inputText: String, - isConnected: Boolean, - hasSynced: Boolean?, + syncStatus: SyncStatusData, onSignOutSelected: () -> Unit, onItemClicked: (item: ListItem) -> Unit, onItemDeleteClicked: (item: ListItem) -> Unit, @@ -40,46 +40,49 @@ internal fun HomeScreen( TopAppBar( title = { Text( - "Todo Lists", - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(end = 36.dp) - ) }, - navigationIcon = { Menu( - true, - onSignOutSelected - ) }, + "Todo Lists", + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(end = 36.dp), + ) + }, + navigationIcon = { + Menu( + true, + onSignOutSelected, + ) + }, actions = { - WifiIcon(isConnected) + WifiIcon(syncStatus) Spacer(modifier = Modifier.width(16.dp)) - } + }, ) when { - hasSynced == null || hasSynced == false -> { - Box( - modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background), - contentAlignment = Alignment.Center - ) { - Text( - text = "Busy with initial sync...", - style = MaterialTheme.typography.h6 - ) - } + syncStatus.hasSynced == null || syncStatus.hasSynced == false -> { + Box( + modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.background), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Busy with initial sync...", + style = MaterialTheme.typography.h6, + ) } - else -> { + } + else -> { Input( text = inputText, onAddClicked = onAddItemClicked, onTextChanged = onInputTextChanged, - screen = Screen.Home + screen = Screen.Home, ) Box(Modifier.weight(1F)) { ListContent( items = items, onItemClicked = onItemClicked, - onItemDeleteClicked = onItemDeleteClicked + onItemDeleteClicked = onItemDeleteClicked, ) } } diff --git a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/TodosScreen.kt b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/TodosScreen.kt index b407f994..7e383c97 100644 --- a/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/TodosScreen.kt +++ b/demos/supabase-todolist/shared/src/commonMain/kotlin/com/powersync/demos/screens/TodosScreen.kt @@ -12,19 +12,17 @@ import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.ArrowBack 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.unit.dp import com.powersync.demos.NavController import com.powersync.demos.Screen import com.powersync.demos.components.Input -import com.powersync.demos.components.Menu import com.powersync.demos.components.TodoList import com.powersync.demos.components.WifiIcon import com.powersync.demos.powersync.TodoItem +import com.powersync.sync.SyncStatusData @Composable internal fun TodosScreen( @@ -32,7 +30,7 @@ internal fun TodosScreen( navController: NavController, items: List, inputText: String, - isConnected: Boolean, + syncStatus: SyncStatusData, onItemClicked: (item: TodoItem) -> Unit, onItemDoneChanged: (item: TodoItem, isDone: Boolean) -> Unit, onItemDeleteClicked: (item: TodoItem) -> Unit, @@ -45,24 +43,25 @@ internal fun TodosScreen( Text( "Todo List", textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(end = 36.dp) - ) }, + modifier = Modifier.fillMaxWidth().padding(end = 36.dp), + ) + }, navigationIcon = { IconButton(onClick = { navController.navigate(Screen.Home) }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Go back") } }, actions = { - WifiIcon(isConnected) + WifiIcon(syncStatus) Spacer(modifier = Modifier.width(16.dp)) - } + }, ) Input( text = inputText, onAddClicked = onAddItemClicked, onTextChanged = onInputTextChanged, - screen = Screen.Todos + screen = Screen.Todos, ) Box(Modifier.weight(1F)) { @@ -70,7 +69,7 @@ internal fun TodosScreen( items = items, onItemClicked = onItemClicked, onItemDoneChanged = onItemDoneChanged, - onItemDeleteClicked = onItemDeleteClicked + onItemDeleteClicked = onItemDeleteClicked, ) } } diff --git a/gradle.properties b/gradle.properties index f495ba79..e93b97f9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,7 +17,7 @@ development=true RELEASE_SIGNING_ENABLED=true # Library config GROUP=com.powersync -LIBRARY_VERSION=1.0.0-BETA26 +LIBRARY_VERSION=1.0.0-BETA27 GITHUB_REPO=https://github.com/powersync-ja/powersync-kotlin.git # POM POM_URL=https://github.com/powersync-ja/powersync-kotlin/