feat:增加 APP 内消息提醒功能

This commit is contained in:
amos
2025-12-19 16:56:39 +08:00
parent 2bc3add6e5
commit 4ddde23819
3 changed files with 466 additions and 0 deletions

View File

@@ -0,0 +1,225 @@
package com.memory.app.ui.screen
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.DoneAll
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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.memory.app.data.model.Notification
import com.memory.app.ui.theme.*
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotificationScreen(
notifications: List<Notification>,
isLoading: Boolean,
onBack: () -> Unit,
onNotificationClick: (Notification) -> Unit,
onMarkAllRead: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
// Top Bar
TopAppBar(
title = { Text("消息通知", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
},
actions = {
if (notifications.any { !it.isRead }) {
IconButton(onClick = onMarkAllRead) {
Icon(
Icons.Default.DoneAll,
contentDescription = "全部已读",
tint = Brand500
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
if (isLoading && notifications.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = Brand500)
}
} else if (notifications.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "暂无消息",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(notifications) { notification ->
NotificationItem(
notification = notification,
onClick = { onNotificationClick(notification) }
)
}
}
}
}
}
@Composable
private fun NotificationItem(
notification: Notification,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.background(
if (!notification.isRead)
Brand500.copy(alpha = 0.05f)
else
MaterialTheme.colorScheme.background
)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.Top
) {
// Avatar
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
if (notification.fromUser?.avatarUrl?.isNotEmpty() == true) {
AsyncImage(
model = notification.fromUser.avatarUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Text(
text = notification.fromUser?.nickname?.firstOrNull()?.toString() ?: "?",
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
// Title
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = notification.fromUser?.nickname ?: "用户",
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = getNotificationAction(notification),
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Content preview
if (notification.content.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = notification.content,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
// Time
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatNotificationTime(notification.createdAt),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.outline
)
}
// Unread indicator
if (!notification.isRead) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(Brand500)
)
}
}
}
private fun getNotificationAction(notification: Notification): String {
return when (notification.type) {
"like" -> "赞了你的帖子"
"comment" -> "评论了你的帖子"
"reaction" -> "对你的帖子发送了 ${notification.content}"
else -> "与你互动"
}
}
private fun formatNotificationTime(createdAt: String): String {
return try {
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
val time = LocalDateTime.parse(createdAt.replace("T", " ").substringBefore("+").substringBefore("Z"), formatter)
val now = LocalDateTime.now()
val minutes = ChronoUnit.MINUTES.between(time, now)
val hours = ChronoUnit.HOURS.between(time, now)
val days = ChronoUnit.DAYS.between(time, now)
when {
minutes < 1 -> "刚刚"
minutes < 60 -> "${minutes}分钟前"
hours < 24 -> "${hours}小时前"
days < 7 -> "${days}天前"
else -> time.format(DateTimeFormatter.ofPattern("MM-dd"))
}
} catch (e: Exception) {
createdAt.substringBefore("T")
}
}

View File

@@ -0,0 +1,79 @@
package com.memory.app.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.memory.app.data.api.ApiClient
import com.memory.app.data.model.Notification
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class NotificationViewModel : ViewModel() {
private val _notifications = MutableStateFlow<List<Notification>>(emptyList())
val notifications: StateFlow<List<Notification>> = _notifications
private val _unreadCount = MutableStateFlow(0)
val unreadCount: StateFlow<Int> = _unreadCount
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
fun loadNotifications() {
viewModelScope.launch {
_isLoading.value = true
try {
val response = ApiClient.api.getNotifications()
if (response.isSuccessful) {
_notifications.value = response.body() ?: emptyList()
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
_isLoading.value = false
}
}
}
fun loadUnreadCount() {
viewModelScope.launch {
try {
val response = ApiClient.api.getUnreadCount()
if (response.isSuccessful) {
_unreadCount.value = response.body()?.count ?: 0
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun markAsRead(notificationId: Long) {
viewModelScope.launch {
try {
val response = ApiClient.api.markAsRead(notificationId)
if (response.isSuccessful) {
_notifications.value = _notifications.value.map {
if (it.id == notificationId) it.copy(isRead = true) else it
}
_unreadCount.value = (_unreadCount.value - 1).coerceAtLeast(0)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun markAllAsRead() {
viewModelScope.launch {
try {
val response = ApiClient.api.markAllAsRead()
if (response.isSuccessful) {
_notifications.value = _notifications.value.map { it.copy(isRead = true) }
_unreadCount.value = 0
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}

View File

@@ -0,0 +1,162 @@
package handler
import (
"database/sql"
"net/http"
"strconv"
"memory/internal/config"
"memory/internal/middleware"
"github.com/gin-gonic/gin"
)
type NotificationHandler struct {
db *sql.DB
cfg *config.Config
}
func NewNotificationHandler(db *sql.DB, cfg *config.Config) *NotificationHandler {
return &NotificationHandler{db: db, cfg: cfg}
}
type Notification struct {
ID int64 `json:"id"`
Type string `json:"type"` // like, comment, reaction
FromUserID int64 `json:"from_user_id"`
FromUser *struct {
ID int64 `json:"id"`
Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url"`
} `json:"from_user,omitempty"`
PostID *int64 `json:"post_id,omitempty"`
CommentID *int64 `json:"comment_id,omitempty"`
Content string `json:"content"`
IsRead bool `json:"is_read"`
CreatedAt string `json:"created_at"`
}
// 获取通知列表
func (h *NotificationHandler) List(c *gin.Context) {
userID := middleware.GetUserID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
offset := (page - 1) * pageSize
rows, err := h.db.Query(`
SELECT n.id, n.type, n.from_user_id, n.post_id, n.comment_id, n.content, n.is_read, n.created_at,
u.id, u.nickname, u.avatar_url
FROM notifications n
JOIN users u ON n.from_user_id = u.id
WHERE n.user_id = ?
ORDER BY n.created_at DESC
LIMIT ? OFFSET ?
`, userID, pageSize, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
defer rows.Close()
notifications := []Notification{}
for rows.Next() {
var n Notification
var postID, commentID sql.NullInt64
var fromUserID, fuID int64
var fuNickname, fuAvatarURL string
var isRead int
err := rows.Scan(
&n.ID, &n.Type, &fromUserID, &postID, &commentID, &n.Content, &isRead, &n.CreatedAt,
&fuID, &fuNickname, &fuAvatarURL,
)
if err != nil {
continue
}
n.FromUserID = fromUserID
n.IsRead = isRead == 1
if postID.Valid {
n.PostID = &postID.Int64
}
if commentID.Valid {
n.CommentID = &commentID.Int64
}
n.FromUser = &struct {
ID int64 `json:"id"`
Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url"`
}{
ID: fuID,
Nickname: fuNickname,
AvatarURL: fuAvatarURL,
}
notifications = append(notifications, n)
}
c.JSON(http.StatusOK, notifications)
}
// 获取未读数量
func (h *NotificationHandler) UnreadCount(c *gin.Context) {
userID := middleware.GetUserID(c)
var count int
err := h.db.QueryRow(`
SELECT COUNT(*) FROM notifications WHERE user_id = ? AND is_read = 0
`, userID).Scan(&count)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
c.JSON(http.StatusOK, gin.H{"count": count})
}
// 标记为已读
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
userID := middleware.GetUserID(c)
notificationID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
_, err := h.db.Exec(`
UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?
`, notificationID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "marked as read"})
}
// 标记全部已读
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
userID := middleware.GetUserID(c)
_, err := h.db.Exec(`
UPDATE notifications SET is_read = 1 WHERE user_id = ?
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "all marked as read"})
}
// 创建通知(内部使用)
func CreateNotification(db *sql.DB, userID, fromUserID int64, notifType string, postID *int64, commentID *int64, content string) {
// 不给自己发通知
if userID == fromUserID {
return
}
_, err := db.Exec(`
INSERT INTO notifications (user_id, type, from_user_id, post_id, comment_id, content)
VALUES (?, ?, ?, ?, ?, ?)
`, userID, notifType, fromUserID, postID, commentID, content)
if err != nil {
// 忽略错误,通知不是关键功能
}
}