A clean, modern Android app for managing notes and tasks, built with production-grade architecture.
NoteApp is a fully offline note-taking and task management app for Android. Users can create, edit, delete, search, and categorize their notes — with all data persisted locally via Room.
The project is built with Jetpack Compose, follows MVVM + Clean Architecture, applies all 5 SOLID principles, and uses Kotlin Coroutines + Flow for reactive, lifecycle-safe data handling.
It serves as both a real-world productivity app and a reference implementation of modern Android development best practices.
| Feature | Description |
|---|---|
| 📝 Create & Edit Notes | Add notes with title, content, and category |
| 🗑️ Delete Notes | Remove notes with a single tap |
| 🔍 Search | Real-time filtering by title, content, or category |
| 🏷️ Category Filter | Filter notes by category chips; auto-updates as categories change |
| ✅ Validation | Prevents empty titles, empty content, and duplicate titles |
| 💾 Local Persistence | All data stored offline with Room (SQLite) |
| 🔄 Reactive UI | Room → Flow → StateFlow → Compose — always in sync |
| ⬅️ Back Navigation | System back and top bar back icon both supported in editor |
| 📱 Material 3 UI | Clean, modern design using Jetpack Compose + Material 3 |
The app is structured into four distinct layers, following Clean Architecture:
┌─────────────────────────────────────────┐
│ Presentation Layer │
│ NotesScreen (Compose) · NotesUiState │
│ NotesViewModel · NotesViewModelFactory │
├─────────────────────────────────────────┤
│ Domain Layer │
│ Note (model) · NoteRepository (iface) │
│ GetNotesUseCase · SaveNoteUseCase │
│ DeleteNoteUseCase │
├─────────────────────────────────────────┤
│ Data Layer │
│ NoteEntity · NoteDao · NoteDatabase │
│ NoteMapper · RoomNoteRepository │
├─────────────────────────────────────────┤
│ DI Layer │
│ AppContainer │
└─────────────────────────────────────────┘
MainActivity wires it all
- Presentation knows only about
NotesUiStateand ViewModel events — zero database imports - Domain is pure Kotlin — no Android, no Room, fully unit-testable
- Data implements domain interfaces — Room details never leak upward
- DI via
AppContainerkeeps setup explicit and Hilt-ready for the future
app/
├── MainActivity.kt # Entry point; wires DI, renders screen
│
├── presentation/
│ ├── NotesScreen.kt # Stateless Compose UI
│ ├── NotesUiState.kt # Single source of truth for screen state
│ ├── NotesViewModel.kt # Handles events, updates state, calls use cases
│ └── NotesViewModelFactory.kt # Creates ViewModel with injected dependencies
│
├── domain/
│ ├── model/
│ │ └── Note.kt # Pure domain model (no Room annotations)
│ ├── repository/
│ │ └── NoteRepository.kt # Interface — implemented by data layer
│ └── usecase/
│ ├── GetNotesUseCase.kt # Observe all notes as Flow
│ ├── SaveNoteUseCase.kt # Validate + create/update note
│ └── DeleteNoteUseCase.kt # Delete note by ID
│
├── data/
│ ├── local/
│ │ ├── NoteEntity.kt # Room table definition
│ │ ├── NoteDao.kt # SQL queries (Flow + suspend)
│ │ └── NoteDatabase.kt # Room database singleton
│ ├── mapper/
│ │ └── NoteMapper.kt # NoteEntity ↔ Note conversions
│ └── repository/
│ └── RoomNoteRepository.kt # Implements NoteRepository using Room
│
└── di/
└── AppContainer.kt # Manual DI — creates and wires all dependencies
Every user action follows unidirectional data flow:
User Action
│
▼
NotesScreen ──(event callback)──▶ NotesViewModel
│
Use Case (validate & execute)
│
NoteRepository (interface)
│
RoomNoteRepository (impl)
│
Room DB
│
Flow<List<NoteEntity>>
│
StateFlow<NotesUiState>
│
◀────────(recompose)──────── NotesScreen
Room emits a new list on every database change → ViewModel maps it to NotesUiState → Compose recomposes automatically. No manual refresh needed.
NotesUiState is one immutable data class holding everything the screen needs: notes list, search query, selected category, editor fields, and more. ViewModel updates it with .copy() — predictable, no hidden state.
NotesScreen receives state and callbacks only. It owns nothing. This makes it reusable, previewable, and easy to test.
Validation logic (empty title, empty content, duplicate titles, default category) lives in SaveNoteUseCase — not in the ViewModel, not in the UI. Rules apply regardless of what triggered the save.
Note (domain model) and NoteEntity (Room model) are separate classes connected by NoteMapper. Room schema changes don't touch domain logic.
| Principle | How it's applied |
|---|---|
| S — Single Responsibility | DAO queries DB · Repository abstracts data · Use case handles rules · ViewModel manages UI state · Composable renders UI |
| O — Open/Closed | Add FirebaseNoteRepository without touching use cases — just implement NoteRepository |
| L — Liskov Substitution | Any NoteRepository impl (Room, fake, network) is a valid substitute in use cases |
| I — Interface Segregation | NoteRepository exposes only note-related operations — no unrelated method pollution |
| D — Dependency Inversion | Use cases depend on NoteRepository interface, not RoomNoteRepository — data layer depends on domain, not vice versa |
| Technology | Purpose |
|---|---|
| Kotlin | Primary language |
| Jetpack Compose | Declarative UI toolkit |
| Material 3 | Design system |
| Room | Local SQLite database with Kotlin support |
| Kotlin Coroutines | Async operations (save, delete) |
| Flow / StateFlow | Reactive data streams |
| ViewModel | Lifecycle-aware state holder |
| collectAsStateWithLifecycle | Lifecycle-safe state collection in Compose |
| AppContainer | Manual dependency injection |
- Android Studio Hedgehog or later
- Android SDK 26+ (minSdk)
- Kotlin 2.0+
git clone https://github.com/your-username/NoteApp.git
cd NoteAppOpen in Android Studio → Run ▶
The architecture is designed for testability at every layer:
Domain (pure JVM — no Android needed)
// Test SaveNoteUseCase with a fake repository
val fakeRepo = FakeNoteRepository()
val useCase = SaveNoteUseCase(fakeRepo)
useCase("", "content", "Work") // → Error: title empty
useCase("Title", "", "Work") // → Error: content empty
useCase("Existing Title", "...", "Work") // → Error: duplicate title
useCase("New Note", "content", "") // → Saved with default categoryViewModel
// Inject fake use cases, assert UiState transitions
viewModel.onTitleChange("My Note")
viewModel.saveCurrentNote()
assertThat(viewModel.uiState.value.notes).hasSize(1)UI (Compose)
composeTestRule.setContent { NotesScreen(state = fakeState, ...) }
composeTestRule.onNodeWithText("My Note").assertIsDisplayed()- Hilt dependency injection
- Navigation Compose
- Snackbar undo on delete
- Delete confirmation dialog
- Room database migrations
- Note sorting (by date, title, category)
- Archive / favorite notes
- Dark mode support
- Widget support
- Export notes as PDF
A complete set of interview questions and answers covering every aspect of this project. Use this to prepare for Android developer interviews.
🏛️ Architecture & Project Overview (Q1–Q6)
Q1. What is the project goal?
The goal is to build a note-taking Android app where users can create, view, edit, delete, search, and categorize notes. Notes are stored locally using Room database. The app follows MVVM, a clean architecture style, and SOLID principles.
Q2. What features are implemented?
The app supports CRUD operations, title/content/category fields, local persistence with Room, duplicate title validation, empty title/content validation, category filtering, keyword search, Material 3 Compose UI, back navigation on the editor screen, and reactive UI updates using Flow and StateFlow.
Q3. What is the high-level architecture?
The app is divided into Presentation, Domain, Data, DI, and Activity layers. Presentation contains Compose screens, UI state, and ViewModel. Domain contains business models, repository contracts, and use cases. Data contains Room entity, DAO, database, mapper, and repository implementation. DI contains AppContainer. MainActivity only wires dependencies and renders the screen.
Q4. Why use MVVM?
MVVM separates UI rendering from UI logic. The View displays state and sends events. The ViewModel handles state and user actions. The Model/Data layer provides data. This improves testability, lifecycle handling, and maintainability.
Q5. What is the role of MainActivity?
MainActivity is the app entry point. In this project it stays thin: it applies the theme, creates the AppContainer, gets NotesViewModel using a factory, collects UI state with collectAsStateWithLifecycle, and passes state plus callbacks to NotesScreen. It does not contain database or business logic.
Q6. Why should business logic not be in Activity?
Activities are framework classes and are harder to unit test. If validation and database code are placed in Activity, the class becomes large and tightly coupled. Moving logic into ViewModel and use cases improves separation of concerns and testability.
🎨 Jetpack Compose & UI (Q7–Q10)
Q7. What is Jetpack Compose?
Jetpack Compose is Android's modern declarative UI toolkit. Instead of manually updating views, you describe what the UI should look like for a given state. When state changes, Compose recomposes the affected UI automatically.
Q8. What is recomposition?
Recomposition is the process where Compose re-executes Composable functions when observed state changes. For example, when the search query changes, the ViewModel updates UiState and NotesScreen recomposes to display filtered notes.
Q9. What is state hoisting?
State hoisting means moving state ownership to a higher-level component and passing state plus callbacks down to UI components. In this app, NotesViewModel owns state and NotesScreen receives state and events. This makes the UI reusable and easier to test.
Q10. Why is NotesScreen mostly stateless?
NotesScreen receives NotesUiState and callback functions. It does not directly access Room or business logic. Stateless Composables are predictable, reusable, and preview-friendly.
🧠 State Management & ViewModel (Q11–Q14)
Q11. What is NotesUiState?
NotesUiState is a single immutable data class that represents everything the screen needs: all notes, visible notes, categories, search query, selected category, selected note, editor fields, editing mode, and messages. A single state object makes the UI predictable.
Q12. Why use ViewModel?
ViewModel stores and manages UI-related state in a lifecycle-aware way. It survives configuration changes and exposes state to the UI. In this app, NotesViewModel handles note events, editor state, filtering, and calls use cases.
Q13. Why keep MutableStateFlow private?
The ViewModel exposes StateFlow publicly and keeps MutableStateFlow private. This follows encapsulation: only the ViewModel can mutate state, while the UI can only observe it. This prevents accidental state changes from outside.
Q14. What is unidirectional data flow?
Data flows in one direction: ViewModel exposes state → UI displays state → user performs actions → UI calls ViewModel functions → ViewModel updates state → UI recomposes. This makes UI behavior predictable.
🏗️ Clean Architecture & Domain Layer (Q15–Q18)
Q15. What is Clean Architecture?
Clean Architecture separates code by responsibility and dependency direction. The domain layer contains business rules and remains independent of Android frameworks, Room, and Compose. Presentation and Data depend on Domain, not the other way around.
Q16. Why do we need a domain layer?
The domain layer contains business rules like validation and note-saving behavior. It should not know about Room or Compose. This keeps core logic reusable, testable, and independent from framework details.
Q17. What is a use case?
A use case represents one business operation. This app has GetNotesUseCase, SaveNoteUseCase, and DeleteNoteUseCase. SaveNoteUseCase handles input trimming, empty validation, duplicate title validation, default category assignment, and saving through the repository.
Q18. Why is validation in SaveNoteUseCase?
Validation is business logic, not UI logic. Rules like "title cannot be empty", "content cannot be empty", and "duplicate titles are not allowed" should apply no matter which UI triggers saving. Use cases make these rules reusable and testable.
🗄️ Room Database (Q19–Q23)
Q19. What is Room?
Room is a Jetpack persistence library built on top of SQLite. It reduces boilerplate, validates SQL queries at compile time, supports Coroutines and Flow, and maps Kotlin classes to database tables.
Q20. What is a Room Entity?
A Room Entity is a Kotlin data class mapped to a database table. In this app, NoteEntity represents the notes table with id, title, content, category, createdAt, and updatedAt columns.
Q21. What is a DAO?
DAO stands for Data Access Object. It defines database operations such as observeNotes, insert, update, deleteById, and isDuplicateTitle. DAO keeps SQL queries separate from business and UI code.
Q22. Why use Flow in DAO?
Room can return Flow from queries. When the notes table changes, Room emits the latest list automatically. The ViewModel collects this Flow and updates UI state, so the UI stays synchronized without manual refresh calls.
Q23. Why use suspend functions in DAO?
Database insert, update, delete, and validation queries can take time. Suspend functions allow these operations to run asynchronously in coroutines without blocking the main thread.
📦 Repository Pattern & Mapping (Q24–Q27)
Q24. What is the Repository pattern?
Repository abstracts data access. Domain and use cases depend on the NoteRepository interface, while the data layer provides RoomNoteRepository. This hides whether data comes from Room, network, Firebase, or fake test data.
Q25. Why use a repository interface?
Using an interface follows the Dependency Inversion Principle. High-level business logic depends on abstractions, not concrete Room classes. This improves testability and allows swapping implementations later.
Q26. Why separate Note and NoteEntity?
Note is the domain model, while NoteEntity is the database model. Keeping them separate prevents Room annotations and database details from leaking into domain logic. It also allows the database schema to evolve independently.
Q27. Why use mapper functions?
Mappers convert between NoteEntity and Note. They keep data and domain layers independent. This is useful when database models, API models, and domain models have different shapes.
⚡ Coroutines & Flow (Q28–Q32)
Q28. What are Coroutines?
Coroutines are Kotlin's lightweight concurrency mechanism. They allow asynchronous code to be written in a sequential style. In this app, ViewModel launches coroutines to save and delete notes without blocking the UI.
Q29. What is viewModelScope?
viewModelScope is a coroutine scope tied to the ViewModel lifecycle. Coroutines launched inside it are cancelled automatically when the ViewModel is cleared, preventing memory leaks.
Q30. What is Flow?
Flow is an asynchronous stream of values over time. Room emits note list updates as Flow. The ViewModel collects that stream and updates StateFlow for the UI.
Q31. Difference between Flow and StateFlow?
Flow is a general stream and is cold by default. StateFlow is a hot state holder that always has a current value. In this app, Room returns Flow and ViewModel exposes StateFlow to Compose.
Q32. Why use collectAsStateWithLifecycle?
collectAsStateWithLifecycle collects StateFlow in a lifecycle-aware way. It avoids collecting when the screen is not active, reducing unnecessary work and lifecycle bugs.
🔀 Feature Walkthroughs (Q33–Q39)
Q33. How does the create note flow work?
User taps plus button → ViewModel.startNewNote opens editor mode → user enters fields → Save button calls ViewModel.saveCurrentNote → SaveNoteUseCase validates input → repository saves through Room → Room Flow emits updated data → ViewModel updates UiState → Compose recomposes.
Q34. How does the edit note flow work?
User taps a note card → ViewModel.editNote fills editor fields with selected note data → user changes fields → SaveNoteUseCase validates and updates note → Room updates row → Flow emits new list → UI updates.
Q35. How does the delete note flow work?
User taps Delete → ViewModel.deleteNote calls DeleteNoteUseCase → repository deletes from Room → Room emits the updated note list → ViewModel updates state → UI recomposes without the deleted note.
Q36. How does search work?
User types in the search box → NotesScreen sends text to ViewModel.onSearchChange → ViewModel updates searchQuery and recalculates visibleNotes using title, content, and category matching → Compose shows the filtered list.
Q37. How does category filtering work?
Categories are derived from existing notes. When the user selects a category chip, ViewModel updates selectedCategory and recalculates visibleNotes. If the selected category no longer exists, it falls back to "All".
Q38. What is BackHandler used for?
BackHandler intercepts Android system back press in Compose. In this app it is enabled only when editing. Pressing system back cancels editing and returns to the notes list.
Q39. Why use a back icon in TopAppBar?
The back icon provides visible navigation in add/edit mode. It improves user experience because users can clearly return to the list without saving.
🔒 SOLID Principles (Q40–Q44)
Q40. How does the app follow the Single Responsibility Principle?
Each class has one responsibility: DAO handles database queries, repository handles data abstraction, use cases handle business rules, ViewModel handles UI state, Composables render UI, and AppContainer creates dependencies.
Q41. How does the app follow the Open/Closed Principle?
Use cases depend on the NoteRepository interface. You can add another implementation, such as FirebaseNoteRepository, without modifying use cases. The app is open for extension but closed for modification.
Q42. How does the app follow the Liskov Substitution Principle?
Any implementation of NoteRepository can replace RoomNoteRepository as long as it follows the same contract. This means fake repositories, network repositories, or local repositories can be substituted freely.
Q43. How does the app follow the Interface Segregation Principle?
NoteRepository exposes only note-related operations required by the domain. It does not force unrelated methods on classes that do not need them.
Q44. How does the app follow the Dependency Inversion Principle?
High-level domain use cases depend on the NoteRepository abstraction, not the concrete RoomNoteRepository. The data layer implements the abstraction. This keeps business logic independent from storage details.
💉 Dependency Injection & State Design (Q45–Q48)
Q45. What is AppContainer?
AppContainer is a simple manual dependency injection container. It creates NoteDatabase, RoomNoteRepository, use cases, and NotesViewModelFactory. For a small assignment app this keeps setup simple and clear.
Q46. Why not use Hilt?
Manual DI is acceptable for a small project. Hilt is better for production or larger apps because it automatically manages dependency graphs and scopes. AppContainer keeps setup simple, while Hilt would be the natural next step for a production app.
Q47. What is single source of truth?
Room is the source of truth for saved notes. NotesUiState is the source of truth for current screen state. The UI does not maintain separate independent copies of saved notes.
Q48. Why use immutable data classes for state?
Immutable state is predictable. Instead of mutating fields, ViewModel creates new state using .copy(). This works well with Compose and reduces accidental bugs caused by shared mutable state.
🧪 Testing & Production Readiness (Q49–Q50)
Q49. How would you test SaveNoteUseCase?
Create a fake NoteRepository and test: empty title, empty content, duplicate title, valid save, and default category behavior. Because the use case depends on an interface, no Android or Room dependency is required for these tests.
val fakeRepo = FakeNoteRepository()
val useCase = SaveNoteUseCase(fakeRepo)
// Empty title
val result1 = useCase(title = "", content = "Hello", category = "Work")
assertThat(result1).isInstanceOf(Result.Error::class.java)
// Duplicate title
fakeRepo.addNote(Note(title = "Existing"))
val result2 = useCase(title = "Existing", content = "Hello", category = "Work")
assertThat(result2).isInstanceOf(Result.Error::class.java)
// Valid save
val result3 = useCase(title = "New Note", content = "Hello", category = "")
assertThat(result3).isInstanceOf(Result.Success::class.java)Q50. How would you improve this app for production?
Add Hilt, Navigation Compose, Snackbar messages, delete confirmation dialog, Room migrations, unit tests, ViewModel tests, UI tests, date formatting, sorting options, archive/favorite notes, and better error handling.
🏁 Final Interview Summary
Use this as your elevator pitch when asked "Tell me about this project."
I implemented a notes CRUD app using Jetpack Compose, Room, MVVM, and Clean Architecture. The UI is stateless and observes StateFlow from the ViewModel. The ViewModel manages UI state and delegates business actions to use cases. Use cases contain validation and business rules. The domain layer depends on a repository interface, while the data layer implements it using Room. Room exposes notes as Flow, so database changes automatically update the UI. This follows all 5 SOLID principles and improves testability, maintainability, and scalability.
The current app is a solid foundation. Here's a structured roadmap of improvements across every layer — from quick wins to long-term production features.
| Improvement | Details |
|---|---|
| Migrate to Hilt | Replace AppContainer with Hilt for automatic dependency graph management, scoping, and easier testing |
| Navigation Compose | Replace manual screen state flags in NotesUiState with NavHost + type-safe routes for scalable multi-screen navigation |
| Multi-module architecture | Split into :app, :feature:notes, :domain, :data, :core modules for faster builds and better separation |
| Improvement | Details |
|---|---|
| Snackbar with Undo | Show an "Undo" snackbar after delete instead of permanent immediate deletion |
| Delete confirmation dialog | Ask for confirmation before deleting a note to prevent accidental data loss |
| Note sorting | Sort by date created, date modified, title A–Z, or category |
| Rich text editor | Support bold, italic, bullet lists, and checkboxes inside note content |
| Note colors / themes | Let users assign a color to each note for visual organization |
| Grid / list toggle | Switch between card grid and compact list layouts |
| Swipe to delete | Swipe a note card left/right to trigger delete with animation |
| Dark mode | Full Material 3 dynamic color and dark theme support |
| Empty state illustrations | Show friendly illustrations when the note list or search results are empty |
| Animations | Shared element transitions between list and editor, animated card insertions and removals |
| Improvement | Details |
|---|---|
| Room migrations | Add proper Migration strategies so database schema changes don't wipe user data on app update |
| Archive notes | Move notes to an archive instead of deleting them permanently |
| Favorite / pin notes | Let users star or pin important notes to always appear at the top |
| Soft delete / recycle bin | Keep deleted notes in a trash bin for 30 days before permanent removal |
| Note timestamps | Display formatted "Created" and "Last edited" dates on each note card |
| Image attachments | Allow users to attach photos from gallery or camera to a note |
| Encrypted notes | Lock sensitive notes behind biometric authentication or a PIN |
| Improvement | Details |
|---|---|
| Firebase / Firestore sync | Sync notes across devices in real time using a FirebaseNoteRepository that implements the existing NoteRepository interface — no use case changes needed |
| Google Drive backup | Periodic backup of the Room database to Google Drive |
| Export as PDF / TXT | Let users export individual notes or all notes as a PDF or plain text file |
| Import notes | Import notes from a JSON or CSV file |
| Improvement | Details |
|---|---|
| Unit tests for use cases | Test all validation rules in SaveNoteUseCase using a FakeNoteRepository — no Android dependencies needed |
| ViewModel tests | Use TestCoroutineDispatcher + FakeRepository to test all state transitions in NotesViewModel |
| Compose UI tests | Use ComposeTestRule to verify screen rendering, search behavior, and navigation |
| Integration tests | Test RoomNoteRepository against an in-memory Room database |
| Screenshot tests | Use Paparazzi or Roborazzi for snapshot regression testing of Compose screens |
| Improvement | Details |
|---|---|
| CI/CD pipeline | GitHub Actions workflow for lint, unit tests, and build on every PR |
| Detekt / ktlint | Static analysis and code style enforcement |
| Baseline profiles | Add Jetpack BaselineProfile to reduce app startup time and improve rendering performance |
| ProGuard / R8 | Enable code shrinking and obfuscation for release builds |
| Crashlytics | Firebase Crashlytics integration for production crash reporting |
| Analytics | Track feature usage (search, category filter, create/edit/delete) with Firebase Analytics |
| Improvement | Details |
|---|---|
| Content descriptions | Add contentDescription to all icon buttons for screen reader support |
| Dynamic font scaling | Test and support large text sizes for accessibility |
| RTL layout support | Ensure layouts mirror correctly for right-to-left languages |
| Multi-language support | Extract all strings to strings.xml and add translations |
| Improvement | Details |
|---|---|
| Home screen widget | Glance widget to show recent notes or quick-add a note from the home screen |
| Shortcut support | App shortcuts (long-press icon) for "New Note" and "Search" |
| Wear OS companion | View and create short notes from a Wear OS watch |
| Tablet / foldable support | Two-pane adaptive layout — note list on the left, editor on the right |
| Android Auto | Read notes aloud via Android Auto for hands-free access |
MIT License
Copyright (c) 2026 Mukesh Kumar Patel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction...
See LICENSE for the full text.