feat: add notification permission banner #11

Merged
reya merged 3 commits from feat/notification-perm into master 2026-06-02 08:54:48 +00:00
2 changed files with 230 additions and 132 deletions
Showing only changes of commit 562a7236bc - Show all commits

View File

@@ -1,6 +1,12 @@
package su.reya.coop.screens package su.reya.coop.screens
import android.Manifest
import android.content.ClipData 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -36,6 +45,7 @@ import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TooltipDefaults
@@ -61,8 +71,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.Res
import coop.composeapp.generated.resources.ic_new_chat import coop.composeapp.generated.resources.ic_new_chat
import coop.composeapp.generated.resources.ic_qr import coop.composeapp.generated.resources.ic_qr
@@ -85,6 +98,7 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen() { fun HomeScreen() {
val context = LocalContext.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
@@ -102,10 +116,26 @@ fun HomeScreen() {
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var isRefreshing 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) { LaunchedEffect(Unit) {
viewModel.getChatRooms() viewModel.getChatRooms()
} }
@@ -187,161 +217,229 @@ fun HomeScreen() {
} }
}, },
content = { innerPadding -> content = { innerPadding ->
Surface( Column(
modifier = Modifier modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
.fillMaxSize() verticalArrangement = Arrangement.spacedBy(16.dp),
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) { ) {
PullToRefreshBox( if (!isNotificationEnabled) {
modifier = Modifier.fillMaxSize(), Surface(
isRefreshing = isRefreshing, modifier = Modifier
state = pullToRefreshState, .fillMaxWidth()
onRefresh = { .padding(horizontal = 16.dp),
scope.launch { shape = RoundedCornerShape(24.dp),
isRefreshing = true color = MaterialTheme.colorScheme.secondaryContainer,
viewModel.refreshChatRooms() ) {
isRefreshing = false Column(
} modifier = Modifier
}, .fillMaxWidth()
indicator = { .padding(16.dp),
PullToRefreshDefaults.LoadingIndicator( verticalArrangement = Arrangement.spacedBy(16.dp),
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
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( Text(
text = "No chats yet", text = "Get message notifications",
style = MaterialTheme.typography.titleLargeEmphasized.copy( style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.SemiBold color = MaterialTheme.colorScheme.onSecondaryFixed,
),
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "Your conversations will appear here.", text = "Make sure you know when you have new messages.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline color = MaterialTheme.colorScheme.onSecondaryContainer,
) )
} }
} Row(
} else { horizontalArrangement = Arrangement.spacedBy(8.dp),
LazyColumn( ) {
state = listState, TextButton(
modifier = Modifier.fillMaxSize() onClick = { },
) { modifier = Modifier.weight(1f),
items(chatRooms.toList(), key = { it.id }) { room -> ) {
ChatRoom( Text(text = "Maybe later")
room = room, }
onClick = { navigator.navigate(Screen.Chat(room.id)) } 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")
}
} }
} }
} }
} }
Surface(
if (showBottomSheet) { modifier = Modifier.fillMaxSize(),
ModalBottomSheet( color = MaterialTheme.colorScheme.surface,
onDismissRequest = { showBottomSheet = false }, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
sheetState = sheetState, ) {
) { PullToRefreshBox(
val pubkey = viewModel.currentUser() modifier = Modifier.fillMaxSize(),
val shortPubkey = pubkey?.short() ?: "Not available" isRefreshing = isRefreshing,
state = pullToRefreshState,
val userName = onRefresh = {
userProfile?.asRecord()?.displayName
?: userProfile?.asRecord()?.name
?: "No name"
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
scope.launch { scope.launch {
sheetState.hide() isRefreshing = true
showBottomSheet = false viewModel.refreshChatRooms()
action() isRefreshing = false
} }
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter),
)
} }
) {
Column( if (!isPartialProcessedGiftWrap) {
modifier = Modifier Box(
.padding(16.dp) modifier = Modifier.fillMaxSize(),
.fillMaxWidth(), contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Box( LoadingIndicator()
modifier = Modifier }
.size(84.dp) } else if (chatRooms.isEmpty()) {
.clip(MaterialShapes.Cookie9Sided.toShape()), Box(
contentAlignment = Alignment.Center modifier = Modifier.fillMaxSize(),
) { contentAlignment = Alignment.Center
Avatar( ) {
picture = userProfile?.asRecord()?.picture, Column(
description = userProfile?.asRecord()?.displayName, horizontalAlignment = Alignment.CenterHorizontally,
shape = MaterialShapes.Cookie9Sided.toShape(), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.size(8.dp))
Box(
contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = userName, text = "No chats yet",
style = MaterialTheme.typography.titleLargeEmphasized, 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( } else {
verticalAlignment = Alignment.CenterVertically, LazyColumn(
) { state = listState,
OutlinedButton( modifier = Modifier.fillMaxSize()
onClick = { ) {
scope.launch { items(chatRooms.toList(), key = { it.id }) { room ->
pubkey?.let { ChatRoom(
val bech32 = it.toBech32() room = room,
val data = ClipData.newPlainText(bech32, bech32) onClick = { navigator.navigate(Screen.Chat(room.id)) }
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) }
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)
}
} }
} }
} }

View File

@@ -1,7 +1,7 @@
[versions] [versions]
agp = "9.2.1" agp = "9.2.1"
android-compileSdk = "37" android-compileSdk = "37"
android-minSdk = "24" android-minSdk = "26"
android-targetSdk = "37" android-targetSdk = "37"
androidx-activity = "1.13.0" androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1" androidx-appcompat = "1.7.1"