The Problem: Too Many Ads, Not Enough Features
I spent weeks trying different note-taking apps on the Play Store. Every single one had the same issues: intrusive ads, subscription paywalls, or missing basic features like proper list management. I wanted something simple - create lists with checkboxes, write quick notes, encrypt sensitive data, and organize everything without distractions.
So I built it myself. And along the way, I learned a ton about modern Android development, proper architecture patterns, and what it takes to ship a polished app.
Architecture First: The MVVM Foundation
Before writing a single line of UI code, I mapped out the architecture. I knew from previous projects that rushing into implementation without a solid foundation leads to spaghetti code six months down the road.
The DListItem Model: One Entity to Rule Them All
Here's where things got interesting. I initially planned separate entities for Lists and Notes - seemed logical, right? Different data structures, different use cases. But after sketching out the database schema, I realized they shared 90% of their properties:
@Entity(tableName = "d_list_item")
data class DListItem(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val content: String,
val createdAt: Long,
val updatedAt: Long,
val isArchived: Boolean = false,
val isPinned: Boolean = false,
val selected: Int = -1,
val color: Int = ListColor0,
val type: String, // "list" or "note"
val orderIndex: Int = 0,
val isEncrypted: Boolean = false,
val encryptedContent: String? = null,
val encryptionHint: String? = null,
val encryptionTimestamp: Long? = null
)
The magic is in the type field. One model, two behaviors. Lists store checkbox items in markdown format ([ ] unchecked, [x] checked), while notes use the content field for freeform text. This decision simplified everything - one DAO, one Repository, one set of ViewModels.
The Repository Pattern: Single Source of Truth
I had two repositories - NoteListRepository and NoteRepository to begin with, since I had two models. But later on I changed it into once single ListItemRepository instead:
class ListItemRepository(private val listItemDao: ListItemDao) {
init {
Logger.info("ListItemRepository initialized")
}
fun getAllDListItems(): Flow<List<DListItem>> {
Logger.debug("Getting all DListItems (lists and notes)")
return listItemDao.getAllListItems()
}
fun searchDListItems(query: String): Flow<List<DListItem>> {
Logger.debug("Searching DListItems: query='$query'")
return listItemDao.searchListItem(query)
}
fun getListById(id: Long): Flow<DListItem?> {
Logger.debug("Getting list by id: $id")
return listItemDao.getListItemFromId(id)
}
suspend fun insertList(list: DListItem): Long {
Logger.info("Inserting list: name='${list.name}', color=${list.color}")
return try {
val id = listItemDao.insertListItem(list)
Logger.info("List inserted successfully: id=$id, name='${list.name}'")
id
} catch (e: Exception) {
Logger.error("Failed to insert list: name='${list.name}'", e)
throw e
}
}
suspend fun updateList(list: DListItem) {
Logger.info("Updating list: id=${list.id}, name='${list.name}'")
try {
listItemDao.updateListItem(list)
Logger.info("List updated successfully: id=${list.id}")
} catch (e: Exception) {
Logger.error("Failed to update list: id=${list.id}, name='${list.name}'", e)
throw e
}
}
suspend fun updateListItems(lists: List<DListItem>) {
Logger.info("Batch updating ${lists.size} list items")
try {
listItemDao.updateListItems(lists)
Logger.info("Batch update successful: ${lists.size} items")
} catch (e: Exception) {
Logger.error("Failed to batch update list items", e)
throw e
}
}
suspend fun deleteList(list: DListItem) {
Logger.info("Deleting list: id=${list.id} title='${list.name}', type='${list.type}'")
try {
listItemDao.deleteListItem(list)
Logger.info("List deleted successfully: id=${list.id} title='${list.name}', type='${list.type}'")
} catch (e: Exception) {
Logger.error(
"Failed to delete list: id=${list.id} title='${list.name}', type='${list.type}'",
e
)
throw e
}
}
suspend fun deleteListById(id: Int) {
Logger.info("Deleting list by id: $id")
try {
listItemDao.deleteListItemFromId(id)
Logger.info("List deleted successfully by id: $id")
} catch (e: Exception) {
Logger.error("Failed to delete list by id: $id", e)
throw e
}
}
suspend fun updateItemOrder(itemId: Int, newOrder: Int) {
//Logger.info("Updating item order: itemId=$itemId, newOrder=$newOrder")
try {
listItemDao.updateItemOrder(itemId, newOrder)
Logger.info("Item order updated successfully: itemId=$itemId to newOrder:$newOrder")
} catch (e: Exception) {
Logger.error("Failed to update item order: itemId=$itemId to newOrder:$newOrder", e)
throw e
}
}
}
Every database operation goes through the repository. The ViewModels never talk directly to the DAO. This makes testing easier and keeps the data layer isolated from UI concerns.
ViewModels: The Brain of the Operation
Here's where the MVVM pattern really shines. The ViewModels are the true source of truth for UI state. When a user updates a list item, here's the flow:
- User Action: Tap checkbox in UI →
ItemDetailScreencaptures event - ViewModel Update:
ItemDetailViewModel.toggleCheckbox(index)called - State Change: ViewModel updates internal
StateFlow<DListItem> - UI Reaction: Compose observes
StateFlowand recomposes - Persistence: ViewModel calls
repository.update()in background coroutine - Database Write: Repository persists to Room database
class ItemDetailViewModel(
private val noteListRepository: NoteListRepository,
private val noteRepository: NoteRepository
) : ViewModel() {
private val _item = MutableStateFlow<DListItem?>(null)
val item: StateFlow<DListItem?> = _item.asStateFlow()
fun toggleCheckbox(index: Int) {
val currentItem = _item.value ?: return
val lines = currentItem.content.split("\n").toMutableList()
if (index in lines.indices) {
lines[index] = when {
lines[index].startsWith("[ ]") ->
lines[index].replace("[ ]", "[x]")
lines[index].startsWith("[x]") ->
lines[index].replace("[x]", "[ ]")
else -> lines[index]
}
val updated = currentItem.copy(
content = lines.joinToString("\n")
)
_item.value = updated
viewModelScope.launch {
noteListRepository.update(updated)
}
}
}
}
The ViewModel is the single source of truth. The Screen doesn't maintain any state - it just renders what the ViewModel tells it to render. This eliminates entire categories of bugs related to state synchronization.
The Unified Interface Challenge
Initially, the app had separate tabs for Lists and Notes. User testing revealed a problem: people kept switching tabs to find items. "Where did I put that grocery list?" Was it a list or a note?
I refactored the entire UI to show both types in a single chronological feed, sorted by most recently updated. This was trickier than it sounds because the UI had to adapt to each item type:
@Composable
fun AllItemsScreen(viewModel: AllItemsViewModel) {
val items by viewModel.allItems.collectAsState()
LazyColumn {
items(items, key = { it.id }) { item ->
when (item.type) {
"list" -> UnifiedListItem(
item = item,
onTogglePin = { viewModel.togglePin(it) },
onDelete = { viewModel.deleteItem(it) },
onClick = { /* navigate */ }
)
"note" -> UnifiedNoteItem(
item = item,
onTogglePin = { viewModel.togglePin(it) },
onDelete = { viewModel.deleteItem(it) },
onClick = { /* navigate */ }
)
}
}
}
}
Pinned items appear first, then everything else sorted by updatedAt timestamp. The ViewModel handles the sorting logic:
val allItems: Flow<List<DListItem>> = combine(
noteListRepository.getAllLists(),
noteRepository.getAllNotes()
) { lists, notes ->
(lists + notes)
.sortedWith(
compareByDescending<DListItem> { it.isPinned }
.thenByDescending { it.updatedAt }
)
}
Interactive Checkboxes: The Details Matter
Getting checkboxes to feel right took way more iterations than expected. Users expect to tap anywhere on a list item to toggle it - not hunt for a tiny checkbox. Here's the implementation:
@Composable
fun CheckboxListItem(
text: String,
checked: Boolean,
onToggle: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onToggle() }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = checked,
onCheckedChange = { onToggle() }
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = text,
style = if (checked) {
MaterialTheme.typography.bodyMedium.copy(
textDecoration = TextDecoration.LineThrough,
color = MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.6f
)
)
} else {
MaterialTheme.typography.bodyMedium
},
modifier = Modifier.weight(1f)
)
}
}
The entire row is clickable. Completed items get strikethrough text and fade to 60% opacity. It feels responsive and natural.
Drag-and-Drop Reordering: Complex but Worth It
Users wanted to reorder list items by dragging. Jetpack Compose doesn't have built-in drag-and-drop for LazyColumn, so I built it from scratch using Modifier.pointerInput() and gesture detection.
The key insight: track drag state in the ViewModel, update the UI optimistically, then persist to database after the drag ends:
fun reorderItems(fromIndex: Int, toIndex: Int) {
val currentItem = _item.value ?: return
val lines = currentItem.content.split("\n").toMutableList()
if (fromIndex in lines.indices && toIndex in lines.indices) {
val movedLine = lines.removeAt(fromIndex)
lines.add(toIndex, movedLine)
val updated = currentItem.copy(
content = lines.joinToString("\n")
)
_item.value = updated
viewModelScope.launch {
noteListRepository.update(updated)
}
}
}
The UI updates immediately (optimistic update), then the change persists in the background. If the database write fails, I could add rollback logic, but in practice with local SQLite it never fails.
Encryption: Security Without Complexity
Some notes need encryption - passwords, API keys, private thoughts. I implemented AES-256-GCM encryption with PBKDF2 key derivation:
object EncryptionManager {
private const val ALGORITHM = "AES/GCM/NoPadding"
private const val KEY_SIZE = 256
private const val IV_SIZE = 12
private const val ITERATION_COUNT = 10000
fun encrypt(data: String, password: String): String {
val salt = ByteArray(16).apply {
SecureRandom().nextBytes(this)
}
val key = deriveKey(password, salt)
val cipher = Cipher.getInstance(ALGORITHM)
val iv = ByteArray(IV_SIZE).apply {
SecureRandom().nextBytes(this)
}
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv))
val encrypted = cipher.doFinal(data.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(
salt + iv + encrypted,
Base64.NO_WRAP
)
}
private fun deriveKey(password: String, salt: ByteArray): SecretKey {
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
val spec = PBEKeySpec(
password.toCharArray(),
salt,
ITERATION_COUNT,
KEY_SIZE
)
return SecretKeySpec(factory.generateSecret(spec).encoded, "AES")
}
}
Encrypted items show a lock icon. The content stays encrypted in the database until the user enters the password. No cloud sync means no attack surface - everything stays on device.
The Color System: Simple but Effective
Nine colors for visual organization. Users can assign colors to lists and notes for quick identification:
val ListColor0 = Color(0x99C9C9C9) // Default grey val ListColor1 = Color(0xFFE57373) // Red val ListColor2 = Color(0xFF81C784) // Green val ListColor3 = Color(0xFF64B5F6) // Blue val ListColor4 = Color(0x99FFD54F) // Yellow val ListColor5 = Color(0xFFBA68C8) // Purple val ListColor6 = Color(0xFFFF8A65) // Orange val ListColor7 = Color(0xFF4DB6AC) // Teal val ListColor8 = Color(0xFFA1887F) // Brown
The color picker in the detail screen shows circular swatches with a checkmark on the selected color. It's purely visual - no functional impact - but users love it.
Room Database: Version Control Hell
The database went through 15 versions during development. Adding the encryption fields broke existing installations. I learned the hard way to implement proper migrations:
val MIGRATION_14_15 = object : Migration(14, 15) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE d_list_item ADD COLUMN isEncrypted INTEGER NOT NULL DEFAULT 0"
)
database.execSQL(
"ALTER TABLE d_list_item ADD COLUMN encryptedContent TEXT"
)
database.execSQL(
"ALTER TABLE d_list_item ADD COLUMN encryptionHint TEXT"
)
database.execSQL(
"ALTER TABLE d_list_item ADD COLUMN encryptionTimestamp INTEGER"
)
}
}
Early versions used fallbackToDestructiveMigration() which nuked user data on updates. Not great. Now migrations preserve data properly.
Jetpack Compose: The Good and the Frustrating
Compose is incredible for rapid UI iteration. Change a color, see it instantly. But the learning curve is steep - thinking in composition instead of views requires rewiring your brain.
Side effects are the hardest part. When do you use LaunchedEffect vs DisposableEffect vs SideEffect? I got it wrong multiple times and created memory leaks.
Preview support is a game-changer though:
@Preview(showBackground = true)
@Composable
fun PreviewItemDetailScreen() {
ListsAndNotesTheme {
ItemDetailScreen(
navController = rememberNavController(),
itemId = 1L,
itemType = "list",
isPreview = true
)
}
}
Design in the preview pane, test on device. No more blind coding.
The Settings Screen: User Control
Users need control over their data. The settings screen offers:
- Force Dark Mode: Override system theme (stores in SharedPreferences)
- Database Backup: Export entire database to user-selected location
- Database Restore: Import backup file, auto-restart app
- Clear All Data: Nuclear option with confirmation dialog
- Preview Item Count: Configure how many list items show in cards
- Toggle Search Bar: Hide search if unused
The backup/restore uses Android's Storage Access Framework - no storage permissions needed:
val backupLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/octet-stream")
) { uri ->
uri?.let { viewModel.backupDatabase(context, it) }
}
Button(onClick = {
backupLauncher.launch("listsandnotes_backup.db")
}) {
Text("Backup Database")
}
Performance: Keeping It Smooth
With hundreds of list items, scroll performance mattered. Key optimizations:
- LazyColumn: Only renders visible items
- Key parameter:
items(list, key = { it.id })for stable identity - Immutable data classes: Compose can skip recomposition when nothing changed
- Flow collection: UI observes database changes reactively
- Background coroutines: All database writes happen off main thread
The app feels instant even with 500+ items. Flow-based queries mean the UI updates automatically when data changes - no manual refresh needed.
Testing and Logging
I built a centralized logging system early:
object Logger {
private const val TAG = "v.listsandnotes"
fun debug(message: String) {
if (BuildConfig.DEBUG) {
Log.d(TAG, message)
}
}
fun error(message: String, throwable: Throwable? = null) {
Log.e(TAG, message, throwable)
}
}
Every ViewModel logs state changes. Every database operation logs success/failure. When bugs appear, the logs tell the story.
What I'd Do Differently
Looking back after six months of development:
- Start with migrations: The destructive fallback cost me user trust early on
- Write tests first: No unit tests = refactoring fear
- Design system upfront: I changed colors and spacing 20 times
- User testing earlier: The unified interface should have been version 1
- Cloud sync from day one: Now it's a massive undertaking to add
The Result: 100% Kotlin, 0% Ads
Version 0.6 shipped in November 2025. The app is:
- 100% Kotlin (finally converted the last Java files)
- Ad-free and free forever
- Local-only storage (privacy first)
- AES-256 encryption available
- Material Design 3 throughout
- Dark mode support
- Drag-and-drop reordering
- Unified lists and notes interface
- Interactive checkboxes with tap-to-toggle
- Backup and restore functionality
Lessons Learned
Architecture matters. The MVVM pattern with proper separation of concerns made adding features easy. Every new feature slots into the existing structure.
Compose is the future. Yes, the learning curve is steep, but the productivity gains are real. I can iterate 10x faster than with XML layouts.
One model for multiple types works. The DListItem entity handling both lists and notes simplified everything. Don't prematurely split entities.
Users care about polish. The checkbox tap target size, the drag-and-drop feel, the color picker feedback - these details matter more than I expected.
Local-first is a feature. No cloud sync means no privacy concerns, no account management, no subscription model. Users love the simplicity.
What's Next
The roadmap includes:
- Cloud sync (optional, end-to-end encrypted)
- Home screen widgets
- Rich text formatting
- Categories and tags
- Reminders and notifications
- Export to PDF/Markdown
- Collaborative lists
But the core philosophy stays the same: no ads, privacy first, simple and powerful.
Try It Yourself
The app is free on the Play Store (coming soon) or you can build it from source on GitHub. The entire codebase is documented with 25+ patch files explaining every major feature implementation.
If you're learning Android development, this is a real-world example of:
- MVVM architecture done right
- Jetpack Compose with Material 3
- Room database with migrations
- Kotlin coroutines and Flow
- Encryption implementation
- Gesture handling (drag-and-drop)
- Storage Access Framework
- Custom logging and debugging
Building this app taught me more than any tutorial ever could. Sometimes the best way to learn is to solve your own problem.
Tech Stack Summary:
- Kotlin 2.0.21
- Jetpack Compose with Material Design 3
- Room Database (SQLite)
- Coroutines + Flow
- Navigation Component
- MVVM Architecture
- AES-256-GCM Encryption
- Min SDK 24, Target SDK 36
Available at: www.yourdev.net - No ads, FREE
Need an Android Developer?
I specialize in Kotlin, Jetpack Compose, and Material Design 3. Check out my portfolio or get in touch to discuss your project.