Merge pull request 'Notificaciones instantáneas + pantalla de notificaciones + fixes' (#25) from feat/android-app-redesign into master

Reviewed-on: #25
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
This commit was merged in pull request #25.
This commit is contained in:
2026-06-09 17:55:24 -04:00
13 changed files with 561 additions and 20 deletions

View File

@@ -12,8 +12,8 @@ android {
applicationId = "com.devlab.app" applicationId = "com.devlab.app"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = 2 versionCode = 3
versionName = "1.1.0" versionName = "1.2.0"
} }
buildTypes { buildTypes {

View File

@@ -5,18 +5,14 @@ import android.app.AlertDialog
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle 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.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.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.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -25,6 +21,11 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp 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.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination 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.dashboard.DashboardScreen
import com.devlab.app.ui.login.LoginScreen import com.devlab.app.ui.login.LoginScreen
import com.devlab.app.ui.login.LoginViewModel 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.profile.ProfileScreen
import com.devlab.app.ui.register.RegisterScreen import com.devlab.app.ui.register.RegisterScreen
import com.devlab.app.ui.servers.ServerDetailScreen import com.devlab.app.ui.servers.ServerDetailScreen
import com.devlab.app.ui.servers.ServerListScreen import com.devlab.app.ui.servers.ServerListScreen
import com.devlab.app.ui.theme.ServerManagerTheme 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() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -53,6 +59,20 @@ class MainActivity : ComponentActivity() {
requestNotificationPermission() requestNotificationPermission()
checkForUpdates() checkForUpdates()
NotificationWorker.checkNow(this) 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 { setContent {
ServerManagerTheme { ServerManagerTheme {
AppRoot() AppRoot()
@@ -111,6 +131,7 @@ fun AppRoot() {
val navController = rememberNavController() val navController = rememberNavController()
val loginViewModel: LoginViewModel = viewModel() val loginViewModel: LoginViewModel = viewModel()
var isLoggedIn by remember { mutableStateOf(false) } var isLoggedIn by remember { mutableStateOf(false) }
var unreadCount by remember { mutableIntStateOf(0) }
LaunchedEffect(isLoggedIn) { LaunchedEffect(isLoggedIn) {
if (isLoggedIn) { if (isLoggedIn) {
@@ -124,7 +145,8 @@ fun AppRoot() {
val inLoggedInArea = currentDestination?.route in listOf( val inLoggedInArea = currentDestination?.route in listOf(
Screen.Dashboard.route, Screen.Dashboard.route,
Screen.ServerList.route, Screen.ServerList.route,
Screen.Profile.route Screen.Profile.route,
Screen.Notifications.route
) )
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -141,19 +163,21 @@ fun AppRoot() {
} }
} }
DisposableEffect(Unit) {
ServerManagerApp.instance.notificationPoller?.onCountChanged = { count ->
unreadCount = count
}
onDispose {
ServerManagerApp.instance.notificationPoller?.onCountChanged = null
}
}
Scaffold( Scaffold(
bottomBar = { bottomBar = {
if (inLoggedInArea) { if (inLoggedInArea) {
NavigationBar { NavigationBar {
NavigationBarItem( NavigationBarItem(
icon = { icon = { Icon(Icons.Default.Dashboard, contentDescription = null) },
Icon(
Icons.Default.Dashboard,
contentDescription = null,
modifier = if (currentDestination?.hierarchy?.any { it.route == Screen.Dashboard.route } == true)
Modifier.padding(0.dp) else Modifier
)
},
label = { Text("Dashboard") }, label = { Text("Dashboard") },
selected = currentDestination?.hierarchy?.any { it.route == Screen.Dashboard.route } == true, selected = currentDestination?.hierarchy?.any { it.route == Screen.Dashboard.route } == true,
onClick = { 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( NavigationBarItem(
icon = { Icon(Icons.Default.Person, contentDescription = null) }, icon = { Icon(Icons.Default.Person, contentDescription = null) },
label = { Text("Profile") }, label = { Text("Profile") },
@@ -255,7 +299,11 @@ fun AppRoot() {
}, },
onProfileClick = { onProfileClick = {
navController.navigate(Screen.Profile.route) 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) { composable(Screen.Profile.route) {
ProfileScreen( ProfileScreen(
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() }

View File

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

View File

@@ -61,4 +61,7 @@ interface ApiService {
@POST("api/notifications/read-all") @POST("api/notifications/read-all")
suspend fun markAllNotificationsRead(): ApiResponse<Map<String, Boolean>> 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 pagination: Pagination? = null,
val unread_count: Int = 0 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 Dashboard : Screen("dashboard")
data object ServerList : Screen("servers") data object ServerList : Screen("servers")
data object Profile : Screen("profile") data object Profile : Screen("profile")
data object Notifications : Screen("notifications")
data object ServerDetail : Screen("servers/{serverId}") { data object ServerDetail : Screen("servers/{serverId}") {
fun createRoute(serverId: Int) = "servers/$serverId" fun createRoute(serverId: Int) = "servers/$serverId"
} }

View File

@@ -23,6 +23,8 @@ import com.devlab.app.ui.theme.*
fun DashboardScreen( fun DashboardScreen(
onLogout: () -> Unit, onLogout: () -> Unit,
onProfileClick: () -> Unit = {}, onProfileClick: () -> Unit = {},
onNotificationsClick: () -> Unit = {},
unreadCount: Int = 0,
viewModel: DashboardViewModel = viewModel() viewModel: DashboardViewModel = viewModel()
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
@@ -54,6 +56,22 @@ fun DashboardScreen(
tint = MaterialTheme.colorScheme.onPrimary 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() }) { IconButton(onClick = { viewModel.loadStats() }) {
Icon( Icon(
Icons.Default.Refresh, 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->post('/api/auth/register', [ApiController::class, 'register']);
$router->get('/api/app-version', [ApiController::class, 'appVersion']); $router->get('/api/app-version', [ApiController::class, 'appVersion']);
$router->get('/api/notifications', [ApiController::class, 'notifications']); $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/:id/read', [ApiController::class, 'markNotificationRead']);
$router->post('/api/notifications/read-all', [ApiController::class, 'markAllNotificationsRead']); $router->post('/api/notifications/read-all', [ApiController::class, 'markAllNotificationsRead']);
$router->get('/api/users', [ApiController::class, 'users']); $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 public function notifications(): void
{ {
$user = $this->authenticateRequest(); $user = $this->authenticateRequest();