diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md
index 06189dd5..300d2dc2 100644
--- a/.planning/REQUIREMENTS.md
+++ b/.planning/REQUIREMENTS.md
@@ -26,6 +26,10 @@ Requirements for this bug fix milestone. Each maps to roadmap phases.
- [x] **OFFLINE-MAP-01**: Offline maps render correctly after extended offline periods
+### Loading States (#341)
+
+- [x] **UX-LOADING-01**: Show loading indicators instead of flashing empty states while data loads
+
## v2 Requirements
Deferred bug fixes to address in a future milestone.
@@ -60,12 +64,13 @@ Which phases cover which requirements.
| RELAY-02 | Phase 2 | Pending |
| ANNOUNCE-01 | Phase 2.1 | Complete |
| OFFLINE-MAP-01 | Phase 2.2 | Complete |
+| UX-LOADING-01 | Phase 2.3 | Complete |
**Coverage:**
-- v1 requirements: 7 total
+- v1 requirements: 8 total
- Mapped to phases: 7
- Unmapped: 0
---
*Requirements defined: 2026-01-24*
-*Last updated: 2026-01-27 after phase 2.2 completion*
+*Last updated: 2026-01-28 after phase 2.3 completion*
diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 26fb7763..7da7a233 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -14,6 +14,7 @@ This milestone addresses two high-priority bugs reported after the 0.7.2 pre-rel
- [ ] **Phase 2: Relay Loop Fix** - Investigate and fix the relay auto-selection loop
- [x] **Phase 2.1: Clear Announces Preserves Contacts** - Fix Clear All Announces to exempt My Contacts (#365) (INSERTED)
- [x] **Phase 2.2: Offline Map Tile Rendering** - Fix offline maps failing to render after extended offline period (#354) (INSERTED)
+- [x] **Phase 2.3: Loading States for Tabs** - Show loading indicators instead of empty states while data loads (#341) (INSERTED)
## Phase Details
@@ -80,6 +81,22 @@ Plans:
- [x] 02.2-01-PLAN.md — Add localStylePath to DB schema and cache style JSON during download
- [x] 02.2-02-PLAN.md — Load local style JSON when offline and update MapScreen (depends on 02.2-01)
+### Phase 2.3: Loading States for Tabs (INSERTED)
+**Goal**: Show loading indicators while data initializes instead of flashing empty states ("No conversations yet", "No contacts yet") that confuse users into thinking no data exists
+**Depends on**: Nothing (independent fix)
+**Requirements**: UX-LOADING-01
+**Issue**: [#341](https://github.com/torlando-tech/columba/issues/341)
+**Success Criteria** (what must be TRUE):
+ 1. User sees "Loading conversations..." when navigating to Chats tab before data loads
+ 2. User sees "Loading contacts..." when navigating to Contacts tab before data loads
+ 3. User does NOT see "No conversations yet" or "No contacts yet" while data is still loading
+ 4. After Skip on onboarding, user sees loading state (not empty state) until data loads
+ 5. Empty states only appear when data has finished loading AND is actually empty
+**Plans**: 1 plan in 1 wave
+
+Plans:
+- [x] 02.3-01-PLAN.md — Add loading states to ChatsViewModel/Screen and ContactsViewModel/Screen
+
## Progress
**Execution Order:**
@@ -91,3 +108,4 @@ Phases 1 and 2 are independent and can be worked in any order.
| 2. Relay Loop Fix | 0/3 | Not started | - |
| 2.1. Clear Announces Preserves Contacts (INSERTED) | 2/2 | ✓ Complete | 2026-01-27 |
| 2.2. Offline Map Tile Rendering (INSERTED) | 2/2 | ✓ Complete | 2026-01-27 |
+| 2.3. Loading States for Tabs (INSERTED) | 1/1 | ✓ Complete | 2026-01-28 |
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 09b74bb8..2da250c4 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -5,23 +5,23 @@
See: .planning/PROJECT.md (updated 2026-01-24)
**Core value:** Fix the performance degradation and relay selection loop bugs so users have a stable, responsive app experience.
-**Current focus:** Phase 2.2 - Offline Map Tile Rendering
+**Current focus:** Phase 2.3 - Loading States for Tabs
## Current Position
-Phase: 2.2 (Offline Map Tile Rendering)
-Plan: 2 of 2 complete
+Phase: 2.3 (Loading States for Tabs)
+Plan: 1 of 1 complete
Status: Phase complete
-Last activity: 2026-01-28 — Completed 02.2-02-PLAN.md (Load local style for offline rendering)
+Last activity: 2026-01-28 — Completed 02.3-01-PLAN.md (Add loading states to Chats and Contacts tabs)
-Progress: [████████████] 100% (10/10 total plans: 6 from phases 1-2 + 2/2 from phase 2.1 + 2/2 from phase 2.2)
+Progress: [████████████] 100% (11/11 total plans: 6 from phases 1-2 + 2/2 from phase 2.1 + 2/2 from phase 2.2 + 1/1 from phase 2.3)
## Performance Metrics
**Velocity:**
-- Total plans completed: 10
-- Average duration: 7m 0s
-- Total execution time: 69m 35s
+- Total plans completed: 11
+- Average duration: 7m 10s
+- Total execution time: 78m 47s
**By Phase:**
@@ -31,10 +31,11 @@ Progress: [████████████] 100% (10/10 total plans: 6 from
| 02-relay-loop-fix | 3/3 | 16m 19s | 5m 26s |
| 02.1-clear-announces | 2/2 | 8m 12s | 4m 6s |
| 02.2-offline-maps | 2/2 | 26m 22s | 13m 11s |
+| 02.3-loading-states | 1/1 | 9m 12s | 9m 12s |
**Recent Trend:**
-- Last 3 plans: 5m 14s (02.1-02), 18m 22s (02.2-01), 8m (02.2-02)
-- Trend: Database migrations slower, UI-only changes faster
+- Last 3 plans: 18m 22s (02.2-01), 8m (02.2-02), 9m 12s (02.3-01)
+- Trend: UI-only changes consistently fast (~9 min)
*Updated after each plan completion*
@@ -66,6 +67,9 @@ Recent decisions affecting current work:
- Use 5-second timeout on URL fetch to prevent infinite hangs in tests and slow networks (02.2-01)
- Use fromJson() instead of fromUri() for local style files to avoid HTTP cache dependency (02.2-02)
- Fall back to HTTP style URL if cached style file doesn't exist (backward compatibility) (02.2-02)
+- Use boolean isLoading flag pattern (consistent with MapViewModel) instead of sealed class UiState (02.3-01)
+- Use map{} operator on Flow to wrap data with state, simpler than combine() (02.3-01)
+- Loading state only for initial load, not during search filtering (02.3-01)
### Roadmap Evolution
@@ -76,6 +80,10 @@ Recent decisions affecting current work:
- Downloaded offline maps stop rendering after extended offline period (days)
- Likely cause: offline code path still uses network style URL, so MapLibre can't resolve layer definitions when fully offline
- Fix: ensure offline style loading explicitly uses local tile data without network dependency
+- Phase 2.3 inserted after Phase 2.2: Loading States for Tabs — #341 (URGENT) - **COMPLETE**
+ - "No conversations yet" / "No contacts yet" flash while data loads, especially after onboarding Skip
+ - Root cause: StateFlows use `initialValue = emptyList()`, UI checks `isEmpty()` without checking loading state
+ - Fix: Add UiState wrapper with Loading state, check loading before showing empty (follow Map tab pattern)
### Pending Todos
@@ -101,9 +109,9 @@ Also pending from plans:
## Session Continuity
Last session: 2026-01-28
-Stopped at: Completed 02.2-02-PLAN.md - Load cached style for offline rendering
+Stopped at: Completed 02.3-01-PLAN.md - Add loading states to Chats and Contacts tabs
Resume file: None
-Next: All planned phases complete (Phase 1, 2, 2.1, 2.2)
+Next: All planned phases complete (Phase 1, 2, 2.1, 2.2, 2.3)
## Phase 2 Completion Summary
@@ -181,3 +189,31 @@ All 2 plans executed successfully:
- Safe database migration (nullable column addition)
- No regressions in online map functionality
- No pending blockers for this phase
+
+## Phase 2.3 Completion Summary
+
+**Phase 02.3 - Loading States for Tabs: COMPLETE**
+
+All 1 plan executed successfully:
+- 02.3-01: Add loading states to Chats and Contacts tabs (9m 12s) ✓
+
+**Key outcomes:**
+- Issue #341 (empty state flash) resolved
+- ChatsState wraps conversations with isLoading flag
+- ContactsState wraps grouped contacts with isLoading flag
+- LoadingConversationsState and LoadingContactsState composables added
+- Both screens check isLoading before showing empty state
+
+**Technical implementation:**
+- State wrapper pattern: `data class XState(val data: T, val isLoading: Boolean = true)`
+- Flow transformation: `flow.map { XState(data = it, isLoading = false) }`
+- UI check: `when { isLoading -> Loading; isEmpty -> Empty; else -> Content }`
+- Consistent with MapViewModel boolean flag pattern
+
+**Testing confidence:** High - Build verification passes, code patterns verified
+
+**Production readiness:**
+- Ready for merge and release
+- Fixes confusing UX where users see "No conversations/contacts yet" flash
+- No regressions in existing functionality
+- Pattern established for any future tabs needing loading states
diff --git a/.planning/phases/02.3-loading-states-for-tabs/02.3-01-PLAN.md b/.planning/phases/02.3-loading-states-for-tabs/02.3-01-PLAN.md
new file mode 100644
index 00000000..686344b6
--- /dev/null
+++ b/.planning/phases/02.3-loading-states-for-tabs/02.3-01-PLAN.md
@@ -0,0 +1,388 @@
+---
+phase: 02.3-loading-states-for-tabs
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt
+ - app/src/main/java/com/lxmf/messenger/ui/screens/ChatsScreen.kt
+ - app/src/main/java/com/lxmf/messenger/viewmodel/ContactsViewModel.kt
+ - app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt
+autonomous: true
+
+must_haves:
+ truths:
+ - "User sees 'Loading conversations...' when navigating to Chats tab before data loads"
+ - "User sees 'Loading contacts...' when navigating to Contacts tab before data loads"
+ - "User does NOT see 'No conversations yet' or 'No contacts yet' while data is still loading"
+ - "Empty states only appear when data has finished loading AND is actually empty"
+ artifacts:
+ - path: "app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt"
+ provides: "ChatsState data class with isLoading flag, chatsState StateFlow"
+ contains: "data class ChatsState"
+ - path: "app/src/main/java/com/lxmf/messenger/ui/screens/ChatsScreen.kt"
+ provides: "Loading check before empty state, LoadingConversationsState composable"
+ contains: "LoadingConversationsState"
+ - path: "app/src/main/java/com/lxmf/messenger/viewmodel/ContactsViewModel.kt"
+ provides: "ContactsState data class with isLoading flag"
+ contains: "data class ContactsState"
+ - path: "app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt"
+ provides: "Loading check before empty state, LoadingContactsState composable"
+ contains: "LoadingContactsState"
+ key_links:
+ - from: "ChatsScreen.kt"
+ to: "ChatsViewModel.kt"
+ via: "collectAsState() on chatsState"
+ pattern: "viewModel\\.chatsState\\.collectAsState"
+ - from: "ContactsScreen.kt"
+ to: "ContactsViewModel.kt"
+ via: "collectAsState() on contactsState"
+ pattern: "state\\.isLoading"
+---
+
+
+Add loading states to Chats and Contacts tabs to prevent flashing empty states while data initializes.
+
+Purpose: Fix UX bug (#341) where users see "No conversations yet" or "No contacts yet" flash while data loads from the database, making them think no data exists.
+
+Output: Modified ViewModels with state wrappers containing `isLoading` flag, updated Screens with loading state composables shown before empty states.
+
+
+
+@/home/tyler/.claude/get-shit-done/workflows/execute-plan.md
+@/home/tyler/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/02.3-loading-states-for-tabs/02.3-RESEARCH.md
+@app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt
+@app/src/main/java/com/lxmf/messenger/viewmodel/ContactsViewModel.kt
+@app/src/main/java/com/lxmf/messenger/ui/screens/ChatsScreen.kt
+@app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt
+
+
+
+
+
+ Task 1: Add loading states to ChatsViewModel and ChatsScreen
+
+ app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt
+ app/src/main/java/com/lxmf/messenger/ui/screens/ChatsScreen.kt
+
+
+**ChatsViewModel.kt changes:**
+
+1. Add ChatsState data class near the top of the file (after imports, before class):
+```kotlin
+/**
+ * UI state for the Chats tab, including loading status.
+ */
+data class ChatsState(
+ val conversations: List = emptyList(),
+ val isLoading: Boolean = true,
+)
+```
+
+2. Replace the existing `conversations` StateFlow with a new `chatsState` StateFlow:
+```kotlin
+// Replace this:
+val conversations: StateFlow> = ...
+
+// With this:
+val chatsState: StateFlow =
+ searchQuery
+ .flatMapLatest { query ->
+ if (query.isBlank()) {
+ conversationRepository.getConversations()
+ } else {
+ conversationRepository.searchConversations(query)
+ }
+ }
+ .map { conversations ->
+ ChatsState(
+ conversations = conversations,
+ isLoading = false,
+ )
+ }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000L),
+ initialValue = ChatsState(isLoading = true),
+ )
+```
+
+3. Add import for `map` from kotlinx.coroutines.flow if not already present.
+
+**ChatsScreen.kt changes:**
+
+1. Update state collection at the top of ChatsScreen composable:
+```kotlin
+// Change from:
+val conversations by viewModel.conversations.collectAsState()
+
+// To:
+val chatsState by viewModel.chatsState.collectAsState()
+```
+
+2. Update subtitle to use chatsState:
+```kotlin
+subtitle = "${chatsState.conversations.size} ${if (chatsState.conversations.size == 1) "conversation" else "conversations"}"
+```
+
+3. Add LoadingConversationsState composable (add near EmptyChatsState):
+```kotlin
+@Composable
+fun LoadingConversationsState(modifier: Modifier = Modifier) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Loading conversations...",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+```
+
+4. Update the content check inside Scaffold to check loading first:
+```kotlin
+// Replace this:
+if (conversations.isEmpty()) {
+ EmptyChatsState(...)
+} else {
+ LazyColumn(...)
+}
+
+// With this:
+when {
+ chatsState.isLoading -> {
+ LoadingConversationsState(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ )
+ }
+ chatsState.conversations.isEmpty() -> {
+ EmptyChatsState(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ )
+ }
+ else -> {
+ LazyColumn(...) {
+ // Update items() call to use chatsState.conversations
+ items(chatsState.conversations, key = { it.peerHash }) { conversation ->
+ ...
+ }
+ }
+ }
+}
+```
+
+5. Update any other references from `conversations` to `chatsState.conversations` in the file.
+
+
+- Build succeeds: `cd /home/tyler/repos/public/columba && JAVA_HOME=/home/tyler/android-studio/jbr ./gradlew :app:compileDebugKotlin`
+- No lint errors related to ChatsState or chatsState
+- ChatsState data class exists with isLoading field
+- chatsState StateFlow emits ChatsState with isLoading = true initially
+
+
+- ChatsViewModel exposes chatsState with isLoading flag
+- ChatsScreen checks isLoading before showing empty state
+- LoadingConversationsState composable shows spinner and "Loading conversations..." text
+
+
+
+
+ Task 2: Add loading states to ContactsViewModel and ContactsScreen
+
+ app/src/main/java/com/lxmf/messenger/viewmodel/ContactsViewModel.kt
+ app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt
+
+
+**ContactsViewModel.kt changes:**
+
+1. Add ContactsState data class near the top of the file (after existing sealed classes/data classes):
+```kotlin
+/**
+ * UI state for the Contacts tab, including loading status.
+ */
+data class ContactsState(
+ val groupedContacts: ContactGroups = ContactGroups(null, emptyList(), emptyList()),
+ val isLoading: Boolean = true,
+)
+```
+
+2. Replace the existing `groupedContacts` StateFlow with a new `contactsState` StateFlow:
+```kotlin
+// Replace this:
+val groupedContacts: StateFlow =
+ filteredContacts
+ .combine(MutableStateFlow(Unit)) { contacts, _ ->
+ ...
+ }
+ .stateIn(...)
+
+// With this:
+val contactsState: StateFlow =
+ filteredContacts
+ .map { contacts ->
+ val relay = contacts.find { it.isMyRelay }
+ Log.d(TAG, "ContactsState: ${contacts.size} filtered, relay=${relay?.displayName}")
+ ContactsState(
+ groupedContacts = ContactGroups(
+ relay = relay,
+ pinned = contacts.filter { it.isPinned && !it.isMyRelay },
+ all = contacts.filterNot { it.isPinned || it.isMyRelay },
+ ),
+ isLoading = false,
+ )
+ }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000L),
+ initialValue = ContactsState(isLoading = true),
+ )
+```
+
+3. Remove the now-unused `groupedContacts` val if it was the only definition (the old one).
+
+4. Add import for `map` from kotlinx.coroutines.flow if not already present.
+
+**ContactsScreen.kt changes:**
+
+1. Update state collection:
+```kotlin
+// Change from:
+val groupedContacts by viewModel.groupedContacts.collectAsState()
+
+// To:
+val contactsState by viewModel.contactsState.collectAsState()
+```
+
+2. Add LoadingContactsState composable (add near EmptyContactsState):
+```kotlin
+@Composable
+fun LoadingContactsState(modifier: Modifier = Modifier) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Loading contacts...",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+```
+
+3. Update the MY_CONTACTS tab content check to check loading first. Find the existing condition:
+```kotlin
+if (groupedContacts.relay == null && groupedContacts.pinned.isEmpty() && groupedContacts.all.isEmpty()) {
+ EmptyContactsState(...)
+} else {
+ LazyColumn(...)
+}
+```
+
+Replace with:
+```kotlin
+when {
+ contactsState.isLoading -> {
+ LoadingContactsState(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ )
+ }
+ contactsState.groupedContacts.relay == null &&
+ contactsState.groupedContacts.pinned.isEmpty() &&
+ contactsState.groupedContacts.all.isEmpty() -> {
+ EmptyContactsState(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ )
+ }
+ else -> {
+ LazyColumn(...) {
+ // Update all references from groupedContacts to contactsState.groupedContacts
+ ...
+ }
+ }
+}
+```
+
+4. Update all references from `groupedContacts` to `contactsState.groupedContacts` in the file, including:
+ - Debug LaunchedEffect logging
+ - MY RELAY section (groupedContacts.relay)
+ - Pinned section (groupedContacts.pinned)
+ - All contacts section (groupedContacts.all, groupedContacts.relay, groupedContacts.pinned)
+
+
+- Build succeeds: `cd /home/tyler/repos/public/columba && JAVA_HOME=/home/tyler/android-studio/jbr ./gradlew :app:compileDebugKotlin`
+- No lint errors related to ContactsState or contactsState
+- ContactsState data class exists with isLoading field
+- contactsState StateFlow emits ContactsState with isLoading = true initially
+
+
+- ContactsViewModel exposes contactsState with isLoading flag
+- ContactsScreen checks isLoading before showing empty state
+- LoadingContactsState composable shows spinner and "Loading contacts..." text
+
+
+
+
+
+
+After both tasks complete:
+
+1. **Build verification:**
+ ```bash
+ cd /home/tyler/repos/public/columba
+ JAVA_HOME=/home/tyler/android-studio/jbr ./gradlew :app:compileDebugKotlin
+ ```
+ Expected: BUILD SUCCESSFUL
+
+2. **Code verification:**
+ - ChatsState and ContactsState data classes exist with isLoading field
+ - chatsState and contactsState StateFlows expose the state wrappers
+ - ChatsScreen and ContactsScreen check isLoading before showing empty state
+ - LoadingConversationsState and LoadingContactsState composables exist
+
+3. **Pattern verification:**
+ - Loading state shows CircularProgressIndicator + descriptive text
+ - Empty states only appear when isLoading = false AND data is empty
+ - Follows MapViewModel boolean flag pattern for consistency
+
+
+
+- Build compiles without errors
+- ChatsScreen shows "Loading conversations..." on initial load
+- ContactsScreen shows "Loading contacts..." on initial load
+- Empty states ("No conversations yet", "No contacts yet") only appear after data loads and is empty
+- No regressions in existing functionality (lists display correctly, search works)
+
+
+
diff --git a/.planning/phases/02.3-loading-states-for-tabs/02.3-01-SUMMARY.md b/.planning/phases/02.3-loading-states-for-tabs/02.3-01-SUMMARY.md
new file mode 100644
index 00000000..e6668a5b
--- /dev/null
+++ b/.planning/phases/02.3-loading-states-for-tabs/02.3-01-SUMMARY.md
@@ -0,0 +1,106 @@
+---
+phase: 02.3-loading-states-for-tabs
+plan: 01
+subsystem: ui
+tags: [compose, stateflow, loading-states, ux]
+
+# Dependency graph
+requires:
+ - phase: 02.2-offline-maps
+ provides: completed foundation phases, MapViewModel loading state pattern
+provides:
+ - ChatsState data class with isLoading flag for Chats tab
+ - ContactsState data class with isLoading flag for Contacts tab
+ - LoadingConversationsState composable for Chats loading UI
+ - LoadingContactsState composable for Contacts loading UI
+ - Pattern for preventing empty state flash during data load
+affects: [future-ui-tabs, any-new-tabs-with-loading-states]
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - "State wrapper pattern: wrap List in data class with isLoading flag"
+ - "Loading state check before empty state in when expression"
+ - "Use map{} operator to transform Flow> to Flow>"
+
+key-files:
+ created: []
+ modified:
+ - app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt
+ - app/src/main/java/com/lxmf/messenger/ui/screens/ChatsScreen.kt
+ - app/src/main/java/com/lxmf/messenger/viewmodel/ContactsViewModel.kt
+ - app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt
+
+key-decisions:
+ - "Use boolean isLoading flag pattern (consistent with MapViewModel) instead of sealed class UiState"
+ - "Use map{} operator on Flow to wrap data with state, simpler than combine()"
+ - "Loading state only for initial load, not during search filtering"
+
+patterns-established:
+ - "State wrapper: data class XState(val data: T, val isLoading: Boolean = true)"
+ - "Loading check: when { isLoading -> Loading; data.isEmpty() -> Empty; else -> Content }"
+
+# Metrics
+duration: 9m 12s
+completed: 2026-01-28
+---
+
+# Phase 02.3 Plan 01: Loading States for Tabs Summary
+
+**State wrapper pattern for Chats and Contacts tabs with isLoading flag prevents empty state flash during data load**
+
+## Performance
+
+- **Duration:** 9m 12s
+- **Started:** 2026-01-28T23:22:25Z
+- **Completed:** 2026-01-28T23:31:37Z
+- **Tasks:** 2
+- **Files modified:** 4
+
+## Accomplishments
+- ChatsState data class wraps conversations list with isLoading flag
+- ContactsState data class wraps grouped contacts with isLoading flag
+- LoadingConversationsState composable shows spinner + "Loading conversations..." text
+- LoadingContactsState composable shows spinner + "Loading contacts..." text
+- Both screens now check isLoading before showing empty state, preventing the flash
+
+## Task Commits
+
+Each task was committed atomically:
+
+1. **Task 1: Add loading states to ChatsViewModel and ChatsScreen** - `b5137705` (feat)
+2. **Task 2: Add loading states to ContactsViewModel and ContactsScreen** - `d85fa290` (feat)
+
+## Files Created/Modified
+- `app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt` - Added ChatsState, replaced conversations with chatsState
+- `app/src/main/java/com/lxmf/messenger/ui/screens/ChatsScreen.kt` - Added LoadingConversationsState, updated when block
+- `app/src/main/java/com/lxmf/messenger/viewmodel/ContactsViewModel.kt` - Added ContactsState, replaced groupedContacts with contactsState
+- `app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt` - Added LoadingContactsState, updated when block
+
+## Decisions Made
+- Used boolean isLoading flag pattern (MapViewModel consistency) instead of sealed class UiState
+- Used map{} operator on Flow to transform data to state (simpler than combine with MutableStateFlow)
+- Loading state only appears on initial load, search filtering is instant (data is already loaded)
+
+## Deviations from Plan
+
+None - plan executed exactly as written.
+
+## Issues Encountered
+- Linter kept removing unused `map` import until the code actually used it - resolved by adding import after code using it
+- Brace counting mismatch from when block needed extra indentation fix - resolved by verifying structure with brace counting script
+
+## User Setup Required
+
+None - no external service configuration required.
+
+## Next Phase Readiness
+- Issue #341 (loading states flash) is now fixed
+- Users will see "Loading conversations..." and "Loading contacts..." on initial load
+- Empty states only appear when data has loaded AND is actually empty
+- Pattern established for any future tabs that need loading states
+
+---
+*Phase: 02.3-loading-states-for-tabs*
+*Completed: 2026-01-28*
diff --git a/.planning/phases/02.3-loading-states-for-tabs/02.3-VERIFICATION.md b/.planning/phases/02.3-loading-states-for-tabs/02.3-VERIFICATION.md
new file mode 100644
index 00000000..27b6fb24
--- /dev/null
+++ b/.planning/phases/02.3-loading-states-for-tabs/02.3-VERIFICATION.md
@@ -0,0 +1,97 @@
+---
+phase: 02.3-loading-states-for-tabs
+verified: 2026-01-28T10:30:00Z
+status: passed
+score: 5/5 must-haves verified
+---
+
+# Phase 2.3: Loading States for Tabs Verification Report
+
+**Phase Goal:** Show loading indicators while data initializes instead of flashing empty states ("No conversations yet", "No contacts yet") that confuse users into thinking no data exists
+
+**Verified:** 2026-01-28T10:30:00Z
+
+**Status:** passed
+
+**Re-verification:** No - initial verification
+
+## Goal Achievement
+
+### Observable Truths
+
+| # | Truth | Status | Evidence |
+|---|-------|--------|----------|
+| 1 | User sees "Loading conversations..." when navigating to Chats tab before data loads | VERIFIED | `ChatsScreen.kt` lines 159-167 check `chatsState.isLoading` first; `LoadingConversationsState` composable (lines 563-579) displays `CircularProgressIndicator` + "Loading conversations..." text |
+| 2 | User sees "Loading contacts..." when navigating to Contacts tab before data loads | VERIFIED | `ContactsScreen.kt` lines 423-431 check `contactsState.isLoading` first; `LoadingContactsState` composable (lines 1289-1305) displays `CircularProgressIndicator` + "Loading contacts..." text |
+| 3 | User does NOT see "No conversations yet" or "No contacts yet" while data is still loading | VERIFIED | Both screens use `when` blocks that check `isLoading` BEFORE checking for empty state, preventing empty state flash |
+| 4 | After Skip on onboarding, user sees loading state (not empty state) until data loads | VERIFIED | `ChatsViewModel.chatsState` has `initialValue = ChatsState(isLoading = true)` (line 76); `ContactsViewModel.contactsState` has `initialValue = ContactsState(isLoading = true)` (line 153) |
+| 5 | Empty states only appear when data has finished loading AND is actually empty | VERIFIED | In both screens, empty state condition is only reachable when `isLoading = false` (after first data emission from Room) |
+
+**Score:** 5/5 truths verified
+
+### Required Artifacts
+
+| Artifact | Expected | Status | Details |
+|----------|----------|--------|---------|
+| `app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt` | ChatsState data class with isLoading flag | VERIFIED | Lines 27-30: `data class ChatsState(val conversations: List = emptyList(), val isLoading: Boolean = true)` |
+| `app/src/main/java/com/lxmf/messenger/ui/screens/ChatsScreen.kt` | LoadingConversationsState composable, loading check before empty state | VERIFIED | Lines 159-167: `when { chatsState.isLoading -> LoadingConversationsState(...) }`; Lines 563-579: `LoadingConversationsState` composable |
+| `app/src/main/java/com/lxmf/messenger/viewmodel/ContactsViewModel.kt` | ContactsState data class with isLoading flag | VERIFIED | Lines 68-71: `data class ContactsState(val groupedContacts: ContactGroups = ..., val isLoading: Boolean = true)` |
+| `app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt` | LoadingContactsState composable, loading check before empty state | VERIFIED | Lines 423-431: `when { contactsState.isLoading -> LoadingContactsState(...) }`; Lines 1289-1305: `LoadingContactsState` composable |
+
+### Key Link Verification
+
+| From | To | Via | Status | Details |
+|------|----|-----|--------|---------|
+| ChatsScreen.kt | ChatsViewModel.kt | `collectAsState()` on chatsState | WIRED | Line 86: `val chatsState by viewModel.chatsState.collectAsState()` |
+| ContactsScreen.kt | ContactsViewModel.kt | `collectAsState()` on contactsState | WIRED | Line 134: `val contactsState by viewModel.contactsState.collectAsState()` |
+
+### Requirements Coverage
+
+| Requirement | Status | Notes |
+|-------------|--------|-------|
+| UX-LOADING-01 | SATISFIED | Loading indicators prevent flashing empty states |
+
+### Anti-Patterns Found
+
+None. No TODO/FIXME comments, no placeholder content, no stub implementations in the loading state code.
+
+### Human Verification Required
+
+| # | Test | Expected | Why Human |
+|---|------|----------|-----------|
+| 1 | Launch app fresh (clear data), navigate to Chats tab | See "Loading conversations..." with spinner briefly, then either conversations list or "No conversations yet" | Visual timing verification - need to see actual spinner appear before data loads |
+| 2 | Launch app fresh, navigate to Contacts tab | See "Loading contacts..." with spinner briefly, then either contacts list or "No contacts yet" | Visual timing verification |
+| 3 | Complete onboarding with Skip, observe Chats tab | Loading state should appear before any empty state | Tests the specific onboarding flow mentioned in issue #341 |
+
+### Implementation Details
+
+**ChatsViewModel StateFlow pattern:**
+```kotlin
+val chatsState: StateFlow =
+ searchQuery
+ .flatMapLatest { ... }
+ .map { conversations ->
+ ChatsState(conversations = conversations, isLoading = false)
+ }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000L),
+ initialValue = ChatsState(isLoading = true), // Key: starts loading
+ )
+```
+
+**ChatsScreen rendering logic:**
+```kotlin
+when {
+ chatsState.isLoading -> LoadingConversationsState(...) // Check loading FIRST
+ chatsState.conversations.isEmpty() -> EmptyChatsState(...) // Then empty
+ else -> LazyColumn(...) // Then data
+}
+```
+
+The same pattern is correctly applied in ContactsViewModel and ContactsScreen.
+
+---
+
+*Verified: 2026-01-28T10:30:00Z*
+*Verifier: Claude (gsd-verifier)*
diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/AnnounceStreamScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/AnnounceStreamScreen.kt
index 9281fd48..a33cc269 100644
--- a/app/src/main/java/com/lxmf/messenger/ui/screens/AnnounceStreamScreen.kt
+++ b/app/src/main/java/com/lxmf/messenger/ui/screens/AnnounceStreamScreen.kt
@@ -678,64 +678,73 @@ fun AnnounceStreamContent(
// Scroll state
val listState = rememberLazyListState()
- if (pagingItems.itemCount == 0) {
- EmptyAnnounceState(modifier = modifier.fillMaxSize())
- } else {
- LazyColumn(
- state = listState,
- modifier = modifier.fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- items(
- count = pagingItems.itemCount,
- key = pagingItems.itemKey { announce -> announce.destinationHash },
- ) { index ->
- val announce = pagingItems[index]
- if (announce != null) {
- Box {
- AnnounceCard(
- announce = announce,
- onClick = {
- onPeerClick(announce.destinationHash, announce.peerName)
- },
- onFavoriteClick = {
- viewModel.toggleContact(announce.destinationHash)
- },
- onLongPress = {
- contextMenuAnnounce = announce
- showContextMenu = true
- },
- )
+ // Check loading state - show spinner while initial data loads
+ val isLoading = pagingItems.loadState.refresh is androidx.paging.LoadState.Loading
- // Show context menu for this announce
- if (showContextMenu && contextMenuAnnounce == announce) {
- PeerContextMenu(
- expanded = true,
- onDismiss = { showContextMenu = false },
+ when {
+ isLoading -> {
+ LoadingNetworkState(modifier = modifier.fillMaxSize())
+ }
+ pagingItems.itemCount == 0 -> {
+ EmptyAnnounceState(modifier = modifier.fillMaxSize())
+ }
+ else -> {
+ LazyColumn(
+ state = listState,
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(
+ count = pagingItems.itemCount,
+ key = pagingItems.itemKey { announce -> announce.destinationHash },
+ ) { index ->
+ val announce = pagingItems[index]
+ if (announce != null) {
+ Box {
+ AnnounceCard(
announce = announce,
- onToggleFavorite = {
- viewModel.toggleContact(announce.destinationHash)
- },
- onStartChat = {
- onStartChat(announce.destinationHash, announce.peerName)
- },
- onViewDetails = {
+ onClick = {
onPeerClick(announce.destinationHash, announce.peerName)
},
- onDeleteAnnounce = {
- announceToDelete = announce
- showDeleteDialog = true
+ onFavoriteClick = {
+ viewModel.toggleContact(announce.destinationHash)
+ },
+ onLongPress = {
+ contextMenuAnnounce = announce
+ showContextMenu = true
},
)
+
+ // Show context menu for this announce
+ if (showContextMenu && contextMenuAnnounce == announce) {
+ PeerContextMenu(
+ expanded = true,
+ onDismiss = { showContextMenu = false },
+ announce = announce,
+ onToggleFavorite = {
+ viewModel.toggleContact(announce.destinationHash)
+ },
+ onStartChat = {
+ onStartChat(announce.destinationHash, announce.peerName)
+ },
+ onViewDetails = {
+ onPeerClick(announce.destinationHash, announce.peerName)
+ },
+ onDeleteAnnounce = {
+ announceToDelete = announce
+ showDeleteDialog = true
+ },
+ )
+ }
}
}
}
- }
- // Bottom spacing for navigation bar
- item {
- Spacer(modifier = Modifier.height(100.dp))
+ // Bottom spacing for navigation bar
+ item {
+ Spacer(modifier = Modifier.height(100.dp))
+ }
}
}
}
@@ -758,6 +767,25 @@ fun AnnounceStreamContent(
}
}
+@Composable
+fun LoadingNetworkState(modifier: Modifier = Modifier) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Loading network...",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
@Composable
fun EmptyAnnounceState(modifier: Modifier = Modifier) {
Column(
diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/ChatsScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/ChatsScreen.kt
index c619ca2b..ed487add 100644
--- a/app/src/main/java/com/lxmf/messenger/ui/screens/ChatsScreen.kt
+++ b/app/src/main/java/com/lxmf/messenger/ui/screens/ChatsScreen.kt
@@ -83,7 +83,7 @@ fun ChatsScreen(
onViewPeerDetails: (peerHash: String) -> Unit = {},
viewModel: ChatsViewModel = hiltViewModel(),
) {
- val conversations by viewModel.conversations.collectAsState()
+ val chatsState by viewModel.chatsState.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
val isSyncing by viewModel.isSyncing.collectAsState()
val syncProgress by viewModel.syncProgress.collectAsState()
@@ -123,7 +123,7 @@ fun ChatsScreen(
topBar = {
SearchableTopAppBar(
title = "Chats",
- subtitle = "${conversations.size} ${if (conversations.size == 1) "conversation" else "conversations"}",
+ subtitle = "${chatsState.conversations.size} ${if (chatsState.conversations.size == 1) "conversation" else "conversations"}",
isSearching = isSearching,
searchQuery = searchQuery,
onSearchQueryChange = { viewModel.searchQuery.value = it },
@@ -156,94 +156,107 @@ fun ChatsScreen(
)
},
) { paddingValues ->
- if (conversations.isEmpty()) {
- EmptyChatsState(
- modifier =
- Modifier
- .fillMaxSize()
- .padding(paddingValues),
- )
- } else {
- LazyColumn(
- modifier =
- Modifier
- .fillMaxSize()
- .padding(paddingValues)
- .consumeWindowInsets(paddingValues),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- items(conversations, key = { it.peerHash }) { conversation ->
- // Per-card state for context menu
- val hapticFeedback = LocalHapticFeedback.current
- var showMenu by remember { mutableStateOf(false) }
- val isSaved by viewModel.isContactSaved(conversation.peerHash).collectAsState()
+ when {
+ chatsState.isLoading -> {
+ LoadingConversationsState(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ )
+ }
+ chatsState.conversations.isEmpty() -> {
+ EmptyChatsState(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ )
+ }
+ else -> {
+ LazyColumn(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .consumeWindowInsets(paddingValues),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(chatsState.conversations, key = { it.peerHash }) { conversation ->
+ // Per-card state for context menu
+ val hapticFeedback = LocalHapticFeedback.current
+ var showMenu by remember { mutableStateOf(false) }
+ val isSaved by viewModel.isContactSaved(conversation.peerHash).collectAsState()
- // Wrap card and menu in Box to anchor menu to card
- Box(modifier = Modifier.fillMaxWidth()) {
- ConversationCard(
- conversation = conversation,
- isSaved = isSaved,
- onClick = { onChatClick(conversation.peerHash, conversation.displayName) },
- onLongPress = {
- hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
- showMenu = true
- },
- onStarClick = {
- if (isSaved) {
- viewModel.removeFromContacts(conversation.peerHash)
- Toast.makeText(
- context,
- "Removed ${conversation.displayName} from Contacts",
- Toast.LENGTH_SHORT,
- ).show()
- } else {
- viewModel.saveToContacts(conversation)
- Toast.makeText(
- context,
- "Saved ${conversation.displayName} to Contacts",
- Toast.LENGTH_SHORT,
- ).show()
- }
- },
- )
+ // Wrap card and menu in Box to anchor menu to card
+ Box(modifier = Modifier.fillMaxWidth()) {
+ ConversationCard(
+ conversation = conversation,
+ isSaved = isSaved,
+ onClick = { onChatClick(conversation.peerHash, conversation.displayName) },
+ onLongPress = {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ showMenu = true
+ },
+ onStarClick = {
+ if (isSaved) {
+ viewModel.removeFromContacts(conversation.peerHash)
+ Toast
+ .makeText(
+ context,
+ "Removed ${conversation.displayName} from Contacts",
+ Toast.LENGTH_SHORT,
+ ).show()
+ } else {
+ viewModel.saveToContacts(conversation)
+ Toast
+ .makeText(
+ context,
+ "Saved ${conversation.displayName} to Contacts",
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ },
+ )
- // Context menu anchored to this card
- ConversationContextMenu(
- expanded = showMenu,
- onDismiss = { showMenu = false },
- isSaved = isSaved,
- onSaveToContacts = {
- viewModel.saveToContacts(conversation)
- showMenu = false
- Toast.makeText(context, "Saved ${conversation.displayName} to Contacts", Toast.LENGTH_SHORT).show()
- },
- onRemoveFromContacts = {
- viewModel.removeFromContacts(conversation.peerHash)
- showMenu = false
- Toast.makeText(context, "Removed ${conversation.displayName} from Contacts", Toast.LENGTH_SHORT).show()
- },
- onMarkAsUnread = {
- viewModel.markAsUnread(conversation.peerHash)
- showMenu = false
- Toast.makeText(context, "Marked as unread", Toast.LENGTH_SHORT).show()
- },
- onDeleteConversation = {
- showMenu = false
- selectedConversation = conversation
- showDeleteDialog = true
- },
- onViewDetails = {
- showMenu = false
- onViewPeerDetails(conversation.peerHash)
- },
- )
+ // Context menu anchored to this card
+ ConversationContextMenu(
+ expanded = showMenu,
+ onDismiss = { showMenu = false },
+ isSaved = isSaved,
+ onSaveToContacts = {
+ viewModel.saveToContacts(conversation)
+ showMenu = false
+ Toast.makeText(context, "Saved ${conversation.displayName} to Contacts", Toast.LENGTH_SHORT).show()
+ },
+ onRemoveFromContacts = {
+ viewModel.removeFromContacts(conversation.peerHash)
+ showMenu = false
+ Toast.makeText(context, "Removed ${conversation.displayName} from Contacts", Toast.LENGTH_SHORT).show()
+ },
+ onMarkAsUnread = {
+ viewModel.markAsUnread(conversation.peerHash)
+ showMenu = false
+ Toast.makeText(context, "Marked as unread", Toast.LENGTH_SHORT).show()
+ },
+ onDeleteConversation = {
+ showMenu = false
+ selectedConversation = conversation
+ showDeleteDialog = true
+ },
+ onViewDetails = {
+ showMenu = false
+ onViewPeerDetails(conversation.peerHash)
+ },
+ )
+ }
}
- }
- // Bottom spacing for navigation bar (fixed height since M3 NavigationBar consumes the insets)
- item {
- Spacer(modifier = Modifier.height(100.dp))
+ // Bottom spacing for navigation bar (fixed height since M3 NavigationBar consumes the insets)
+ item {
+ Spacer(modifier = Modifier.height(100.dp))
+ }
}
}
}
@@ -546,6 +559,25 @@ fun DeleteConversationDialog(
)
}
+@Composable
+fun LoadingConversationsState(modifier: Modifier = Modifier) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Loading conversations...",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
@Composable
fun EmptyChatsState(modifier: Modifier = Modifier) {
Column(
@@ -575,9 +607,7 @@ fun EmptyChatsState(modifier: Modifier = Modifier) {
}
// Helper function to convert hex string to byte array (for identicon)
-private fun String.hexStringToByteArray(): ByteArray {
- return chunked(2).map { it.toInt(16).toByte() }.toByteArray()
-}
+private fun String.hexStringToByteArray(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// Reuse timestamp formatting from MessagingScreen
private fun formatTimestamp(timestamp: Long): String {
diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt
index eb374d6c..c1c24428 100644
--- a/app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt
+++ b/app/src/main/java/com/lxmf/messenger/ui/screens/ContactsScreen.kt
@@ -131,7 +131,7 @@ fun ContactsScreen(
onStartChat: (destinationHash: String, peerName: String) -> Unit = { _, _ -> },
) {
val context = LocalContext.current
- val groupedContacts by viewModel.groupedContacts.collectAsState()
+ val contactsState by viewModel.contactsState.collectAsState()
val contactCount by viewModel.contactCount.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
val currentRelayInfo by viewModel.currentRelayInfo.collectAsState()
@@ -165,10 +165,12 @@ fun ContactsScreen(
}
// Debug logging
- LaunchedEffect(groupedContacts) {
+ LaunchedEffect(contactsState) {
+ val groups = contactsState.groupedContacts
android.util.Log.d(
"ContactsScreen",
- "UI received: relay=${groupedContacts.relay?.displayName}, pinned=${groupedContacts.pinned.size}, all=${groupedContacts.all.size}",
+ "UI received: relay=${groups.relay?.displayName}, pinned=${groups.pinned.size}, " +
+ "all=${groups.all.size}, isLoading=${contactsState.isLoading}",
)
}
var showAddContactSheet by remember { mutableStateOf(false) }
@@ -420,158 +422,176 @@ fun ContactsScreen(
when (selectedTab) {
ContactsTab.MY_CONTACTS -> {
// My Contacts tab content
- if (groupedContacts.relay == null && groupedContacts.pinned.isEmpty() && groupedContacts.all.isEmpty()) {
- EmptyContactsState(
- modifier =
- Modifier
- .fillMaxSize()
- .padding(paddingValues),
- )
- } else {
- LazyColumn(
- modifier =
- Modifier
- .fillMaxSize()
- .padding(paddingValues)
- .consumeWindowInsets(paddingValues),
- contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- // My Relay section (shown at top, separate from pinned)
- android.util.Log.d("ContactsScreen", "LazyColumn composing, relay=${groupedContacts.relay?.displayName}")
- groupedContacts.relay?.let { relay ->
- android.util.Log.d("ContactsScreen", "Rendering MY RELAY section for: ${relay.displayName}")
- item {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp),
- ) {
- Icon(
- imageVector = Icons.Filled.Hub,
- contentDescription = null,
- modifier = Modifier.size(16.dp),
- tint = MaterialTheme.colorScheme.tertiary,
- )
- Spacer(modifier = Modifier.width(6.dp))
- Text(
- text = "MY RELAY",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.tertiary,
- )
- // Show "(auto)" badge if relay was auto-selected
- if (currentRelayInfo?.isAutoSelected == true) {
+ when {
+ contactsState.isLoading -> {
+ LoadingContactsState(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ )
+ }
+ contactsState.groupedContacts.relay == null &&
+ contactsState.groupedContacts.pinned
+ .isEmpty() &&
+ contactsState.groupedContacts.all
+ .isEmpty() -> {
+ EmptyContactsState(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ )
+ }
+ else -> {
+ LazyColumn(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .consumeWindowInsets(paddingValues),
+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ // My Relay section (shown at top, separate from pinned)
+ android.util.Log.d(
+ "ContactsScreen",
+ "LazyColumn composing, relay=${contactsState.groupedContacts.relay?.displayName}",
+ )
+ contactsState.groupedContacts.relay?.let { relay ->
+ android.util.Log.d("ContactsScreen", "Rendering MY RELAY section for: ${relay.displayName}")
+ item {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Hub,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.tertiary,
+ )
Spacer(modifier = Modifier.width(6.dp))
Text(
- text = "(auto)",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ text = "MY RELAY",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.tertiary,
)
+ // Show "(auto)" badge if relay was auto-selected
+ if (currentRelayInfo?.isAutoSelected == true) {
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = "(auto)",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
}
}
+ item(key = "relay_${relay.destinationHash}") {
+ ContactListItemWithMenu(
+ contact = relay,
+ onClick = {
+ if (relay.status == ContactStatus.PENDING_IDENTITY ||
+ relay.status == ContactStatus.UNRESOLVED
+ ) {
+ pendingContactToShow = relay
+ showPendingContactSheet = true
+ } else {
+ onContactClick(relay.destinationHash, relay.displayName)
+ }
+ },
+ onPinToggle = { viewModel.togglePin(relay.destinationHash) },
+ onEditNickname = {
+ editNicknameContactHash = relay.destinationHash
+ editNicknameCurrentValue = relay.customNickname
+ showEditNicknameDialog = true
+ },
+ onViewDetails = { onViewPeerDetails(relay.destinationHash) },
+ onRemove = {
+ relayToUnset = relay
+ showUnsetRelayDialog = true
+ },
+ )
+ }
}
- item(key = "relay_${relay.destinationHash}") {
- ContactListItemWithMenu(
- contact = relay,
- onClick = {
- if (relay.status == ContactStatus.PENDING_IDENTITY ||
- relay.status == ContactStatus.UNRESOLVED
- ) {
- pendingContactToShow = relay
- showPendingContactSheet = true
- } else {
- onContactClick(relay.destinationHash, relay.displayName)
- }
- },
- onPinToggle = { viewModel.togglePin(relay.destinationHash) },
- onEditNickname = {
- editNicknameContactHash = relay.destinationHash
- editNicknameCurrentValue = relay.customNickname
- showEditNicknameDialog = true
- },
- onViewDetails = { onViewPeerDetails(relay.destinationHash) },
- onRemove = {
- relayToUnset = relay
- showUnsetRelayDialog = true
- },
- )
- }
- }
-
- // Pinned contacts section
- if (groupedContacts.pinned.isNotEmpty()) {
- item {
- Text(
- text = "PINNED",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp),
- )
- }
- items(
- groupedContacts.pinned,
- key = { contact -> "pinned_${contact.destinationHash}" },
- ) { contact ->
- ContactListItemWithMenu(
- contact = contact,
- onClick = {
- if (contact.status == ContactStatus.PENDING_IDENTITY ||
- contact.status == ContactStatus.UNRESOLVED
- ) {
- pendingContactToShow = contact
- showPendingContactSheet = true
- } else {
- onContactClick(contact.destinationHash, contact.displayName)
- }
- },
- onPinToggle = { viewModel.togglePin(contact.destinationHash) },
- onEditNickname = {
- editNicknameContactHash = contact.destinationHash
- editNicknameCurrentValue = contact.customNickname
- showEditNicknameDialog = true
- },
- onViewDetails = { onViewPeerDetails(contact.destinationHash) },
- onRemove = { viewModel.deleteContact(contact.destinationHash) },
- )
- }
- }
- // All contacts section
- if (groupedContacts.all.isNotEmpty()) {
- if (groupedContacts.relay != null || groupedContacts.pinned.isNotEmpty()) {
+ // Pinned contacts section
+ if (contactsState.groupedContacts.pinned.isNotEmpty()) {
item {
Text(
- text = "ALL CONTACTS",
+ text = "PINNED",
style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp),
+ )
+ }
+ items(
+ contactsState.groupedContacts.pinned,
+ key = { contact -> "pinned_${contact.destinationHash}" },
+ ) { contact ->
+ ContactListItemWithMenu(
+ contact = contact,
+ onClick = {
+ if (contact.status == ContactStatus.PENDING_IDENTITY ||
+ contact.status == ContactStatus.UNRESOLVED
+ ) {
+ pendingContactToShow = contact
+ showPendingContactSheet = true
+ } else {
+ onContactClick(contact.destinationHash, contact.displayName)
+ }
+ },
+ onPinToggle = { viewModel.togglePin(contact.destinationHash) },
+ onEditNickname = {
+ editNicknameContactHash = contact.destinationHash
+ editNicknameCurrentValue = contact.customNickname
+ showEditNicknameDialog = true
+ },
+ onViewDetails = { onViewPeerDetails(contact.destinationHash) },
+ onRemove = { viewModel.deleteContact(contact.destinationHash) },
)
}
}
- items(
- groupedContacts.all,
- key = { contact -> contact.destinationHash },
- ) { contact ->
- ContactListItemWithMenu(
- contact = contact,
- onClick = {
- if (contact.status == ContactStatus.PENDING_IDENTITY ||
- contact.status == ContactStatus.UNRESOLVED
- ) {
- pendingContactToShow = contact
- showPendingContactSheet = true
- } else {
- onContactClick(contact.destinationHash, contact.displayName)
- }
- },
- onPinToggle = { viewModel.togglePin(contact.destinationHash) },
- onEditNickname = {
- editNicknameContactHash = contact.destinationHash
- editNicknameCurrentValue = contact.customNickname
- showEditNicknameDialog = true
- },
- onViewDetails = { onViewPeerDetails(contact.destinationHash) },
- onRemove = { viewModel.deleteContact(contact.destinationHash) },
- )
+
+ // All contacts section
+ if (contactsState.groupedContacts.all.isNotEmpty()) {
+ if (contactsState.groupedContacts.relay != null || contactsState.groupedContacts.pinned.isNotEmpty()) {
+ item {
+ Text(
+ text = "ALL CONTACTS",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
+ )
+ }
+ }
+ items(
+ contactsState.groupedContacts.all,
+ key = { contact -> contact.destinationHash },
+ ) { contact ->
+ ContactListItemWithMenu(
+ contact = contact,
+ onClick = {
+ if (contact.status == ContactStatus.PENDING_IDENTITY ||
+ contact.status == ContactStatus.UNRESOLVED
+ ) {
+ pendingContactToShow = contact
+ showPendingContactSheet = true
+ } else {
+ onContactClick(contact.destinationHash, contact.displayName)
+ }
+ },
+ onPinToggle = { viewModel.togglePin(contact.destinationHash) },
+ onEditNickname = {
+ editNicknameContactHash = contact.destinationHash
+ editNicknameCurrentValue = contact.customNickname
+ showEditNicknameDialog = true
+ },
+ onViewDetails = { onViewPeerDetails(contact.destinationHash) },
+ onRemove = { viewModel.deleteContact(contact.destinationHash) },
+ )
+ }
}
}
}
@@ -1117,8 +1137,7 @@ fun ContactListItem(
.background(
color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.2f),
shape = RoundedCornerShape(4.dp),
- )
- .padding(horizontal = 6.dp, vertical = 2.dp),
+ ).padding(horizontal = 6.dp, vertical = 2.dp),
) {
Text(
text = "RELAY",
@@ -1268,6 +1287,25 @@ fun ContactContextMenu(
}
}
+@Composable
+fun LoadingContactsState(modifier: Modifier = Modifier) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Loading contacts...",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
@Composable
fun EmptyContactsState(modifier: Modifier = Modifier) {
Column(
diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt
index 54ed2d05..ee74faa8 100644
--- a/app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt
+++ b/app/src/main/java/com/lxmf/messenger/viewmodel/ChatsViewModel.kt
@@ -15,11 +15,21 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
+/**
+ * UI state for the Chats tab, including loading status.
+ */
+data class ChatsState(
+ val conversations: List = emptyList(),
+ val isLoading: Boolean = true,
+)
+
@HiltViewModel
class ChatsViewModel
@Inject
@@ -47,8 +57,9 @@ class ChatsViewModel
// Search query state
val searchQuery = MutableStateFlow("")
- // Filtered conversations based on search query
- val conversations: StateFlow> =
+ // Filtered conversations based on search query, with loading state
+ // onStart emits loading state each time flow is collected (tab switch, screen entry)
+ val chatsState: StateFlow =
searchQuery
.flatMapLatest { query ->
if (query.isBlank()) {
@@ -56,11 +67,21 @@ class ChatsViewModel
} else {
conversationRepository.searchConversations(query)
}
- }
- .stateIn(
+ }.map { conversations ->
+ ChatsState(
+ conversations = conversations,
+ isLoading = false,
+ )
+ }.onStart {
+ emit(ChatsState(isLoading = true))
+ }.stateIn(
scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(5000L),
- initialValue = emptyList(),
+ started =
+ SharingStarted.WhileSubscribed(
+ stopTimeoutMillis = 0,
+ replayExpirationMillis = 0,
+ ),
+ initialValue = ChatsState(isLoading = true),
)
fun deleteConversation(peerHash: String) {
@@ -132,16 +153,16 @@ class ChatsViewModel
* Check if a peer is saved as a contact.
* Uses a cache to prevent flickering when the LazyColumn recomposes.
*/
- fun isContactSaved(peerHash: String): StateFlow {
- return contactSavedCache.getOrPut(peerHash) {
- contactRepository.hasContactFlow(peerHash)
+ fun isContactSaved(peerHash: String): StateFlow =
+ contactSavedCache.getOrPut(peerHash) {
+ contactRepository
+ .hasContactFlow(peerHash)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = false,
)
}
- }
/**
* Trigger a manual sync with the propagation node.
diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/ContactsViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/ContactsViewModel.kt
index e7f88245..677a03f2 100644
--- a/app/src/main/java/com/lxmf/messenger/viewmodel/ContactsViewModel.kt
+++ b/app/src/main/java/com/lxmf/messenger/viewmodel/ContactsViewModel.kt
@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -38,12 +40,16 @@ sealed class AddContactResult {
/**
* Contact already exists in the contact list.
*/
- data class AlreadyExists(val existingContact: EnrichedContact) : AddContactResult()
+ data class AlreadyExists(
+ val existingContact: EnrichedContact,
+ ) : AddContactResult()
/**
* An error occurred while adding the contact.
*/
- data class Error(val message: String) : AddContactResult()
+ data class Error(
+ val message: String,
+ ) : AddContactResult()
}
/**
@@ -57,6 +63,14 @@ data class ContactGroups(
val all: List,
)
+/**
+ * UI state for the Contacts tab, including loading status.
+ */
+data class ContactsState(
+ val groupedContacts: ContactGroups = ContactGroups(null, emptyList(), emptyList()),
+ val isLoading: Boolean = true,
+)
+
@HiltViewModel
class ContactsViewModel
@Inject
@@ -119,27 +133,38 @@ class ContactsViewModel
initialValue = emptyList(),
)
- // Grouped contacts for section headers (relay, pinned, all)
- val groupedContacts: StateFlow =
+ // Grouped contacts for section headers (relay, pinned, all), with loading state
+ // onStart emits loading state each time flow is collected (tab switch, screen entry)
+ val contactsState: StateFlow =
filteredContacts
- .combine(MutableStateFlow(Unit)) { contacts, _ ->
+ .map { contacts ->
val relay = contacts.find { it.isMyRelay }
- Log.d(TAG, "GroupedContacts: ${contacts.size} filtered, relay=${relay?.displayName}")
- ContactGroups(
- relay = relay,
- pinned = contacts.filter { it.isPinned && !it.isMyRelay },
- all = contacts.filterNot { it.isPinned || it.isMyRelay },
+ Log.d(TAG, "ContactsState: ${contacts.size} filtered, relay=${relay?.displayName}")
+ ContactsState(
+ groupedContacts =
+ ContactGroups(
+ relay = relay,
+ pinned = contacts.filter { it.isPinned && !it.isMyRelay },
+ all = contacts.filterNot { it.isPinned || it.isMyRelay },
+ ),
+ isLoading = false,
)
- }
- .stateIn(
+ }.onStart {
+ emit(ContactsState(isLoading = true))
+ }.stateIn(
scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(5000L),
- initialValue = ContactGroups(null, emptyList(), emptyList()),
+ started =
+ SharingStarted.WhileSubscribed(
+ stopTimeoutMillis = 0,
+ replayExpirationMillis = 0,
+ ),
+ initialValue = ContactsState(isLoading = true),
)
// Contact count
val contactCount: StateFlow =
- contactRepository.getContactCountFlow()
+ contactRepository
+ .getContactCountFlow()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000L),
@@ -374,8 +399,8 @@ class ContactsViewModel
* Decode QR code data and return the destination hash if valid.
* This is used for checking duplicates before showing the confirmation dialog.
*/
- fun decodeQrCode(qrData: String): Pair? {
- return try {
+ fun decodeQrCode(qrData: String): Pair? =
+ try {
val decoded = IdentityQrCodeUtils.decodeFromQrString(qrData)
if (decoded != null) {
val hashHex = decoded.destinationHash.joinToString("") { "%02x".format(it) }
@@ -387,7 +412,6 @@ class ContactsViewModel
Log.e(TAG, "Failed to decode QR code data", e)
null
}
- }
// ========== SIDEBAND IMPORT SUPPORT ==========
diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/OnboardingViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/OnboardingViewModel.kt
index 89c7a23c..e351d274 100644
--- a/app/src/main/java/com/lxmf/messenger/viewmodel/OnboardingViewModel.kt
+++ b/app/src/main/java/com/lxmf/messenger/viewmodel/OnboardingViewModel.kt
@@ -156,11 +156,11 @@ class OnboardingViewModel
// Update display name in database
val activeIdentity = identityRepository.getActiveIdentitySync()
if (activeIdentity != null) {
- identityRepository.updateDisplayName(activeIdentity.identityHash, nameToSave)
+ identityRepository
+ .updateDisplayName(activeIdentity.identityHash, nameToSave)
.onSuccess {
Log.d(TAG, "Display name set to: $nameToSave")
- }
- .onFailure { error ->
+ }.onFailure { error ->
Log.e(TAG, "Failed to update display name", error)
hasWarnings = true
}
@@ -179,11 +179,11 @@ class OnboardingViewModel
// Restart service to apply changes
Log.d(TAG, "Restarting service to apply onboarding settings...")
- interfaceConfigManager.applyInterfaceChanges()
+ interfaceConfigManager
+ .applyInterfaceChanges()
.onSuccess {
Log.d(TAG, "Service restarted with new settings")
- }
- .onFailure { error ->
+ }.onFailure { error ->
Log.w(TAG, "Failed to restart service (settings saved but may need manual restart)", error)
hasWarnings = true
}
@@ -306,6 +306,9 @@ class OnboardingViewModel
* Skip onboarding with default settings.
* Creates only AutoInterface (local WiFi) with default display name.
*
+ * Navigates immediately after saving settings, then applies interface changes
+ * in the background. User sees loading state on Chats screen while service starts.
+ *
* @param onComplete Callback invoked after skipping
*/
fun skipOnboarding(onComplete: () -> Unit) {
@@ -316,11 +319,11 @@ class OnboardingViewModel
// Set default display name
val activeIdentity = identityRepository.getActiveIdentitySync()
if (activeIdentity != null) {
- identityRepository.updateDisplayName(activeIdentity.identityHash, DEFAULT_DISPLAY_NAME)
+ identityRepository
+ .updateDisplayName(activeIdentity.identityHash, DEFAULT_DISPLAY_NAME)
.onSuccess {
Log.d(TAG, "Display name set to default: $DEFAULT_DISPLAY_NAME")
- }
- .onFailure { error ->
+ }.onFailure { error ->
Log.e(TAG, "Failed to set default display name", error)
}
}
@@ -341,28 +344,29 @@ class OnboardingViewModel
// Mark onboarding as completed
settingsRepository.markOnboardingCompleted()
- // Restart service to apply changes
- Log.d(TAG, "Restarting service to apply default settings...")
- interfaceConfigManager.applyInterfaceChanges()
+ // Navigate immediately - user sees loading state on Chats screen
+ _state.value =
+ _state.value.copy(
+ isSaving = false,
+ hasCompletedOnboarding = true,
+ )
+ Log.d(TAG, "Onboarding skipped, navigating to main screen")
+ onComplete()
+
+ // Apply interface changes in background (service restart takes 8-12 seconds)
+ // User sees "Loading conversations..." while this completes
+ Log.d(TAG, "Applying interface changes in background...")
+ interfaceConfigManager
+ .applyInterfaceChanges()
.onSuccess {
Log.d(TAG, "Service restarted with default settings")
- }
- .onFailure { error ->
+ }.onFailure { error ->
Log.w(
TAG,
"Failed to restart service (settings saved but may need manual restart)",
error,
)
}
-
- _state.value =
- _state.value.copy(
- isSaving = false,
- hasCompletedOnboarding = true,
- )
- Log.d(TAG, "Onboarding skipped, using default settings")
-
- onComplete()
} catch (e: Exception) {
Log.e(TAG, "Error skipping onboarding", e)
_state.value =
diff --git a/app/src/test/java/com/lxmf/messenger/ui/screens/ChatsScreenTest.kt b/app/src/test/java/com/lxmf/messenger/ui/screens/ChatsScreenTest.kt
index 8430c9a1..5c905361 100644
--- a/app/src/test/java/com/lxmf/messenger/ui/screens/ChatsScreenTest.kt
+++ b/app/src/test/java/com/lxmf/messenger/ui/screens/ChatsScreenTest.kt
@@ -94,9 +94,10 @@ class ChatsScreenTest {
}
// Then
- composeTestRule.onNodeWithText(
- "Are you sure you want to delete your conversation with Bob? This will permanently delete all messages.",
- ).assertIsDisplayed()
+ composeTestRule
+ .onNodeWithText(
+ "Are you sure you want to delete your conversation with Bob? This will permanently delete all messages.",
+ ).assertIsDisplayed()
}
@Test
@@ -890,10 +891,17 @@ class ChatsScreenTest {
conversations: List = emptyList(),
searchQuery: String = "",
isSyncing: Boolean = false,
+ isLoading: Boolean = false,
): ChatsViewModel {
val mockViewModel = mockk(relaxed = true)
- every { mockViewModel.conversations } returns MutableStateFlow(conversations)
+ every { mockViewModel.chatsState } returns
+ MutableStateFlow(
+ com.lxmf.messenger.viewmodel.ChatsState(
+ conversations = conversations,
+ isLoading = isLoading,
+ ),
+ )
every { mockViewModel.searchQuery } returns MutableStateFlow(searchQuery)
every { mockViewModel.isSyncing } returns MutableStateFlow(isSyncing)
every { mockViewModel.manualSyncResult } returns MutableSharedFlow()
diff --git a/app/src/test/java/com/lxmf/messenger/ui/screens/ContactsScreenTest.kt b/app/src/test/java/com/lxmf/messenger/ui/screens/ContactsScreenTest.kt
index 84e69719..522c898c 100644
--- a/app/src/test/java/com/lxmf/messenger/ui/screens/ContactsScreenTest.kt
+++ b/app/src/test/java/com/lxmf/messenger/ui/screens/ContactsScreenTest.kt
@@ -72,9 +72,10 @@ class ContactsScreenTest {
EmptyContactsState()
}
- composeTestRule.onNodeWithText(
- "Star peers in the Announce Stream\nor add contacts via QR code",
- ).assertIsDisplayed()
+ composeTestRule
+ .onNodeWithText(
+ "Star peers in the Announce Stream\nor add contacts via QR code",
+ ).assertIsDisplayed()
}
@Test
@@ -1175,10 +1176,17 @@ class ContactsScreenTest {
contactCount: Int = 0,
searchQuery: String = "",
currentRelayInfo: RelayInfo? = null,
+ isLoading: Boolean = false,
): ContactsViewModel {
val mockViewModel = mockk(relaxed = true)
- every { mockViewModel.groupedContacts } returns MutableStateFlow(groupedContacts)
+ every { mockViewModel.contactsState } returns
+ MutableStateFlow(
+ com.lxmf.messenger.viewmodel.ContactsState(
+ groupedContacts = groupedContacts,
+ isLoading = isLoading,
+ ),
+ )
every { mockViewModel.contactCount } returns MutableStateFlow(contactCount)
every { mockViewModel.searchQuery } returns MutableStateFlow(searchQuery)
every { mockViewModel.currentRelayInfo } returns MutableStateFlow(currentRelayInfo)
diff --git a/app/src/test/java/com/lxmf/messenger/viewmodel/ChatsViewModelTest.kt b/app/src/test/java/com/lxmf/messenger/viewmodel/ChatsViewModelTest.kt
index e1e2605b..3bf2531e 100644
--- a/app/src/test/java/com/lxmf/messenger/viewmodel/ChatsViewModelTest.kt
+++ b/app/src/test/java/com/lxmf/messenger/viewmodel/ChatsViewModelTest.kt
@@ -100,15 +100,17 @@ class ChatsViewModelTest {
}
@Test
- fun `initial state has empty conversations`() =
+ fun `initial state has empty conversations and is loading`() =
runTest {
- viewModel.conversations.test {
- assertEquals(emptyList(), awaitItem())
+ viewModel.chatsState.test {
+ val state = awaitItem()
+ assertEquals(emptyList(), state.conversations)
+ assertEquals(true, state.isLoading)
}
}
@Test
- fun `conversations flow emits repository data`() =
+ fun `chatsState flow emits repository data`() =
runTest {
// Create fresh mocks and configure BEFORE creating ViewModel
val repository: ConversationRepository = mockk()
@@ -119,14 +121,15 @@ class ChatsViewModelTest {
val newViewModel = ChatsViewModel(repository, mockk(), propagationNodeManager)
// WhileSubscribed requires active collector - test() provides one
- newViewModel.conversations.test {
- // Skip initial value (emptyList), wait for actual data from repository
- awaitItem() // Consume initialValue (emptyList)
+ newViewModel.chatsState.test {
+ // Skip initial loading state, wait for actual data from repository
+ awaitItem() // Consume initialValue (loading state)
advanceUntilIdle() // Let WhileSubscribed start the upstream flow
- val conversations = awaitItem() // This will be the actual data
- assertEquals(2, conversations.size)
- assertEquals("Alice", conversations[0].peerName)
- assertEquals("Bob", conversations[1].peerName)
+ val state = awaitItem() // This will be the actual data
+ assertEquals(2, state.conversations.size)
+ assertEquals("Alice", state.conversations[0].peerName)
+ assertEquals("Bob", state.conversations[1].peerName)
+ assertEquals(false, state.isLoading)
}
}
@@ -146,15 +149,15 @@ class ChatsViewModelTest {
// NOW create ViewModel
val newViewModel = ChatsViewModel(repository, mockk(), propagationNodeManager)
- newViewModel.conversations.test {
- // Skip initial value, wait for actual data from repository
- awaitItem() // Consume initialValue (emptyList)
+ newViewModel.chatsState.test {
+ // Skip initial loading state, wait for actual data from repository
+ awaitItem() // Consume initialValue (loading state)
advanceUntilIdle() // Let WhileSubscribed start the upstream flow
- val conversations = awaitItem() // This will be the actual data
- assertEquals(3, conversations.size)
- assertEquals("Charlie", conversations[0].peerName) // Most recent
- assertEquals("Bob", conversations[1].peerName)
- assertEquals("Alice", conversations[2].peerName) // Oldest
+ val state = awaitItem() // This will be the actual data
+ assertEquals(3, state.conversations.size)
+ assertEquals("Charlie", state.conversations[0].peerName) // Most recent
+ assertEquals("Bob", state.conversations[1].peerName)
+ assertEquals("Alice", state.conversations[2].peerName) // Oldest
}
}
@@ -198,7 +201,7 @@ class ChatsViewModelTest {
}
@Test
- fun `conversations flow updates when repository data changes`() =
+ fun `chatsState flow updates when repository data changes`() =
runTest {
// Create fresh mocks and configure BEFORE creating ViewModel
val repository: ConversationRepository = mockk()
@@ -209,22 +212,22 @@ class ChatsViewModelTest {
val newViewModel = ChatsViewModel(repository, mockk(), propagationNodeManager)
advanceUntilIdle()
- newViewModel.conversations.test {
- // Initial: empty
- assertEquals(0, awaitItem().size)
+ newViewModel.chatsState.test {
+ // Initial: loading state with empty conversations
+ assertEquals(0, awaitItem().conversations.size)
// Add first conversation
conversationsFlow.value = listOf(testConversation1)
- assertEquals(1, awaitItem().size)
+ assertEquals(1, awaitItem().conversations.size)
// Add second conversation
conversationsFlow.value = listOf(testConversation1, testConversation2)
- val conversations = awaitItem()
- assertEquals(2, conversations.size)
+ val state = awaitItem()
+ assertEquals(2, state.conversations.size)
// Remove one conversation
conversationsFlow.value = listOf(testConversation2)
- assertEquals(1, awaitItem().size)
+ assertEquals(1, awaitItem().conversations.size)
cancelAndConsumeRemainingEvents()
}
@@ -246,11 +249,11 @@ class ChatsViewModelTest {
// NOW create ViewModel
val newViewModel = ChatsViewModel(repository, mockk(), propagationNodeManager)
- newViewModel.conversations.test {
- // Skip initial value, wait for actual data from repository
- awaitItem() // Consume initialValue (emptyList)
+ newViewModel.chatsState.test {
+ // Skip initial loading state, wait for actual data from repository
+ awaitItem() // Consume initialValue (loading state)
advanceUntilIdle() // Let WhileSubscribed start the upstream flow
- val result = awaitItem() // This will be the actual data
+ val result = awaitItem().conversations // This will be the actual data
assertEquals(3, result.size)
assertEquals(5, result[0].unreadCount)
assertEquals(0, result[1].unreadCount)
@@ -274,11 +277,11 @@ class ChatsViewModelTest {
// NOW create ViewModel
val newViewModel = ChatsViewModel(repository, mockk(), propagationNodeManager)
- newViewModel.conversations.test {
- // Skip initial value, wait for actual data from repository
- awaitItem() // Consume initialValue (emptyList)
+ newViewModel.chatsState.test {
+ // Skip initial loading state, wait for actual data from repository
+ awaitItem() // Consume initialValue (loading state)
advanceUntilIdle() // Let WhileSubscribed start the upstream flow
- val result = awaitItem() // This will be the actual data
+ val result = awaitItem().conversations // This will be the actual data
assertEquals(3, result.size)
assertNull(result[0].peerPublicKey)
assertNotNull(result[1].peerPublicKey)
@@ -287,17 +290,21 @@ class ChatsViewModelTest {
}
@Test
- fun `conversations flow starts when subscribed`() =
+ fun `chatsState flow starts when subscribed`() =
runTest {
// WhileSubscribed starts only when there's an active subscriber
- viewModel.conversations.test {
+ viewModel.chatsState.test {
+ // Verify we receive initial loading state with empty conversations
+ val state = awaitItem()
+ assertEquals(emptyList(), state.conversations)
+ assertEquals(true, state.isLoading)
+
advanceUntilIdle()
// Verify that getConversations is called when we subscribe
verify { conversationRepository.getConversations() }
- // Verify we receive initial empty state
- assertEquals(emptyList(), awaitItem())
+ cancelAndIgnoreRemainingEvents()
}
}
}
diff --git a/app/src/test/java/com/lxmf/messenger/viewmodel/ContactsViewModelTest.kt b/app/src/test/java/com/lxmf/messenger/viewmodel/ContactsViewModelTest.kt
index 115ad5a2..7b9cd440 100644
--- a/app/src/test/java/com/lxmf/messenger/viewmodel/ContactsViewModelTest.kt
+++ b/app/src/test/java/com/lxmf/messenger/viewmodel/ContactsViewModelTest.kt
@@ -308,117 +308,142 @@ class ContactsViewModelTest {
@Test
fun `groupedContacts - separates relay`() =
runTest {
- // Given
- val relay =
- TestFactories.createEnrichedContact(
- destinationHash = "relay_hash",
- displayName = "My Relay",
- isMyRelay = true,
- )
- val regular =
- TestFactories.createEnrichedContact(
- destinationHash = "regular_hash",
- displayName = "Regular Contact",
+ // Given: Create fresh ViewModel with configured repository
+ val testContactsFlow =
+ MutableStateFlow(
+ listOf(
+ TestFactories.createEnrichedContact(
+ destinationHash = "relay_hash",
+ displayName = "My Relay",
+ isMyRelay = true,
+ ),
+ TestFactories.createEnrichedContact(
+ destinationHash = "regular_hash",
+ displayName = "Regular Contact",
+ ),
+ ),
)
- contactsFlow.value = listOf(relay, regular)
- advanceUntilIdle()
+ every { contactRepository.getEnrichedContacts() } returns testContactsFlow
+ val newViewModel = ContactsViewModel(contactRepository, propagationNodeManager)
- // Then
- viewModel.groupedContacts.test {
- skipItems(1)
- advanceUntilIdle()
- val groups = awaitItem()
+ // Then - wait for data to propagate through the flow chain
+ newViewModel.contactsState.test {
+ // Skip loading states until we get actual data
+ var groups = awaitItem().groupedContacts
+ while (groups.relay == null && groups.all.isEmpty()) {
+ advanceUntilIdle()
+ groups = awaitItem().groupedContacts
+ }
assertNotNull(groups.relay)
assertEquals("My Relay", groups.relay?.displayName)
assertEquals(1, groups.all.size)
+ cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `groupedContacts - separates pinned`() =
runTest {
- // Given
- val pinned =
- TestFactories.createEnrichedContact(
- destinationHash = "pinned_hash",
- displayName = "Pinned Contact",
- isPinned = true,
- )
- val regular =
- TestFactories.createEnrichedContact(
- destinationHash = "regular_hash",
- displayName = "Regular Contact",
- isPinned = false,
+ // Given: Create fresh ViewModel with configured repository
+ val testContactsFlow =
+ MutableStateFlow(
+ listOf(
+ TestFactories.createEnrichedContact(
+ destinationHash = "pinned_hash",
+ displayName = "Pinned Contact",
+ isPinned = true,
+ ),
+ TestFactories.createEnrichedContact(
+ destinationHash = "regular_hash",
+ displayName = "Regular Contact",
+ isPinned = false,
+ ),
+ ),
)
- contactsFlow.value = listOf(pinned, regular)
- advanceUntilIdle()
+ every { contactRepository.getEnrichedContacts() } returns testContactsFlow
+ val newViewModel = ContactsViewModel(contactRepository, propagationNodeManager)
- // Then
- viewModel.groupedContacts.test {
- skipItems(1)
- advanceUntilIdle()
- val groups = awaitItem()
+ // Then - wait for data to propagate through the flow chain
+ newViewModel.contactsState.test {
+ var groups = awaitItem().groupedContacts
+ while (groups.pinned.isEmpty() && groups.all.isEmpty()) {
+ advanceUntilIdle()
+ groups = awaitItem().groupedContacts
+ }
assertEquals(1, groups.pinned.size)
assertEquals("Pinned Contact", groups.pinned[0].displayName)
assertEquals(1, groups.all.size)
+ cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `groupedContacts - excludes relay from pinned`() =
runTest {
- // Given: Relay is also pinned
- val relay =
- TestFactories.createEnrichedContact(
- destinationHash = "relay_hash",
- displayName = "Relay",
- isMyRelay = true,
- isPinned = true,
+ // Given: Create fresh ViewModel with relay that is also pinned
+ val testContactsFlow =
+ MutableStateFlow(
+ listOf(
+ TestFactories.createEnrichedContact(
+ destinationHash = "relay_hash",
+ displayName = "Relay",
+ isMyRelay = true,
+ isPinned = true,
+ ),
+ ),
)
- contactsFlow.value = listOf(relay)
- advanceUntilIdle()
+ every { contactRepository.getEnrichedContacts() } returns testContactsFlow
+ val newViewModel = ContactsViewModel(contactRepository, propagationNodeManager)
- // Then: Should be in relay, not pinned
- viewModel.groupedContacts.test {
- skipItems(1)
- advanceUntilIdle()
- val groups = awaitItem()
+ // Then: Should be in relay, not pinned - wait for data to propagate
+ newViewModel.contactsState.test {
+ var groups = awaitItem().groupedContacts
+ while (groups.relay == null) {
+ advanceUntilIdle()
+ groups = awaitItem().groupedContacts
+ }
assertNotNull(groups.relay)
assertTrue(groups.pinned.isEmpty())
+ cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `groupedContacts - all group excludes relay and pinned`() =
runTest {
- // Given
- val relay =
- TestFactories.createEnrichedContact(
- destinationHash = "relay_hash",
- displayName = "Relay",
- isMyRelay = true,
- )
- val pinned =
- TestFactories.createEnrichedContact(
- destinationHash = "pinned_hash",
- displayName = "Pinned",
- isPinned = true,
- )
- val regular =
- TestFactories.createEnrichedContact(
- destinationHash = "regular_hash",
- displayName = "Regular",
+ // Given: Create fresh ViewModel with configured repository
+ val testContactsFlow =
+ MutableStateFlow(
+ listOf(
+ TestFactories.createEnrichedContact(
+ destinationHash = "relay_hash",
+ displayName = "Relay",
+ isMyRelay = true,
+ ),
+ TestFactories.createEnrichedContact(
+ destinationHash = "pinned_hash",
+ displayName = "Pinned",
+ isPinned = true,
+ ),
+ TestFactories.createEnrichedContact(
+ destinationHash = "regular_hash",
+ displayName = "Regular",
+ ),
+ ),
)
- contactsFlow.value = listOf(relay, pinned, regular)
- advanceUntilIdle()
+ every { contactRepository.getEnrichedContacts() } returns testContactsFlow
+ val newViewModel = ContactsViewModel(contactRepository, propagationNodeManager)
- // Then
- viewModel.groupedContacts.test {
- skipItems(1)
- advanceUntilIdle()
- val groups = awaitItem()
+ // Then - wait for data to propagate through the flow chain
+ newViewModel.contactsState.test {
+ var groups = awaitItem().groupedContacts
+ while (groups.all.isEmpty()) {
+ advanceUntilIdle()
+ groups = awaitItem().groupedContacts
+ }
assertEquals(1, groups.all.size)
assertEquals("Regular", groups.all[0].displayName)
+ cancelAndIgnoreRemainingEvents()
}
}