diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_notification_settings.xml b/composeApp/src/androidMain/composeResources/drawable/ic_notification_settings.xml
new file mode 100644
index 0000000..c162c0f
--- /dev/null
+++ b/composeApp/src/androidMain/composeResources/drawable/ic_notification_settings.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt
index 66294cb..7dfd11a 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt
@@ -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)
+ }
}
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 177bed6..56eb663 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,7 +1,7 @@
[versions]
agp = "9.2.1"
android-compileSdk = "37"
-android-minSdk = "24"
+android-minSdk = "26"
android-targetSdk = "37"
androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1"
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
index 91d669b..751ab71 100644
--- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
+++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
@@ -40,6 +40,9 @@ class NostrViewModel(
private val nostr: Nostr,
private val secretStore: SecretStorage
) : ViewModel() {
+ private val _isNotificationBannerDismissed = MutableStateFlow(false)
+ val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
+
private val _signerRequired = MutableStateFlow(null)
val signerRequired = _signerRequired.asStateFlow()
@@ -72,6 +75,9 @@ class NostrViewModel(
private val seenPublicKeys = mutableSetOf()
init {
+ // Check if the notification banner has been dismissed
+ checkNotificationBannerDismissedStatus()
+
// Check local stored secret (secret key or bunker)
login()
@@ -104,6 +110,13 @@ class NostrViewModel(
}
}
+ private fun checkNotificationBannerDismissedStatus() {
+ viewModelScope.launch {
+ _isNotificationBannerDismissed.value =
+ secretStore.get("notification_banner_dismissed") == "true"
+ }
+ }
+
private fun runObserver() {
viewModelScope.launch {
// Observe new messages
@@ -290,6 +303,13 @@ class NostrViewModel(
}
}
+ fun dismissNotificationBanner() {
+ viewModelScope.launch {
+ secretStore.set("notification_banner_dismissed", "true")
+ _isNotificationBannerDismissed.value = true
+ }
+ }
+
fun dismissRelayWarning() {
_isRelayListEmpty.value = false
}