feat: add notification permission banner (#11)

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-06-02 08:54:47 +00:00
parent ff383a7c6a
commit 71a8240b1d
4 changed files with 260 additions and 132 deletions

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M480,471L480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471L480,471ZM480,880Q447,880 423.5,856.5Q400,833 400,800L560,800Q560,833 536.5,856.5Q513,880 480,880ZM160,760L160,680L240,680L240,400Q240,316 290.5,251Q341,186 422,167Q412,189 406.5,213Q401,237 399,262Q364,283 342,319Q320,355 320,400L320,680L640,680L640,558Q660,561 680,561Q700,561 720,558L720,680L800,680L800,760L160,760ZM640,480L628,420Q616,415 605.5,409.5Q595,404 584,396L526,414L486,346L532,306Q530,293 530,280Q530,267 532,254L486,214L526,146L584,164Q595,156 605.5,150.5Q616,145 628,140L640,80L720,80L732,140Q744,145 754.5,150.5Q765,156 776,164L834,146L874,214L828,254Q830,267 830,280Q830,293 828,306L874,346L834,414L776,396Q765,404 754.5,409.5Q744,415 732,420L720,480L640,480ZM736.5,336.5Q760,313 760,280Q760,247 736.5,223.5Q713,200 680,200Q647,200 623.5,223.5Q600,247 600,280Q600,313 623.5,336.5Q647,360 680,360Q713,360 736.5,336.5Z" />
</vector>

View File

@@ -1,6 +1,12 @@
package su.reya.coop.screens
import android.Manifest
import android.content.ClipData
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -9,12 +15,15 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -36,6 +45,7 @@ import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
@@ -61,8 +71,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.compose.LifecycleResumeEffect
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_new_chat
import coop.composeapp.generated.resources.ic_qr
@@ -85,6 +98,7 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen() {
val context = LocalContext.current
val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val snackbarHostState = LocalSnackbarHostState.current
@@ -97,15 +111,32 @@ fun HomeScreen() {
val userProfile by currentUserProfile.collectAsState(initial = null)
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState()
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
var isNotificationEnabled by remember {
mutableStateOf(NotificationManagerCompat.from(context).areNotificationsEnabled())
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { _ ->
// State will be updated by LifecycleResumeEffect
}
LifecycleResumeEffect(context) {
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
onPauseOrDispose { }
}
LaunchedEffect(Unit) {
viewModel.getChatRooms()
}
@@ -187,161 +218,229 @@ fun HomeScreen() {
}
},
content = { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
Column(
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
PullToRefreshBox(
modifier = Modifier.fillMaxSize(),
isRefreshing = isRefreshing,
state = pullToRefreshState,
onRefresh = {
scope.launch {
isRefreshing = true
viewModel.refreshChatRooms()
isRefreshing = false
}
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter),
)
}
) {
if (!isPartialProcessedGiftWrap) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
LoadingIndicator()
}
} else if (chatRooms.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
if (!isNotificationEnabled && !isBannerDismissed) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "No chats yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
text = "Get message notifications",
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSecondaryFixed,
)
Text(
text = "Your conversations will appear here.",
text = "Make sure you know when you have new messages.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(chatRooms.toList(), key = { it.id }) { room ->
ChatRoom(
room = room,
onClick = { navigator.navigate(Screen.Chat(room.id)) }
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(
onClick = { viewModel.dismissNotificationBanner() },
modifier = Modifier.weight(1f),
) {
Text(text = "Maybe later")
}
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
// For older versions, navigate the user directly to App Notification Settings
val intent =
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(
Settings.EXTRA_APP_PACKAGE,
context.packageName
)
}
context.startActivity(intent)
}
},
modifier = Modifier.weight(1f),
) {
Text(text = "Turn on")
}
}
}
}
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
) {
val pubkey = viewModel.currentUser()
val shortPubkey = pubkey?.short() ?: "Not available"
val userName =
userProfile?.asRecord()?.displayName
?: userProfile?.asRecord()?.name
?: "No name"
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
PullToRefreshBox(
modifier = Modifier.fillMaxSize(),
isRefreshing = isRefreshing,
state = pullToRefreshState,
onRefresh = {
scope.launch {
sheetState.hide()
showBottomSheet = false
action()
isRefreshing = true
viewModel.refreshChatRooms()
isRefreshing = false
}
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter),
)
}
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (!isPartialProcessedGiftWrap) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(84.dp)
.clip(MaterialShapes.Cookie9Sided.toShape()),
contentAlignment = Alignment.Center
) {
Avatar(
picture = userProfile?.asRecord()?.picture,
description = userProfile?.asRecord()?.displayName,
shape = MaterialShapes.Cookie9Sided.toShape(),
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.size(8.dp))
Box(
contentAlignment = Alignment.Center
LoadingIndicator()
}
} else if (chatRooms.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = userName,
style = MaterialTheme.typography.titleLargeEmphasized,
text = "No chats yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Your conversations will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
Spacer(modifier = Modifier.size(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = {
scope.launch {
pubkey?.let {
val bech32 = it.toBech32()
val data = ClipData.newPlainText(bech32, bech32)
clipboardManager.setClipEntry(ClipEntry(data))
}
}
},
) {
Text(text = shortPubkey)
}
FilledIconButton(
onClick = {
dismissAndRun { navigator.navigate(Screen.MyQr) }
},
shape = MaterialShapes.Square.toShape()
) {
Icon(
painter = painterResource(Res.drawable.ic_qr),
contentDescription = "My QR"
)
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(chatRooms.toList(), key = { it.id }) { room ->
ChatRoom(
room = room,
onClick = { navigator.navigate(Screen.Chat(room.id)) }
)
}
}
Spacer(modifier = Modifier.size(16.dp))
BottomMenuList(onDismiss = dismissAndRun)
}
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
modifier = Modifier
.imePadding()
.navigationBarsPadding(),
) {
val pubkey = viewModel.currentUser()
val shortPubkey = pubkey?.short() ?: "Not available"
val userName =
userProfile?.asRecord()?.displayName
?: userProfile?.asRecord()?.name
?: "No name"
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
scope.launch {
sheetState.hide()
showBottomSheet = false
action()
}
}
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(84.dp)
.clip(MaterialShapes.Cookie9Sided.toShape()),
contentAlignment = Alignment.Center
) {
Avatar(
picture = userProfile?.asRecord()?.picture,
description = userProfile?.asRecord()?.displayName,
shape = MaterialShapes.Cookie9Sided.toShape(),
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.size(8.dp))
Box(
contentAlignment = Alignment.Center
) {
Text(
text = userName,
style = MaterialTheme.typography.titleLargeEmphasized,
)
}
Spacer(modifier = Modifier.size(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = {
scope.launch {
pubkey?.let {
val bech32 = it.toBech32()
val data =
ClipData.newPlainText(bech32, bech32)
clipboardManager.setClipEntry(ClipEntry(data))
}
}
},
) {
Text(text = shortPubkey)
}
FilledIconButton(
onClick = {
dismissAndRun { navigator.navigate(Screen.MyQr) }
},
shape = MaterialShapes.Square.toShape()
) {
Icon(
painter = painterResource(Res.drawable.ic_qr),
contentDescription = "My QR"
)
}
}
}
Spacer(modifier = Modifier.size(16.dp))
BottomMenuList(onDismiss = dismissAndRun)
}
}
}
}