diff --git a/android/app/src/main/java/com/devlab/app/MainActivity.kt b/android/app/src/main/java/com/devlab/app/MainActivity.kt index 3aad79e..86e0e72 100644 --- a/android/app/src/main/java/com/devlab/app/MainActivity.kt +++ b/android/app/src/main/java/com/devlab/app/MainActivity.kt @@ -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() } diff --git a/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt b/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt index 8809692..7ff58b8 100644 --- a/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt +++ b/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt @@ -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 { diff --git a/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt b/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt index 7703f85..353f25e 100644 --- a/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt +++ b/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt @@ -61,4 +61,7 @@ interface ApiService { @POST("api/notifications/read-all") suspend fun markAllNotificationsRead(): ApiResponse> + + @GET("api/notifications/unread-count") + suspend fun getUnreadCount(): UnreadCountResponse } diff --git a/android/app/src/main/java/com/devlab/app/data/model/ApiResponse.kt b/android/app/src/main/java/com/devlab/app/data/model/ApiResponse.kt index 10ca05e..869c06f 100644 --- a/android/app/src/main/java/com/devlab/app/data/model/ApiResponse.kt +++ b/android/app/src/main/java/com/devlab/app/data/model/ApiResponse.kt @@ -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 +) diff --git a/android/app/src/main/java/com/devlab/app/navigation/NavGraph.kt b/android/app/src/main/java/com/devlab/app/navigation/NavGraph.kt index 1c45e53..4b7a93c 100644 --- a/android/app/src/main/java/com/devlab/app/navigation/NavGraph.kt +++ b/android/app/src/main/java/com/devlab/app/navigation/NavGraph.kt @@ -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" } diff --git a/android/app/src/main/java/com/devlab/app/ui/dashboard/DashboardScreen.kt b/android/app/src/main/java/com/devlab/app/ui/dashboard/DashboardScreen.kt index 784f707..fb30f0d 100644 --- a/android/app/src/main/java/com/devlab/app/ui/dashboard/DashboardScreen.kt +++ b/android/app/src/main/java/com/devlab/app/ui/dashboard/DashboardScreen.kt @@ -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, diff --git a/android/app/src/main/java/com/devlab/app/ui/notifications/NotificationsScreen.kt b/android/app/src/main/java/com/devlab/app/ui/notifications/NotificationsScreen.kt new file mode 100644 index 0000000..036014c --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/notifications/NotificationsScreen.kt @@ -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 } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/notifications/NotificationsViewModel.kt b/android/app/src/main/java/com/devlab/app/ui/notifications/NotificationsViewModel.kt new file mode 100644 index 0000000..b443436 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/ui/notifications/NotificationsViewModel.kt @@ -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 = 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 = _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" + ) + } + } +} diff --git a/android/app/src/main/java/com/devlab/app/util/NotificationPoller.kt b/android/app/src/main/java/com/devlab/app/util/NotificationPoller.kt new file mode 100644 index 0000000..d76bf0b --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/util/NotificationPoller.kt @@ -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 } + } +} diff --git a/public/sysadmin.apk b/public/sysadmin.apk index fe73c90..7193d90 100644 Binary files a/public/sysadmin.apk and b/public/sysadmin.apk differ diff --git a/routes/web.php b/routes/web.php index 9802297..2bb53a2 100755 --- a/routes/web.php +++ b/routes/web.php @@ -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']); diff --git a/src/Controllers/ApiController.php b/src/Controllers/ApiController.php index 7797e32..850e07b 100755 --- a/src/Controllers/ApiController.php +++ b/src/Controllers/ApiController.php @@ -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();