diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt
index 4a1399932..33c4d1b1f 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt
@@ -18,23 +18,37 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.material.Text
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
+import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
import com.flipcash.app.core.navigation.NavBarButton
import com.flipcash.app.core.navigation.NavBarConfig
+import com.flipcash.app.theme.FlipcashThemeWrapper
import com.flipcash.core.R
import com.getcode.theme.CodeTheme
import com.getcode.theme.xxl
@@ -98,7 +112,6 @@ fun NavigationBar(
modifier = buttonModifier,
label = stringResource(R.string.action_wallet),
painter = painterResource(R.drawable.ic_flipcash_balance),
- badgeCount = state.notificationUnreadCount,
onClick = { onButtonClick(NavBarButton.Wallet) },
toast = {
AnimatedVisibility(
@@ -131,8 +144,8 @@ fun NavigationBar(
NavBarButton.Send -> BottomBarAction(
modifier = buttonModifier,
label = stringResource(R.string.action_send),
+ badgeCount = state.notificationUnreadCount,
painter = painterResource(R.drawable.ic_send_outlined),
- badgeCount = 0,
onClick = { onButtonClick(NavBarButton.Send) }
)
}
@@ -154,7 +167,9 @@ private fun BottomBarAction(
onClick: (() -> Unit)?,
) {
Column(
- modifier = modifier.width(IntrinsicSize.Max),
+ modifier = modifier
+ .then(if (badgeCount > 0) Modifier.zIndex(1f) else Modifier)
+ .width(IntrinsicSize.Max),
horizontalAlignment = Alignment.CenterHorizontally,
) {
toast()
@@ -165,7 +180,6 @@ private fun BottomBarAction(
imageSize = imageSize,
badge = {
Badge(
- modifier = Modifier.padding(top = 6.dp, end = 1.dp),
count = badgeCount,
color = CodeTheme.colors.indicator,
enterTransition = scaleIn(
@@ -195,6 +209,9 @@ private fun BottomBarAction(
badge: @Composable () -> Unit = { },
onClick: (() -> Unit)?,
) {
+ val maskPadding = 4.dp
+ var badgeSize by remember { mutableStateOf(IntSize.Zero) }
+
Layout(
modifier = modifier,
content = {
@@ -209,6 +226,21 @@ private fun BottomBarAction(
) {
Image(
modifier = Modifier
+ .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
+ .drawWithContent {
+ drawContent()
+ val bs = badgeSize
+ if (bs.width > 0 && bs.height > 0) {
+ val mp = maskPadding.toPx()
+ val cpTop = contentPadding.calculateTopPadding().toPx()
+ drawCircle(
+ color = Color.Black,
+ radius = bs.height / 2f + mp,
+ center = Offset(size.width, cpTop),
+ blendMode = BlendMode.DstOut,
+ )
+ }
+ }
.padding(contentPadding)
.size(imageSize),
painter = painter,
@@ -222,7 +254,11 @@ private fun BottomBarAction(
)
}
- Box(modifier = Modifier.layoutId("badge")) {
+ Box(
+ modifier = Modifier
+ .layoutId("badge")
+ .onSizeChanged { badgeSize = it }
+ ) {
badge()
}
}
@@ -233,17 +269,48 @@ private fun BottomBarAction(
val badgePlaceable =
measurables.find { it.layoutId == "badge" }?.measure(constraints)
- val maxWidth = widthOrZero(actionPlaceable)
- val maxHeight = heightOrZero(actionPlaceable)
+ val badgeWidth = widthOrZero(badgePlaceable)
+ val badgeHeight = heightOrZero(badgePlaceable)
+
+ val actionWidth = widthOrZero(actionPlaceable)
+ val actionHeight = heightOrZero(actionPlaceable)
+
+ // Position badge so its left circular end is centered on the icon's top-right corner
+ val imageSizePx = imageSize.roundToPx()
+ val iconTop = contentPadding.calculateTopPadding().roundToPx()
+ val iconRight = (actionWidth + imageSizePx) / 2
+ val badgeX = iconRight - badgeHeight / 2
+ val badgeY = iconTop - badgeHeight / 2
+
layout(
- width = maxWidth,
- height = maxHeight,
+ width = actionWidth,
+ height = actionHeight,
) {
actionPlaceable?.placeRelative(0, 0)
- badgePlaceable?.placeRelative(
- x = maxWidth - widthOrZero(badgePlaceable),
- y = -(heightOrZero(badgePlaceable) / 3)
- )
+ badgePlaceable?.placeRelativeWithLayer(x = badgeX, y = badgeY) {
+ clip = false
+ }
}
}
}
+
+
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun NavigationBarPreview() {
+ NavigationBar(
+ state = NavigationBarState(notificationUnreadCount = 100),
+ )
+}
+@Preview
+@PreviewWrapper(FlipcashThemeWrapper::class)
+@Composable
+private fun SendActionPreview() {
+ BottomBarAction(
+ painter = painterResource(R.drawable.ic_send_outlined),
+ label = "Send",
+ badgeCount = 100,
+ onClick = null,
+ )
+}
diff --git a/apps/flipcash/core/src/main/res/drawable/ic_send_outlined.xml b/apps/flipcash/core/src/main/res/drawable/ic_send_outlined.xml
index 89cf680bc..4c67f2ec9 100644
--- a/apps/flipcash/core/src/main/res/drawable/ic_send_outlined.xml
+++ b/apps/flipcash/core/src/main/res/drawable/ic_send_outlined.xml
@@ -1,17 +1,13 @@
+ android:width="40dp"
+ android:height="40dp"
+ android:viewportWidth="40"
+ android:viewportHeight="40">
+ android:pathData="M0,0h40v40h-40z"/>
+ android:pathData="M20,35L18.395,35.45L19.452,39.218L21.436,35.845L20,35ZM36.667,6.667L38.103,7.512L39.581,5H36.667V6.667ZM4.167,6.667V5H0.289L2.957,7.814L4.167,6.667ZM15.363,18.473L16.968,18.023L16.857,17.626L16.573,17.326L15.363,18.473ZM35.808,9.048L37.266,8.24L35.65,5.325L34.192,6.133L35.808,9.048ZM21.436,35.845L38.103,7.512L35.23,5.822L18.563,34.155L21.436,35.845ZM36.667,5H4.167V8.333H36.667V5ZM2.957,7.814L14.154,19.62L16.573,17.326L5.376,5.52L2.957,7.814ZM13.759,18.924L18.395,35.45L21.605,34.55L16.968,18.023L13.759,18.924ZM16.841,19.56L35.808,9.048L34.192,6.133L15.225,16.644L16.841,19.56Z"
+ android:fillColor="#ffffff"/>
diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt
index 6f9157546..6d4a23731 100644
--- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt
+++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt
@@ -110,7 +110,10 @@ internal class SendFlowViewModel @Inject constructor(
combine(
contactCoordinator.state,
- stateFlow.map { it.searchState }.distinctUntilChanged().flatMapLatest { snapshotFlow { it.text } },
+ stateFlow
+ .map { it.searchState }
+ .distinctUntilChanged()
+ .flatMapLatest { snapshotFlow { it.text } },
chatCoordinator.feed,
tokenCoordinator.tokens,
) { contactState, searchText, chatFeed, tokens ->
@@ -261,7 +264,7 @@ internal class SendFlowViewModel @Inject constructor(
val formattedPhone = phone?.let { phoneUtils.formatNumber(it) }
val displayName = otherMember.userProfile.displayName?.takeIf { it.isNotBlank() }
?: formattedPhone
- ?: return@mapNotNull null // filter out anonymous chats
+ ?: return@mapNotNull null
val unknown = DeviceContact.unknownContact(
e164 = phone.orEmpty(),
diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
index 5c4d8c74e..517d45d0e 100644
--- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
+++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt
@@ -104,14 +104,23 @@ class ChatCoordinator @Inject constructor(
val feed: Flow>
get() = _state.map { state ->
- state.feed.map { metadata ->
+ val selfId = userManager.accountId
+ state.feed.mapNotNull { metadata ->
+ // Filter out anonymous chats (DMs where the other member has no name or phone)
+ val otherMember = metadata.members.firstOrNull { it.userId != selfId }
+ if (otherMember != null) {
+ val profile = otherMember.userProfile
+ val hasIdentity = !profile.displayName.isNullOrBlank() ||
+ !profile.verifiedPhoneNumber.isNullOrBlank()
+ if (!hasIdentity) return@mapNotNull null
+ }
+
val readPointer = metadata.members
- .firstOrNull { it.userId == userManager.accountId }
+ .firstOrNull { it.userId == selfId }
?.pointers
?.firstOrNull { it.type == PointerType.READ }
?.value ?: 0L
- val selfId = userManager.accountId
val unreadCount = metadata.lastMessage?.let { lastMsg ->
if (lastMsg.messageId > readPointer && lastMsg.senderId != selfId) 1 else 0
} ?: 0
@@ -189,6 +198,10 @@ class ChatCoordinator @Inject constructor(
return runCatching { ChatId(raw.decodeBase58()) }
}
+ fun observeUnreadConversations(): Flow {
+ return feed.map { summaries -> summaries.count { it.unreadCount > 0 } }
+ }
+
fun observeMessages(chatId: ChatId): Flow> {
return messageDataSource.observeMessages(chatId)
}
diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt
index 3ea767450..ec690e5a1 100644
--- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt
+++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt
@@ -116,7 +116,7 @@ class ChatMessageDataSource @Inject constructor(
&& entity.senderIdHex == selfHex
&& entity.pendingClientIdHex == null
) {
- entity.copy(pendingClientIdHex = rescuedIds.removeFirst())
+ entity.copy(pendingClientIdHex = rescuedIds.removeAt(0))
} else entity
}
} else entities
diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt
index df9f70273..27a2e6076 100644
--- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt
+++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt
@@ -76,6 +76,9 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
@@ -183,13 +186,22 @@ class RealSessionController @Inject constructor(
.launchIn(scope)
userManager.state
- .mapNotNull { it.authState }
+ .map { it.authState }
.filter { it.isAtLeastRegistered }
.distinctUntilChanged()
.filter { userManager.state.value.flags?.requiresIapForRegistration == true }
.onEach { billingClient.connect() }
.launchIn(scope)
+ userManager.state
+ .map { it.authState }
+ .filter { it.isAtLeastRegistered }
+ .distinctUntilChanged()
+ .flatMapLatest { chatCoordinator.observeUnreadConversations() }
+ .distinctUntilChanged()
+ .onEach { count -> _state.update { it.copy(notificationUnreadCount = count) } }
+ .launchIn(scope)
+
appSettingsCoordinator
.observeValue(AppSettingValue.CameraStartByDefault)
.onEach { autoStart -> _state.update { it.copy(autoStartCamera = autoStart) } }
diff --git a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt
index 98507b987..355b75e12 100644
--- a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt
+++ b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt
@@ -33,7 +33,7 @@ object Flipcash2ColorSpec {
val secondary = Color(115, 129, 121)
val secondaryText = Color.White.copy(alpha = 0.5f)
val cashBill = Color(0xFF06450F)
- val notification = Color(0xFF009EE7)
+ val notification = Color(0xFF058AFF)
val trackColor = Color.White.copy(alpha = 0.07f)
val bannerThemed = Color(0xFF252526)
val success = Color(0xFF1AC86A)
diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt
index d105c6e28..5db18d063 100644
--- a/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt
+++ b/ui/components/src/main/kotlin/com/getcode/ui/components/Badge.kt
@@ -25,10 +25,10 @@ import com.getcode.theme.CodeTheme
fun Badge(
count: Int,
modifier: Modifier = Modifier,
- showMoreUnread: Boolean = count > 99,
+ showMoreUnread: Boolean = count > 100,
color: Color = CodeTheme.colors.brand,
contentColor: Color = Color.White,
- textStyle: TextStyle = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.W700),
+ textStyle: TextStyle = CodeTheme.typography.caption.copy(fontWeight = FontWeight.SemiBold),
enterTransition: EnterTransition = scaleIn(tween(durationMillis = 300)) + fadeIn(),
exitTransition: ExitTransition = fadeOut() + scaleOut(tween(durationMillis = 300))
) {
@@ -49,7 +49,7 @@ fun Badge(
contentColor = contentColor,
contentPadding = PaddingValues(
horizontal = CodeTheme.dimens.grid.x1,
- vertical = 0.dp
+ vertical = 3.dp
)
) {
Text(