From 0faec8e03df6ced988d28ea13aca182d72f85ae1 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 23 May 2026 22:17:46 -0400 Subject: [PATCH 1/9] feat(theme): darken default dark-mode backgrounds to match iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default ("custom") Android dark theme rendered noticeably lighter than iOS, which uses near-black backgrounds. Align with iOS HIG dark system colors (slight off-black for the base, iOS secondary/tertiary greys for elevated surfaces) so the two platforms feel like the same app in dark mode. - background: #131215 → #0A0A0B (slight off-black, OLED-friendly without the harsh #000 step on LCD) - surface: #1F1E21 → #1C1C1E (iOS secondarySystemBackground) - surfaceVariant: #2B2A2E → #2C2C2E (iOS tertiarySystemBackground) - outline: #343338 → #38383A (iOS separator on dark) Named presets (Nord, Dracula, Gruvbox, …) are left untouched — their distinctive backgrounds are part of each preset's identity. --- app/src/main/kotlin/com/wisp/app/ui/theme/Theme.kt | 10 +++++----- app/src/main/kotlin/com/wisp/app/ui/theme/Themes.kt | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/theme/Theme.kt b/app/src/main/kotlin/com/wisp/app/ui/theme/Theme.kt index 2ce58a40..6a268612 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/theme/Theme.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/theme/Theme.kt @@ -106,13 +106,13 @@ fun WispTheme( primaryContainer = primaryContainerDark, onPrimaryContainer = lightenColor(accentColor, 0.5f), secondary = secondary, - background = Color(0xFF131215), - surface = Color(0xFF1F1E21), - surfaceVariant = Color(0xFF2B2A2E), + background = Color(0xFF0A0A0B), + surface = Color(0xFF1C1C1E), + surfaceVariant = Color(0xFF2C2C2E), onBackground = Color(0xFFE0E0E0), onSurface = Color(0xFFE0E0E0), onSurfaceVariant = Color(0xFF9998A0), - outline = Color(0xFF343338) + outline = Color(0xFF38383A) ) } else { val colors = themePreset.dark @@ -171,7 +171,7 @@ fun WispTheme( val wispColors = if (isDarkTheme) { if (isCustomTheme) { WispColors( - backgroundColor = Color(0xFF131215), + backgroundColor = Color(0xFF0A0A0B), zapColor = accentColor, repostColor = Color(0xFF4CAF50), bookmarkColor = accentColor, diff --git a/app/src/main/kotlin/com/wisp/app/ui/theme/Themes.kt b/app/src/main/kotlin/com/wisp/app/ui/theme/Themes.kt index 900d5de2..605baf29 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/theme/Themes.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/theme/Themes.kt @@ -10,13 +10,13 @@ object Themes { dark = ThemeColors( primary = Color(0xFFFF9800), secondary = Color(0xFFFFB74D), - background = Color(0xFF131215), - surface = Color(0xFF1F1E21), - surfaceVariant = Color(0xFF2B2A2E), + background = Color(0xFF0A0A0B), + surface = Color(0xFF1C1C1E), + surfaceVariant = Color(0xFF2C2C2E), onBackground = Color(0xFFE0E0E0), onSurface = Color(0xFFE0E0E0), onSurfaceVariant = Color(0xFF9998A0), - outline = Color(0xFF343338), + outline = Color(0xFF38383A), zapColor = Color(0xFFFF9800), repostColor = Color(0xFF4CAF50), bookmarkColor = Color(0xFFFF9800), From bdb036d1d3c3eff1b6f21415e1b2790b7a8cfddd Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 23 May 2026 16:12:44 -0400 Subject: [PATCH 2/9] feat(theme): match TopAppBar chrome to body color across screens Default `TopAppBarDefaults.topAppBarColors` uses `MaterialTheme.color Scheme.surface`, which sat noticeably lighter than the body after the preceding dark-mode background darken. iOS uses one near-black across body + chrome and reserves the lighter "surface" tone for elevated controls (pills, cards). Switch every screen's TopAppBar container to `background` so chrome reads as part of the page, not as a raised layer above it. 30 screens touched; only `containerColor` lines inside `TopAppBarDefaults.topAppBarColors(...)` blocks are changed, so other surface usages (cards, dialogs, sheets, the elevated pills the home top bar overlays) keep their existing tone. --- app/src/main/kotlin/com/wisp/app/ui/screen/ArticleScreen.kt | 2 +- .../main/kotlin/com/wisp/app/ui/screen/BlossomServersScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/BookmarkSetScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/BookmarksScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/CommunityScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt | 2 +- .../main/kotlin/com/wisp/app/ui/screen/ContactPickerScreen.kt | 2 +- .../main/kotlin/com/wisp/app/ui/screen/DmConversationScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/DmListScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/DraftsScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/GroupDetailScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/GroupRoomScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/HashtagFeedScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/ListScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/ListsHubScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/LiveStreamScreen.kt | 2 +- .../main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/PowSettingsScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/ProfileEditScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/RelayDetailScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/RelayHealthScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/RelayScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/SafetyScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/SocialGraphScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt | 2 +- app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt | 2 +- 29 files changed, 29 insertions(+), 29 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ArticleScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ArticleScreen.kt index cdb3180c..3537fc4e 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ArticleScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ArticleScreen.kt @@ -136,7 +136,7 @@ fun ArticleScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/BlossomServersScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/BlossomServersScreen.kt index cc7f206d..3ab0ef9b 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/BlossomServersScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/BlossomServersScreen.kt @@ -78,7 +78,7 @@ fun BlossomServersScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/BookmarkSetScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/BookmarkSetScreen.kt index 8b68ffea..548d8b0a 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/BookmarkSetScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/BookmarkSetScreen.kt @@ -103,7 +103,7 @@ fun BookmarkSetScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/BookmarksScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/BookmarksScreen.kt index c8036449..98255f8a 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/BookmarksScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/BookmarksScreen.kt @@ -71,7 +71,7 @@ fun BookmarksScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/CommunityScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/CommunityScreen.kt index 958c8349..e146f1e1 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/CommunityScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/CommunityScreen.kt @@ -77,7 +77,7 @@ fun CommunityScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt index 168c6235..cca16c0e 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt @@ -303,7 +303,7 @@ fun ComposeScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ContactPickerScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ContactPickerScreen.kt index 4ab05a53..3893e622 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ContactPickerScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ContactPickerScreen.kt @@ -87,7 +87,7 @@ fun ContactPickerScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) }, diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/DmConversationScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/DmConversationScreen.kt index 5c02d207..f1b5c0f6 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/DmConversationScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/DmConversationScreen.kt @@ -279,7 +279,7 @@ fun DmConversationScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/DmListScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/DmListScreen.kt index 80d26b6a..584e1542 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/DmListScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/DmListScreen.kt @@ -121,7 +121,7 @@ fun DmListScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) }, diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/DraftsScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/DraftsScreen.kt index 1da50495..ed727d45 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/DraftsScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/DraftsScreen.kt @@ -88,7 +88,7 @@ fun DraftsScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt index bd7ccefc..71a144de 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt @@ -867,7 +867,7 @@ fun FeedScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ), navigationIcon = { Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/GroupDetailScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/GroupDetailScreen.kt index 1c2f9364..cbce428e 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/GroupDetailScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/GroupDetailScreen.kt @@ -126,7 +126,7 @@ fun GroupDetailScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/GroupRoomScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/GroupRoomScreen.kt index 33bc91e9..9137420f 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/GroupRoomScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/GroupRoomScreen.kt @@ -398,7 +398,7 @@ fun GroupRoomScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/HashtagFeedScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/HashtagFeedScreen.kt index fb9108ed..dbe58f2e 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/HashtagFeedScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/HashtagFeedScreen.kt @@ -148,7 +148,7 @@ fun HashtagFeedScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt index 1739e902..110da1cb 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/InterfaceScreen.kt @@ -132,7 +132,7 @@ fun InterfaceScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ListScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ListScreen.kt index 93da2e15..e647b185 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ListScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ListScreen.kt @@ -190,7 +190,7 @@ fun ListScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ListsHubScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ListsHubScreen.kt index 2d9e8263..06805f92 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ListsHubScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ListsHubScreen.kt @@ -133,7 +133,7 @@ fun ListsHubScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) }, diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/LiveStreamScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/LiveStreamScreen.kt index 2fde73b1..4e2f5d85 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/LiveStreamScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/LiveStreamScreen.kt @@ -174,7 +174,7 @@ fun LiveStreamScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt index 98372d50..5ed61e69 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt @@ -342,7 +342,7 @@ fun NotificationsScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/PowSettingsScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/PowSettingsScreen.kt index fcc61ced..c004ec44 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/PowSettingsScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/PowSettingsScreen.kt @@ -60,7 +60,7 @@ fun PowSettingsScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ProfileEditScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ProfileEditScreen.kt index 2564973f..7a9f2056 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ProfileEditScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ProfileEditScreen.kt @@ -100,7 +100,7 @@ fun ProfileEditScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/RelayDetailScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/RelayDetailScreen.kt index 56135bbc..9f0200d5 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/RelayDetailScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/RelayDetailScreen.kt @@ -111,7 +111,7 @@ fun RelayDetailScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/RelayHealthScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/RelayHealthScreen.kt index dc0c20bd..3ecfa9a0 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/RelayHealthScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/RelayHealthScreen.kt @@ -71,7 +71,7 @@ fun RelayHealthScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/RelayScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/RelayScreen.kt index df0a5644..acc081e3 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/RelayScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/RelayScreen.kt @@ -73,7 +73,7 @@ fun RelayScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/SafetyScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/SafetyScreen.kt index 8ad24485..a8f2ab3d 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/SafetyScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/SafetyScreen.kt @@ -76,7 +76,7 @@ fun SafetyScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/SocialGraphScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/SocialGraphScreen.kt index bf751c06..4422ebe6 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/SocialGraphScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/SocialGraphScreen.kt @@ -146,7 +146,7 @@ fun SocialGraphScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt index ce55063f..b97e729e 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt @@ -222,7 +222,7 @@ fun ThreadScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt index 0641de9d..3edfd530 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt @@ -519,7 +519,7 @@ fun UserProfileScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt index 4a7fe06a..52ae009f 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt @@ -197,7 +197,7 @@ fun WalletScreen( } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.background ) ) } From bbfab2465219895390cae0515fa78e3cbe521b2c Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 23 May 2026 16:15:39 -0400 Subject: [PATCH 3/9] feat(theme): compact chrome heights, drop tab pill, iOS badge red MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS-style cleanup on the home screen's top + bottom chrome: - `FeedScreen` `CenterAlignedTopAppBar` clamps to 48dp content + status-bar inset (was Material's default ~64dp + inset). Drops the gap below the icon row that pushed the feed down. - `BottomBar` `NavigationBar` clamps to 56dp content + gesture inset (was Material's default 80dp). The 80dp slot reserves space for the label text we don't render — pure waste on small phones. - Tab indicator pill is suppressed (`indicatorColor = Color.Transparent`). The selected-icon orange tint is enough signal; matches iOS where the tab bar has no rounded background on the active tab. - Notification dot uses iOS systemRed (#FF3B30) instead of the app's primary accent so it reads as "alert" rather than "branded highlight" — same red iOS shows on the bell. - Filter icon for "All" content types switches from `Icons.Outlined.Dashboard` (1 large + 3 small panels) to `Icons.Outlined.GridView` (2x2 of equal squares) to match the iOS toolbar icon. --- .../com/wisp/app/ui/component/BottomBar.kt | 25 ++++++++++++++++--- .../com/wisp/app/ui/screen/FeedScreen.kt | 15 ++++++++++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/BottomBar.kt b/app/src/main/kotlin/com/wisp/app/ui/component/BottomBar.kt index bc3653bc..a57d3cda 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/BottomBar.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/BottomBar.kt @@ -19,6 +19,8 @@ import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.Icon import androidx.compose.ui.res.painterResource import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.height import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar @@ -28,6 +30,7 @@ import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.dp @@ -73,7 +76,18 @@ fun WispBottomBar( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) ) NavigationBar( - containerColor = MaterialTheme.colorScheme.surface, + // Match the body color (background) instead of Material's + // default surface tone — iOS uses one near-black across body + // + chrome and reserves the lighter "surface" for elevated + // controls (pills, cards). + containerColor = MaterialTheme.colorScheme.background, + // Material's default 80dp NavigationBar reserves a tall slot + // for labels we never render — clamp to 56dp + the gesture + // inset for a chrome height closer to the iOS tab bar. + modifier = Modifier.height( + 56.dp + NavigationBarDefaults.windowInsets + .asPaddingValues().calculateBottomPadding() + ), windowInsets = NavigationBarDefaults.windowInsets ) { visibleTabs.forEach { tab -> @@ -92,7 +106,9 @@ fun WispBottomBar( colors = NavigationBarItemDefaults.colors( selectedIconColor = MaterialTheme.colorScheme.primary, unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - indicatorColor = MaterialTheme.colorScheme.surfaceVariant + // Drop the active-pill indicator — the orange tint on + // the selected icon is enough signal (matches iOS). + indicatorColor = Color.Transparent ), icon = { val useBolt = com.wisp.app.ui.util.useBoltIcon() @@ -129,13 +145,16 @@ fun WispBottomBar( ) } if (hasUnread) { + // Notification dot uses iOS-standard badge red + // (#FF3B30) instead of the primary accent so it + // reads as "alert" rather than "branded highlight." Box( modifier = Modifier .size(8.dp) .align(Alignment.TopEnd) .offset(x = 2.dp, y = (-2).dp) .background( - color = MaterialTheme.colorScheme.primary, + color = Color(0xFFFF3B30), shape = CircleShape ) ) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt index 71a144de..f62db203 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/FeedScreen.kt @@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -125,6 +127,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.automirrored.outlined.Article import androidx.compose.material.icons.outlined.CurrencyBitcoin import androidx.compose.material.icons.outlined.Dashboard +import androidx.compose.material.icons.outlined.GridView import androidx.compose.material.icons.outlined.HowToVote import androidx.compose.material.icons.outlined.Photo import androidx.compose.material.icons.outlined.FavoriteBorder @@ -749,6 +752,14 @@ fun FeedScreen( contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { CenterAlignedTopAppBar( + // Material's default 64dp content + status-bar inset + // pads a chunky gap below the icon row. Clamp to 48dp + // + status-bar inset so the chrome lands closer to + // the iOS navigation bar (~44dp content + safe-area). + modifier = Modifier.height( + 48.dp + WindowInsets.statusBars + .asPaddingValues().calculateTopPadding() + ), title = { Box { Surface( @@ -888,7 +899,9 @@ fun FeedScreen( modifier = Modifier.size(36.dp) ) { val (icon, tint) = when (contentFilter) { - FeedContentFilter.ALL -> Icons.Outlined.Dashboard to MaterialTheme.colorScheme.onSurfaceVariant + // GridView = 2x2 of equal squares (matches iOS). + // Dashboard was 1 large + 3 small panels. + FeedContentFilter.ALL -> Icons.Outlined.GridView to MaterialTheme.colorScheme.onSurfaceVariant FeedContentFilter.TEXT_ONLY -> Icons.AutoMirrored.Outlined.Article to MaterialTheme.colorScheme.primary FeedContentFilter.GALLERY_ONLY -> Icons.Outlined.Photo to MaterialTheme.colorScheme.primary FeedContentFilter.POLLS_ONLY -> Icons.Outlined.HowToVote to MaterialTheme.colorScheme.primary From e7418b4c5489e3497c9f71ff2ccb9a0e570961c8 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 23 May 2026 16:17:55 -0400 Subject: [PATCH 4/9] feat(theme): full-width post divider, hide zero-value action counters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two post-card refinements that move the feed toward the iOS look: - `PostCard` now wraps content + the inter-post `HorizontalDivider` in an outer Column. The content Column keeps its 16dp horizontal padding (so post body / action bar / metadata stay inset), but the divider sits outside that padding and runs edge-to-edge. Matches iOS where the separator spans the full viewport width. - `ActionBar` gates each of the four counters (`replyCount`, `likeCount`, `repostCount`, `zapSats`) on `> 0`. Empty engagement no longer shows "0" beside the icon — matches iOS where the count text only appears when there's something to show. As soon as the count crosses 1, the number reappears. --- .../com/wisp/app/ui/component/ActionBar.kt | 46 +++++++++++-------- .../com/wisp/app/ui/component/PostCard.kt | 16 +++++-- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt b/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt index 22a68f35..e1ba35d5 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt @@ -104,12 +104,16 @@ fun ActionBar( modifier = Modifier.size(22.dp) ) } - Text( - text = replyCount.toString(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1 - ) + // Hide the count when zero — matches iOS minimalistic action bar + // where empty metrics drop entirely instead of showing "0". + if (replyCount > 0) { + Text( + text = replyCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } // React + Zap are available on private replies as gift-wrapped/DIP-03 actions. // Repost / Quote stay hidden on private replies because those events would // publicly attach an e-tag pointing at the encrypted rumor id. @@ -160,12 +164,14 @@ fun ActionBar( ) } } - Text( - text = likeCount.toString(), - style = MaterialTheme.typography.labelSmall, - color = if (userReactionEmojis.isNotEmpty()) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1 - ) + if (likeCount > 0) { + Text( + text = likeCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = if (userReactionEmojis.isNotEmpty()) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } if (!isPrivate) { Spacer(Modifier.width(8.dp)) Box { @@ -191,12 +197,14 @@ fun ActionBar( ) } } - Text( - text = repostCount.toString(), - style = MaterialTheme.typography.labelSmall, - color = if (hasUserReposted) WispThemeColors.repostColor else MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1 - ) + if (repostCount > 0) { + Text( + text = repostCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = if (hasUserReposted) WispThemeColors.repostColor else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } } Spacer(Modifier.width(8.dp)) Box { @@ -247,7 +255,7 @@ fun ActionBar( ) } } - if (!isZapInProgress) { + if (!isZapInProgress && zapSats > 0) { val context = LocalContext.current val fiatPrefs = remember { FiatPreferences.get(context) } fiatPrefs.fiatMode.collectAsState().value diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt b/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt index abc708be..8967ed2a 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt @@ -230,8 +230,12 @@ fun PostCard( } } + // Wrap content + divider so the divider can run full-width while the + // content keeps its 16dp horizontal padding. Tap-to-open lives on the + // content Column so the (tiny) divider area isn't tappable. + Column(modifier = modifier.fillMaxWidth()) { Column( - modifier = modifier + modifier = Modifier .fillMaxWidth() .then(if (onNoteClick != null) Modifier.pointerInput(onNoteClick) { awaitEachGesture { @@ -875,9 +879,13 @@ fun PostCard( } } } - if (showDivider) { - HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = 0.5.dp) - } + } + if (showDivider) { + // Full-bleed inter-post separator — sits outside the content + // Column's 16dp horizontal padding so it spans edge to edge, + // matching the iOS feed. + HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = 0.5.dp) + } } } From 2dee278b874fb163fbf20f07a590f733af9341fd Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 23 May 2026 16:19:23 -0400 Subject: [PATCH 5/9] feat(theme): iOS-red error color, darken profile tab strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more iOS-parity tweaks: - `WispTheme` sets `error = #FF3B30` (and `onError = white`) explicitly on every color-scheme variant (custom dark/light + preset dark/light). Material 3's defaults for `error` render pinkish in dark mode and a muted brick red in light mode — neither matches the iOS systemRed used by the rest of the destructive UI in this app. With this override, every `MaterialTheme.colorScheme.error` consumer (logout button, destructive labels, error text) now matches the iOS counterpart and the existing #FF3B30 used directly on Disconnect/Switch wallet flows. - `UserProfileScreen` sticky-header tab strip + the sort-pill row below it use `background` (#0A0A0B) instead of `surface` (#1C1C1E). The two grey tiers stacked above each other read as visually noisy on the profile; the iOS profile uses one near-black across both. Body posts below still render with the elevated tier where they need to. --- .../wisp/app/ui/screen/UserProfileScreen.kt | 11 ++++++--- .../kotlin/com/wisp/app/ui/theme/Theme.kt | 23 +++++++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt index 3edfd530..ee8ccf25 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/UserProfileScreen.kt @@ -643,7 +643,10 @@ fun UserProfileScreen( } stickyHeader { - val surfaceColor = MaterialTheme.colorScheme.surface + // Tab strip uses `background` (true near-black) instead of + // `surface` so the chrome reads as part of the body and + // doesn't stack two distinct grey tiers — matches iOS. + val surfaceColor = MaterialTheme.colorScheme.background Column { Box( modifier = Modifier.background(surfaceColor).drawWithContent { @@ -689,7 +692,9 @@ fun UserProfileScreen( Box( contentAlignment = Alignment.Center, modifier = Modifier - .height(34.dp) + // 28dp (was 34) — matches iOS tighter + // tab row over the profile header. + .height(28.dp) .clickable { selectedTab = index } .padding(horizontal = 10.dp) ) { @@ -703,7 +708,7 @@ fun UserProfileScreen( } } } - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(6.dp)) } } diff --git a/app/src/main/kotlin/com/wisp/app/ui/theme/Theme.kt b/app/src/main/kotlin/com/wisp/app/ui/theme/Theme.kt index 6a268612..2117ddc4 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/theme/Theme.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/theme/Theme.kt @@ -98,6 +98,13 @@ fun WispTheme( val primaryContainerDark = remember(primary) { darkenColor(primary, 0.6f) } val primaryContainerLight = remember(primary) { lightenColor(primary, 0.7f) } + // iOS systemRed (#FF3B30) for destructive / error UI. Material 3's + // default `error` renders pinkish in dark mode (`#F2B8B5`) and a + // muted brick red in light mode (`#B3261E`); both read "off" next + // to the iOS counterpart. Setting `error` explicitly propagates to + // every `MaterialTheme.colorScheme.error` consumer (logout, alerts, + // destructive labels). + val iosRed = Color(0xFFFF3B30) val colorScheme = if (isDarkTheme) { if (isCustomTheme) { darkColorScheme( @@ -112,7 +119,9 @@ fun WispTheme( onBackground = Color(0xFFE0E0E0), onSurface = Color(0xFFE0E0E0), onSurfaceVariant = Color(0xFF9998A0), - outline = Color(0xFF38383A) + outline = Color(0xFF38383A), + error = iosRed, + onError = Color.White ) } else { val colors = themePreset.dark @@ -129,7 +138,9 @@ fun WispTheme( onBackground = colors.onBackground, onSurface = colors.onSurface, onSurfaceVariant = colors.onSurfaceVariant, - outline = colors.outline + outline = colors.outline, + error = iosRed, + onError = Color.White ) } } else { @@ -146,7 +157,9 @@ fun WispTheme( onBackground = Color(0xFF1C1B1F), onSurface = Color(0xFF1C1B1F), onSurfaceVariant = Color(0xFF6B6B6B), - outline = Color(0xFFCCCCCC) + outline = Color(0xFFCCCCCC), + error = iosRed, + onError = Color.White ) } else { val colors = themePreset.light @@ -163,7 +176,9 @@ fun WispTheme( onBackground = colors.onBackground, onSurface = colors.onSurface, onSurfaceVariant = colors.onSurfaceVariant, - outline = colors.outline + outline = colors.outline, + error = iosRed, + onError = Color.White ) } } From 1a6982ce25796e29c05b3331f829c6accdfd2e1e Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 23 May 2026 17:51:47 -0400 Subject: [PATCH 6/9] feat(thread): tighten depth connectors to match iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce per-depth indent column from 12dp to 8dp and stroke width from 1.5dp to 1dp, with rounded stroke caps. Aesthetic only — no change to thread structure or interaction. --- app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt index b97e729e..5e0ac943 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource @@ -281,7 +282,7 @@ fun ThreadScreen( val userZapPollVote = remember(pollVoteVersion, event.id) { if (event.kind == 6969) eventRepo.getUserZapPollVote(event.id) else null } - val indentDp = 12 + val indentDp = 8 val clampedDepth = min(depth, 8) val lineColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f) Box( @@ -289,13 +290,15 @@ fun ThreadScreen( .fillMaxWidth() .drawBehind { val indentPx = indentDp.dp.toPx() + val strokePx = 1.dp.toPx() for (level in 0 until clampedDepth) { val x = level * indentPx + indentPx / 2f drawLine( color = lineColor, start = Offset(x, 0f), end = Offset(x, size.height), - strokeWidth = 1.5.dp.toPx() + strokeWidth = strokePx, + cap = StrokeCap.Round ) } } From dd40ec26c7657369a0ac7072486ef9c8f4a98a58 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 23 May 2026 21:05:54 -0400 Subject: [PATCH 7/9] feat(thread): iOS-style L-shaped depth connectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the stacked vertical depth lines with a single L-shaped outline per non-root reply — matches the iOS thread view 1:1. For each reply (depth > 0): - Vertical line at `x = depth * indentStep` (16dp per level) running from the top of the post box down to the start of the corner arc. - Counter-clockwise rounded 90° arc (12dp corner radius) at the post's bottom-left, curving the vertical into the horizontal divider. - The horizontal portion of the L is the existing PostCard `HorizontalDivider` at the bottom — no extra drawing needed. Same-depth siblings each get their own self-contained L (no chaining across siblings, matching iOS). Posts are indented by `depth * 16dp` so the L's vertical sits at the post's left content edge. Replaces the earlier per-depth multi-line stack (which drew through avatars and didn't curve) and the top-right elbow attempt (which landed at avatar height, not the bottom-left where iOS terminates). --- .../com/wisp/app/ui/screen/ThreadScreen.kt | 62 ++++++++++++++----- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt index 5e0ac943..a7bead8e 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt @@ -49,7 +49,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource @@ -282,25 +284,57 @@ fun ThreadScreen( val userZapPollVote = remember(pollVoteVersion, event.id) { if (event.kind == 6969) eventRepo.getUserZapPollVote(event.id) else null } - val indentDp = 8 + // iOS-style depth connectors. Each reply is indented + // by `indentStep * depth`; for non-root replies an + // L-shaped outline hugs the bottom-left of the post — + // a vertical line on the indented left edge from + // top-to-bottom of the post, plus a rounded + // counter-clockwise 90° corner that merges into the + // existing horizontal divider at the bottom. + val indentStepDp = 16.dp val clampedDepth = min(depth, 8) + val cornerRadiusDp = 12.dp val lineColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f) + val showConnector = depth > 0 Box( modifier = Modifier .fillMaxWidth() .drawBehind { - val indentPx = indentDp.dp.toPx() + if (!showConnector) return@drawBehind + val lineX = clampedDepth * indentStepDp.toPx() + val r = cornerRadiusDp.toPx() val strokePx = 1.dp.toPx() - for (level in 0 until clampedDepth) { - val x = level * indentPx + indentPx / 2f - drawLine( - color = lineColor, - start = Offset(x, 0f), - end = Offset(x, size.height), - strokeWidth = strokePx, - cap = StrokeCap.Round - ) - } + // Vertical line: top of post down to + // where the arc begins. + drawLine( + color = lineColor, + start = Offset(lineX, 0f), + end = Offset(lineX, size.height - r), + strokeWidth = strokePx + ) + // Bottom-left rounded corner. Bounding + // ellipse has top-left = (lineX, h-2r), + // size = (2r, 2r), so its center sits + // at (lineX+r, h-r) — the inside of the + // L. The arc occupies the bottom-left + // quadrant (from west-of-center to + // south-of-center). Parameterized as + // start = 90° (south point on screen), + // sweep = 90° clockwise → 180° (west); + // visually that traces the ╰ curve. + drawArc( + color = lineColor, + startAngle = 90f, + sweepAngle = 90f, + useCenter = false, + topLeft = Offset(lineX, size.height - 2f * r), + size = Size(2f * r, 2f * r), + style = Stroke(width = strokePx, cap = StrokeCap.Round) + ) + // The horizontal portion of the L is + // the existing PostCard `HorizontalDivider` + // at y = size.height — no extra drawing + // needed here. } ) { if (isGalleryEvent(event)) { @@ -346,7 +380,7 @@ fun ThreadScreen( nip05Repo = nip05Repo, onQuotedNoteClick = onQuotedNoteClick, noteActions = noteActions, - modifier = Modifier.padding(start = (clampedDepth * indentDp).dp) + modifier = Modifier.padding(start = (clampedDepth * indentStepDp.value).dp) ) } else { PostCard( @@ -405,7 +439,7 @@ fun ThreadScreen( zapPollTotalSats = zapPollTotalSats, userZapPollVote = userZapPollVote, onZapPollVote = { idx -> onZapPollVote(event.id, idx) }, - modifier = Modifier.padding(start = (clampedDepth * indentDp).dp) + modifier = Modifier.padding(start = (clampedDepth * indentStepDp.value).dp) ) } } From ee544f9a37c78fa2e0fab08f5580a754dd21d398 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 23 May 2026 21:56:27 -0400 Subject: [PATCH 8/9] feat(thread): iOS-parity indent, reply order, sticky reply bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to match wisp-ios `ThreadView.swift` / `ThreadViewModel.swift`: Indent + connector geometry - Per-depth step `12.dp` (was 16) capped at depth 5 (was 8), matching `indentationWidth = min(depth, 5) * 12` on iOS. - Connector vertical sits at `clampedDepth * 12 − 7`dp from the screen edge — same math iOS uses (`.padding(.leading, depth*12 − 8)` plus `x = 1` inside the shape). - Corner radius `8.dp` (was 12), `1.dp` stroke. - PostCard's full-width divider is suppressed for non-root replies (`showDivider = false`); the connector draws its own partial divider starting at the arc's end so no horizontal line extends to the LEFT of the curve. Reply sort order - `ThreadViewModel.kt` no longer bubbles the user's own replies to the top within each level. Children sorted purely by `created_at` oldest-first to match iOS's `buildNestedReplies`. Threads now order identically across platforms. Sticky reply bar - New `ThreadReplyBar` composable wired into the Scaffold's `bottomBar`. Thin 0.5dp `outlineVariant @ 50%` divider, then an 18dp rounded `surfaceVariant @ 50%` pill with "Reply…" placeholder text + an orange `Icons.Outlined.Edit` icon (closest Material to iOS's `square.and.pencil`). - Tap-anywhere on the pill opens compose with the thread's focal (the first entry in `flatThread`) as the reply parent. Disabled until flatThread populates. - Outer padding `12.dp` horizontal × `8.dp` vertical, container background `MaterialTheme.colorScheme.background`. Matches `ThreadView.swift::composer` 1:1. --- .../com/wisp/app/ui/screen/ThreadScreen.kt | 133 ++++++++++++++---- .../com/wisp/app/viewmodel/ThreadViewModel.kt | 14 +- 2 files changed, 111 insertions(+), 36 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt index a7bead8e..ec6aa57d 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt @@ -20,8 +20,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -29,6 +31,9 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.ui.res.stringResource import com.wisp.app.R import androidx.compose.material3.TopAppBar @@ -214,6 +219,10 @@ fun ThreadScreen( Toast.makeText(zapDisabledContext, zapDisabledMessage, Toast.LENGTH_SHORT).show() } + // iOS-parity sticky composer at the bottom of the thread — tap opens + // compose with the thread's focal as the reply parent. Disabled until + // the focal has loaded (flatThread populated). + val focalEvent = flatThread.firstOrNull()?.first Scaffold( contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { @@ -228,6 +237,12 @@ fun ThreadScreen( containerColor = MaterialTheme.colorScheme.background ) ) + }, + bottomBar = { + ThreadReplyBar( + enabled = focalEvent != null, + onClick = { focalEvent?.let { onReply(it) } } + ) } ) { padding -> if (isLoading && flatThread.isEmpty()) { @@ -284,44 +299,40 @@ fun ThreadScreen( val userZapPollVote = remember(pollVoteVersion, event.id) { if (event.kind == 6969) eventRepo.getUserZapPollVote(event.id) else null } - // iOS-style depth connectors. Each reply is indented - // by `indentStep * depth`; for non-root replies an - // L-shaped outline hugs the bottom-left of the post — - // a vertical line on the indented left edge from - // top-to-bottom of the post, plus a rounded - // counter-clockwise 90° corner that merges into the - // existing horizontal divider at the bottom. - val indentStepDp = 16.dp - val clampedDepth = min(depth, 8) - val cornerRadiusDp = 12.dp - val lineColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f) + // iOS-parity depth connector (matches + // ThreadView.swift's ReplyConnectorShape + + // indentationWidth). Each non-root reply: + // • indents by `depth * 12dp` (capped at 5) + // • draws a single L on its left: vertical from + // top → (bottom − r), 8dp rounded fillet, + // horizontal from arc end → right edge + // • the connector itself sits 8dp LEFT of the + // post's content column so the curve hooks + // cleanly into the row. + val indentStepDp = 12.dp + val clampedDepth = min(depth, 5) + val cornerRadiusDp = 8.dp + val lineColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) val showConnector = depth > 0 Box( modifier = Modifier .fillMaxWidth() .drawBehind { if (!showConnector) return@drawBehind - val lineX = clampedDepth * indentStepDp.toPx() + // Vertical sits at the connector's left + // edge — `clampedDepth*12 − 8 + 1` from + // screen, matching the iOS + // `.padding(.leading, depth*12 − 8)` + // and `x = 1` inside the shape. + val lineX = (clampedDepth * indentStepDp.toPx()) - 8.dp.toPx() + 1.dp.toPx() val r = cornerRadiusDp.toPx() val strokePx = 1.dp.toPx() - // Vertical line: top of post down to - // where the arc begins. drawLine( color = lineColor, start = Offset(lineX, 0f), end = Offset(lineX, size.height - r), strokeWidth = strokePx ) - // Bottom-left rounded corner. Bounding - // ellipse has top-left = (lineX, h-2r), - // size = (2r, 2r), so its center sits - // at (lineX+r, h-r) — the inside of the - // L. The arc occupies the bottom-left - // quadrant (from west-of-center to - // south-of-center). Parameterized as - // start = 90° (south point on screen), - // sweep = 90° clockwise → 180° (west); - // visually that traces the ╰ curve. drawArc( color = lineColor, startAngle = 90f, @@ -331,10 +342,16 @@ fun ThreadScreen( size = Size(2f * r, 2f * r), style = Stroke(width = strokePx, cap = StrokeCap.Round) ) - // The horizontal portion of the L is - // the existing PostCard `HorizontalDivider` - // at y = size.height — no extra drawing - // needed here. + // Horizontal divider from arc end → right + // edge. PostCard's full-width divider is + // suppressed (`showDivider = false`) so + // no line renders LEFT of the curve. + drawLine( + color = lineColor, + start = Offset(lineX + r, size.height), + end = Offset(size.width, size.height), + strokeWidth = strokePx + ) } ) { if (isGalleryEvent(event)) { @@ -380,6 +397,7 @@ fun ThreadScreen( nip05Repo = nip05Repo, onQuotedNoteClick = onQuotedNoteClick, noteActions = noteActions, + showDivider = !showConnector, modifier = Modifier.padding(start = (clampedDepth * indentStepDp.value).dp) ) } else { @@ -439,6 +457,7 @@ fun ThreadScreen( zapPollTotalSats = zapPollTotalSats, userZapPollVote = userZapPollVote, onZapPollVote = { idx -> onZapPollVote(event.id, idx) }, + showDivider = !showConnector, modifier = Modifier.padding(start = (clampedDepth * indentStepDp.value).dp) ) } @@ -646,3 +665,61 @@ private fun SpamToggle(count: Int, expanded: Boolean, onToggle: () -> Unit) { } } +/** + * Sticky reply composer at the bottom of the thread view — iOS parity + * (see `wisp-ios/wisp/ThreadView.swift::composer`). + * + * A thin divider runs the full width above a tap-to-compose pill: a + * surfaceVariant-tinted rounded rect with "Reply…" placeholder text on + * the left and an orange edit icon on the right. The whole pill is the + * tap target; iOS uses `square.and.pencil` here so we use + * `Icons.Outlined.Edit` (the closest Material equivalent). + */ +@Composable +private fun ThreadReplyBar( + enabled: Boolean, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + ) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + thickness = 0.5.dp + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(18.dp), + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp) + ) { + Text( + text = "Reply…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = "Reply", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + } + } + } + } +} + diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/ThreadViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/ThreadViewModel.kt index bf822f60..d1b62b4d 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/ThreadViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/ThreadViewModel.kt @@ -466,16 +466,14 @@ class ThreadViewModel : ViewModel() { scoreAuthorsAsync(pubkeysToScore) } - val myPubkey = currentUserPubkey + // Sort children by createdAt (oldest first), matching iOS + // `ThreadViewModel.buildNestedReplies`. Previously the user's own + // replies were bubbled to the top within each level — that's + // useful in some clients but diverges from iOS, so threads showed + // different orderings across platforms. for (children in parentToChildren.values) { children.sortWith(Comparator { a, b -> - val aIsOwn = myPubkey != null && a.pubkey == myPubkey - val bIsOwn = myPubkey != null && b.pubkey == myPubkey - if (aIsOwn != bIsOwn) { - if (aIsOwn) -1 else 1 - } else { - a.created_at.compareTo(b.created_at) - } + a.created_at.compareTo(b.created_at) }) } From 7e204b94d6302fcb7752be4e2207fa27351598fb Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 23 May 2026 22:01:37 -0400 Subject: [PATCH 9/9] fix(compose): disable Publish until post has content or attachment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Material's filled Button stays fully tappable even when the post body is empty — easy to fire off an empty note by accident. Gate the Publish button's `enabled` on having either at least one non-whitespace character or at least one uploaded attachment. Matches the iOS composer's send-button gating. `!publishing && !isMiningBusy` continues to gate against double-taps during publish / PoW mining. --- .../main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt index cca16c0e..b85cb7c5 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ComposeScreen.kt @@ -1267,6 +1267,11 @@ fun ComposeScreen( } } } else { + // Publish is disabled until the post has at least one + // character of text OR at least one uploaded attachment. + // Prevents accidental empty posts and matches the iOS + // composer's send-button gating. + val hasContent = content.text.isNotBlank() || uploadedUrls.isNotEmpty() Button( onClick = { viewModel.publish( @@ -1282,7 +1287,7 @@ fun ComposeScreen( resolvedEmojis = resolvedEmojis ) }, - enabled = !publishing && !isMiningBusy, + enabled = !publishing && !isMiningBusy && hasContent, modifier = Modifier.fillMaxWidth().height(44.dp), contentPadding = PaddingValues(0.dp) ) {