feat: instant notifications + Notifications screen

- NotificationPoller polls every 30s while app is in foreground
- Poller auto-starts on resume, stops on pause
- New NotificationsScreen with mark-as-read, mark-all-read, infinite scroll
- Bottom nav: Alerts tab with unread badge
- Dashboard: bell icon with unread badge
- Backend: GET /api/notifications/unread-count endpoint
- App version 1.1.0
This commit is contained in:
2026-06-09 17:51:55 -04:00
parent d1c36d4642
commit a43e0ae3a4
12 changed files with 559 additions and 18 deletions

View File

@@ -5,18 +5,14 @@ import android.app.AlertDialog
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.core.app.ActivityCompat
import androidx.lifecycle.lifecycleScope
import com.devlab.app.util.AppUpdater
import com.devlab.app.util.UpdateChecker
import com.devlab.app.worker.NotificationWorker
import kotlinx.coroutines.launch
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
@@ -25,6 +21,11 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
@@ -39,11 +40,16 @@ import com.devlab.app.navigation.Screen
import com.devlab.app.ui.dashboard.DashboardScreen
import com.devlab.app.ui.login.LoginScreen
import com.devlab.app.ui.login.LoginViewModel
import com.devlab.app.ui.notifications.NotificationsScreen
import com.devlab.app.ui.profile.ProfileScreen
import com.devlab.app.ui.register.RegisterScreen
import com.devlab.app.ui.servers.ServerDetailScreen
import com.devlab.app.ui.servers.ServerListScreen
import com.devlab.app.ui.theme.ServerManagerTheme
import com.devlab.app.util.AppUpdater
import com.devlab.app.util.UpdateChecker
import com.devlab.app.worker.NotificationWorker
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -53,6 +59,20 @@ class MainActivity : ComponentActivity() {
requestNotificationPermission()
checkForUpdates()
NotificationWorker.checkNow(this)
lifecycle.addObserver(LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
ServerManagerApp.instance.notificationPoller?.start()
ServerManagerApp.instance.notificationPoller?.checkNow()
}
Lifecycle.Event.ON_PAUSE -> {
ServerManagerApp.instance.notificationPoller?.stop()
}
else -> {}
}
})
setContent {
ServerManagerTheme {
AppRoot()
@@ -111,6 +131,7 @@ fun AppRoot() {
val navController = rememberNavController()
val loginViewModel: LoginViewModel = viewModel()
var isLoggedIn by remember { mutableStateOf(false) }
var unreadCount by remember { mutableIntStateOf(0) }
LaunchedEffect(isLoggedIn) {
if (isLoggedIn) {
@@ -124,7 +145,8 @@ fun AppRoot() {
val inLoggedInArea = currentDestination?.route in listOf(
Screen.Dashboard.route,
Screen.ServerList.route,
Screen.Profile.route
Screen.Profile.route,
Screen.Notifications.route
)
LaunchedEffect(Unit) {
@@ -141,19 +163,21 @@ fun AppRoot() {
}
}
DisposableEffect(Unit) {
ServerManagerApp.instance.notificationPoller?.onCountChanged = { count ->
unreadCount = count
}
onDispose {
ServerManagerApp.instance.notificationPoller?.onCountChanged = null
}
}
Scaffold(
bottomBar = {
if (inLoggedInArea) {
NavigationBar {
NavigationBarItem(
icon = {
Icon(
Icons.Default.Dashboard,
contentDescription = null,
modifier = if (currentDestination?.hierarchy?.any { it.route == Screen.Dashboard.route } == true)
Modifier.padding(0.dp) else Modifier
)
},
icon = { Icon(Icons.Default.Dashboard, contentDescription = null) },
label = { Text("Dashboard") },
selected = currentDestination?.hierarchy?.any { it.route == Screen.Dashboard.route } == true,
onClick = {
@@ -176,6 +200,26 @@ fun AppRoot() {
}
}
)
NavigationBarItem(
icon = {
BadgedBox(badge = {
if (unreadCount > 0) {
Badge { Text("$unreadCount") }
}
}) {
Icon(Icons.Default.Notifications, contentDescription = null)
}
},
label = { Text("Alerts") },
selected = currentDestination?.hierarchy?.any { it.route == Screen.Notifications.route } == true,
onClick = {
navController.navigate(Screen.Notifications.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
)
NavigationBarItem(
icon = { Icon(Icons.Default.Person, contentDescription = null) },
label = { Text("Profile") },
@@ -255,7 +299,11 @@ fun AppRoot() {
},
onProfileClick = {
navController.navigate(Screen.Profile.route)
}
},
onNotificationsClick = {
navController.navigate(Screen.Notifications.route)
},
unreadCount = unreadCount
)
}
@@ -267,6 +315,12 @@ fun AppRoot() {
)
}
composable(Screen.Notifications.route) {
NotificationsScreen(
onBack = { navController.popBackStack() }
)
}
composable(Screen.Profile.route) {
ProfileScreen(
onBack = { navController.popBackStack() }

View File

@@ -2,14 +2,19 @@ package com.devlab.app
import android.app.Application
import com.devlab.app.util.NotificationHelper
import com.devlab.app.util.NotificationPoller
import com.devlab.app.worker.NotificationWorker
class ServerManagerApp : Application() {
var notificationPoller: NotificationPoller? = null
private set
override fun onCreate() {
super.onCreate()
instance = this
NotificationHelper.createChannels(this)
NotificationWorker.schedule(this)
notificationPoller = NotificationPoller(this)
}
companion object {

View File

@@ -61,4 +61,7 @@ interface ApiService {
@POST("api/notifications/read-all")
suspend fun markAllNotificationsRead(): ApiResponse<Map<String, Boolean>>
@GET("api/notifications/unread-count")
suspend fun getUnreadCount(): UnreadCountResponse
}

View File

@@ -34,3 +34,10 @@ data class NotificationListResponse(
val pagination: Pagination? = null,
val unread_count: Int = 0
)
@Serializable
data class UnreadCountResponse(
val success: Boolean = true,
val unread_count: Int = 0,
val error: String? = null
)

View File

@@ -6,6 +6,7 @@ sealed class Screen(val route: String) {
data object Dashboard : Screen("dashboard")
data object ServerList : Screen("servers")
data object Profile : Screen("profile")
data object Notifications : Screen("notifications")
data object ServerDetail : Screen("servers/{serverId}") {
fun createRoute(serverId: Int) = "servers/$serverId"
}

View File

@@ -23,6 +23,8 @@ import com.devlab.app.ui.theme.*
fun DashboardScreen(
onLogout: () -> Unit,
onProfileClick: () -> Unit = {},
onNotificationsClick: () -> Unit = {},
unreadCount: Int = 0,
viewModel: DashboardViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
@@ -54,6 +56,22 @@ fun DashboardScreen(
tint = MaterialTheme.colorScheme.onPrimary
)
}
IconButton(onClick = { onNotificationsClick() }) {
BadgedBox(badge = {
if (unreadCount > 0) {
Badge(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
) { Text("$unreadCount") }
}
}) {
Icon(
Icons.Default.Notifications,
contentDescription = "Notifications",
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
IconButton(onClick = { viewModel.loadStats() }) {
Icon(
Icons.Default.Refresh,

View File

@@ -0,0 +1,257 @@
package com.devlab.app.ui.notifications
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.devlab.app.data.model.AppNotification
import com.devlab.app.ui.theme.*
import java.text.SimpleDateFormat
import java.util.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotificationsScreen(
onBack: () -> Unit,
viewModel: NotificationsViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
LaunchedEffect(listState.canScrollForward) {
if (!listState.canScrollForward && !state.isLoadingMore && state.hasMore && state.notifications.isNotEmpty()) {
viewModel.loadMore()
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Notifications")
if (state.unreadCount > 0) {
Spacer(modifier = Modifier.width(8.dp))
Badge(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
) {
Text("${state.unreadCount}")
}
}
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
if (state.unreadCount > 0) {
TextButton(onClick = { viewModel.markAllAsRead() }) {
Text("Mark all read", color = MaterialTheme.colorScheme.onPrimary)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
}
) { padding ->
when {
state.isLoading -> {
Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
state.error != null -> {
Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.ErrorOutline, contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Text(text = state.error ?: "", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(onClick = { viewModel.load() }) { Text("Retry") }
}
}
}
state.notifications.isEmpty() -> {
Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.NotificationsNone, contentDescription = null, modifier = Modifier.size(64.dp), tint = Grey500)
Spacer(modifier = Modifier.height(16.dp))
Text("No notifications", style = MaterialTheme.typography.bodyLarge, color = Grey500)
}
}
}
else -> {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(state.notifications, key = { it.id }) { notification ->
NotificationItem(
notification = notification,
onMarkRead = { viewModel.markAsRead(notification.id) }
)
}
if (state.isLoadingMore) {
item {
Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
}
}
}
}
}
@Composable
private fun NotificationItem(
notification: AppNotification,
onMarkRead: () -> Unit
) {
val bgColor = if (notification.is_read == 0)
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
else Color.Transparent
val typeIcon = when (notification.type) {
"success" -> Icons.Default.CheckCircle
"error" -> Icons.Default.Cancel
"warning" -> Icons.Default.Warning
else -> Icons.Default.Info
}
val typeColor = when (notification.type) {
"success" -> Green500
"error" -> Red500
"warning" -> Orange500
else -> Blue700
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = if (notification.is_read == 0) 2.dp else 0.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.Top
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(typeColor.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = typeIcon,
contentDescription = null,
tint = typeColor,
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = notification.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = if (notification.is_read == 0) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier.weight(1f)
)
if (notification.is_read == 0) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = notification.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = formatTime(notification.created_at),
style = MaterialTheme.typography.labelSmall,
color = Grey500
)
Spacer(modifier = Modifier.weight(1f))
if (notification.is_read == 0) {
TextButton(
onClick = onMarkRead,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
) {
Icon(Icons.Default.Done, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Mark read", style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
}
}
private fun formatTime(timestamp: String?): String {
if (timestamp == null) return ""
return try {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
val date = sdf.parse(timestamp) ?: return timestamp
val now = Calendar.getInstance()
val then = Calendar.getInstance().apply {
this.time = date
}
val diff = now.timeInMillis - then.timeInMillis
when {
diff < 60_000 -> "Just now"
diff < 3600_000 -> "${diff / 60_000}m ago"
diff < 86400_000 -> "${diff / 3600_000}h ago"
else -> {
val out = SimpleDateFormat("MMM d, HH:mm", Locale.US)
out.format(date)
}
}
} catch (_: Exception) { timestamp }
}

View File

@@ -0,0 +1,98 @@
package com.devlab.app.ui.notifications
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.devlab.app.ServerManagerApp
import com.devlab.app.data.api.RetrofitClient
import com.devlab.app.data.model.AppNotification
import com.devlab.app.util.PreferencesManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class NotificationsUiState(
val isLoading: Boolean = true,
val notifications: List<AppNotification> = emptyList(),
val unreadCount: Int = 0,
val error: String? = null,
val currentPage: Int = 1,
val hasMore: Boolean = true,
val isLoadingMore: Boolean = false
)
class NotificationsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(NotificationsUiState())
val uiState: StateFlow<NotificationsUiState> = _uiState.asStateFlow()
init { load() }
fun load() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null, currentPage = 1)
fetchPage(1)
}
}
fun loadMore() {
val state = _uiState.value
if (state.isLoadingMore || !state.hasMore) return
viewModelScope.launch {
_uiState.value = state.copy(isLoadingMore = true)
fetchPage(state.currentPage + 1, append = true)
}
}
fun markAsRead(id: Int) {
viewModelScope.launch {
try {
RetrofitClient.getApiService().markNotificationRead(id)
val updated = _uiState.value.notifications.map {
if (it.id == id) it.copy(is_read = 1) else it
}
val newUnread = (_uiState.value.unreadCount - 1).coerceAtLeast(0)
_uiState.value = _uiState.value.copy(notifications = updated, unreadCount = newUnread)
} catch (_: Exception) { }
}
}
fun markAllAsRead() {
viewModelScope.launch {
try {
RetrofitClient.getApiService().markAllNotificationsRead()
val updated = _uiState.value.notifications.map { it.copy(is_read = 1) }
_uiState.value = _uiState.value.copy(notifications = updated, unreadCount = 0)
} catch (_: Exception) { }
}
}
private suspend fun fetchPage(page: Int, append: Boolean = false) {
try {
val response = RetrofitClient.getApiService().getNotifications(page = page)
if (response.success) {
val list = if (append) _uiState.value.notifications + response.data else response.data
val pagination = response.pagination
val hasMore = pagination != null && page < pagination.total_pages
_uiState.value = _uiState.value.copy(
isLoading = false,
isLoadingMore = false,
notifications = list,
currentPage = page,
hasMore = hasMore,
unreadCount = response.unread_count
)
} else {
_uiState.value = _uiState.value.copy(
isLoading = false, isLoadingMore = false,
error = response.error ?: "Failed to load"
)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false, isLoadingMore = false,
error = e.message ?: "Network error"
)
}
}
}

View File

@@ -0,0 +1,84 @@
package com.devlab.app.util
import android.content.Context
import android.util.Log
import com.devlab.app.data.api.RetrofitClient
import com.devlab.app.data.model.AppNotification
import kotlinx.coroutines.*
class NotificationPoller(private val context: Context) {
private var job: Job? = null
private var lastCheck: Long = 0
private var _unreadCount: Int = 0
val unreadCount: Int get() = _unreadCount
var onCountChanged: ((Int) -> Unit)? = null
companion object {
private const val POLL_INTERVAL_MS = 30_000L
}
fun start() {
if (job?.isActive == true) return
lastCheck = System.currentTimeMillis()
job = CoroutineScope(Dispatchers.IO).launch {
while (isActive) {
try {
checkNow()
} catch (_: Exception) { }
delay(POLL_INTERVAL_MS)
}
}
Log.d("NotificationPoller", "Started polling every 30s")
}
fun stop() {
job?.cancel()
job = null
Log.d("NotificationPoller", "Stopped")
}
fun checkNow() {
val scope = CoroutineScope(Dispatchers.IO)
scope.launch {
try {
val prefs = PreferencesManager(context)
val token = prefs.getToken()
if (token.isBlank()) return@launch
RetrofitClient.setToken(token)
val service = RetrofitClient.getApiService()
val response = service.getNotifications(page = 1, perPage = 5)
if (response.success) {
_unreadCount = response.unread_count
onCountChanged?.invoke(_unreadCount)
val newNotes = response.data.filter { note ->
val noteTime = parseTime(note.created_at)
noteTime > lastCheck && note.is_read == 0
}
for (notification in newNotes) {
NotificationHelper.showNotification(context, notification)
}
if (newNotes.isNotEmpty()) {
lastCheck = newNotes.maxOf { parseTime(it.created_at) }
}
}
} catch (_: Exception) { }
}
}
private fun parseTime(time: String?): Long {
if (time == null) return 0L
return try {
val sdf = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
sdf.parse(time)?.time ?: 0L
} catch (_: Exception) { 0L }
}
}

Binary file not shown.

View File

@@ -109,6 +109,7 @@ $router->post('/api/auth/login', [ApiController::class, 'login']);
$router->post('/api/auth/register', [ApiController::class, 'register']);
$router->get('/api/app-version', [ApiController::class, 'appVersion']);
$router->get('/api/notifications', [ApiController::class, 'notifications']);
$router->get('/api/notifications/unread-count', [ApiController::class, 'unreadCount']);
$router->post('/api/notifications/:id/read', [ApiController::class, 'markNotificationRead']);
$router->post('/api/notifications/read-all', [ApiController::class, 'markAllNotificationsRead']);
$router->get('/api/users', [ApiController::class, 'users']);

View File

@@ -496,6 +496,19 @@ class ApiController
]);
}
public function unreadCount(): void
{
$user = $this->authenticateRequest();
$notificationModel = new \ServerManager\Models\Notification();
$count = $notificationModel->getUnreadCount((int) $user['id']);
$this->view->json([
'success' => true,
'unread_count' => $count,
]);
}
public function notifications(): void
{
$user = $this->authenticateRequest();