In today's mobile-first world, users expect apps to work seamlessly, even when there's no internet connection. This blog post will guide you through the process of building an offline-capable Android app using Kotlin and Jetpack Compose. We'll use a ToDo app as our example to illustrate key concepts and best practices.
Architecture Overview
Before diving into the code, let's outline the architecture we'll use:
UI Layer: Jetpack Compose for the user interface
ViewModel: To manage UI-related data and business logic
Repository: To abstract data sources and manage data flow
Local Database: Room for local data persistence
Remote Data Source: Retrofit for API calls (when online)
WorkManager: For background synchronization
Setting Up the Kotlin Project
First, ensure you have the necessary dependencies in your build.gradle file:
dependencies {
implementation("androidx.core:core-ktx:1.10.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
implementation("androidx.activity:activity-compose:1.7.2")
implementation("androidx.compose.ui:ui:1.4.3")
implementation("androidx.compose.ui:ui-tooling-preview:1.4.3")
implementation("androidx.compose.material3:material3:1.1.1")
// Room
implementation("androidx.room:room-runtime:2.5.2")
implementation("androidx.room:room-ktx:2.5.2")
kapt("androidx.room:room-compiler:2.5.2")
// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// WorkManager
implementation("androidx.work:work-runtime-ktx:2.8.1")
}
Implementing the Local Database
We'll use Room to store ToDo items locally. First, define the entity:
@Entity(tableName = "todos")
data class ToDo(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val title: String,
val description: String,
val isCompleted: Boolean = false,
val lastModified: Long = System.currentTimeMillis()
)
Next, create the DAO (Data Access Object):
@Dao
interface ToDoDao {
@Query("SELECT * FROM todos")
fun getAllToDos(): Flow<List<ToDo>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertToDo(todo: ToDo)
@Update
suspend fun updateToDo(todo: ToDo)
@Delete
suspend fun deleteToDo(todo: ToDo)
}
Finally, set up the Room database:
@Database(entities = [ToDo::class], version = 1)
abstract class ToDoDatabase : RoomDatabase() {
abstract fun todoDao(): ToDoDao
companion object {
@Volatile
private var INSTANCE: ToDoDatabase? = null
fun getDatabase(context: Context): ToDoDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
ToDoDatabase::class.java,
"todo_database"
).build()
INSTANCE = instance
instance
}
}
}
}
Implementing the Repository
The repository will manage data operations and decide whether to fetch from the local database or the remote API:
class ToDoRepository(
private val todoDao: ToDoDao,
private val apiService: ApiService
) {
val allToDos: Flow<List<ToDo>> = todoDao.getAllToDos()
suspend fun refreshToDos() {
try {
val remoteToDos = apiService.getToDos()
todoDao.insertAll(remoteToDos)
} catch (e: Exception) {
// Handle network errors
}
}
suspend fun addToDo(todo: ToDo) {
todoDao.insertToDo(todo)
try {
apiService.addToDo(todo)
} catch (e: Exception) {
// Handle network errors, maybe queue for later sync
}
}
// Implement other CRUD operations similarly
}
Setting Up the ViewModel
The ViewModel will handle the UI logic and interact with the repository:
class ToDoViewModel(private val repository: ToDoRepository) : ViewModel() {
val todos = repository.allToDos.asLiveData()
fun addToDo(title: String, description: String) {
viewModelScope.launch {
val todo = ToDo(title = title, description = description)
repository.addToDo(todo)
}
}
fun refreshToDos() {
viewModelScope.launch {
repository.refreshToDos()
}
}
// Implement other operations
}
Creating the UI with Jetpack Compose
Now, let's create the UI for our ToDo app:
@Composable
fun ToDoScreen(viewModel: ToDoViewModel) {
val todos by viewModel.todos.collectAsState(initial = emptyList())
LazyColumn {
items(todos) { todo ->
ToDoItem(todo)
}
item {
AddToDoButton(viewModel)
}
}
}
@Composable
fun ToDoItem(todo: ToDo) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = todo.isCompleted,
onCheckedChange = { /* Update todo */ }
)
Column(modifier = Modifier.weight(1f)) {
Text(text = todo.title, fontWeight = FontWeight.Bold)
Text(text = todo.description)
}
}
}
}
@Composable
fun AddToDoButton(viewModel: ToDoViewModel) {
var showDialog by remember { mutableStateOf(false) }
Button(onClick = { showDialog = true }) {
Text("Add ToDo")
}
if (showDialog) {
AddToDoDialog(
onDismiss = { showDialog = false },
onConfirm = { title, description ->
viewModel.addToDo(title, description)
showDialog = false
}
)
}
}
@Composable
fun AddToDoDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
// Implement dialog UI here
}
Implementing Background Sync with WorkManager
To ensure our app stays up-to-date even when it's not actively running, we can use WorkManager for background synchronization:
class SyncWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
private val repository = ToDoRepository(
ToDoDatabase.getDatabase(context).todoDao(),
ApiService.create()
)
override suspend fun doWork(): Result {
return try {
repository.refreshToDos()
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
}
Schedule the work in your application class or main activity:
class ToDoApplication : Application() {
override fun onCreate() {
super.onCreate()
setupPeriodicSync()
}
private fun setupPeriodicSync() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
.setConstraints(constraints)
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"ToDo_Sync",
ExistingPeriodicWorkPolicy.KEEP,
syncRequest
)
}
}
Handling Conflicts
When working offline, conflicts may arise when syncing data. Implement a conflict resolution strategy:
suspend fun syncToDo(todo: ToDo) {
try {
val remoteToDo = apiService.getToDo(todo.id)
if (remoteToDo.lastModified > todo.lastModified) {
// Remote version is newer, update local
todoDao.insertToDo(remoteToDo)
} else {
// Local version is newer, update remote
apiService.updateToDo(todo)
}
} catch (e: Exception) {
// Handle network errors
}
}
Testing Offline Functionality
To ensure your app works offline:
Implement a network utility class to check connectivity.
Use this utility in your repository to decide whether to fetch from local or remote.
Write unit tests for your repository and ViewModel.
Perform UI tests with network on and off to verify behavior.
Conclusion
Building an offline-capable Android app requires careful consideration of data flow, synchronization, and conflict resolution. By using Room for local storage, Retrofit for API calls, and WorkManager for background sync, you can create a robust offline experience for your users.
Remember to handle edge cases, such as first-time app usage without internet, and always provide clear feedback to users about the sync status of their data.
Would you like me to explain or break down any part of this code?