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