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:
@@ -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() }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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']);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user