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 }