feat(ios): IOS-M1-03 消息页面增强
- NotificationListView 改为按时间段分组:今天/本周/更早 - 新增 6 种消息类型图标(导入完成/失败/测验/AI/订阅/系统) - 右上角增加「全部已读」按钮 - 未读蓝点标识 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
657e9cf2b4
commit
2fcf68a64b
@ -5,59 +5,92 @@ struct NotificationListView: View {
|
|||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var isRefreshing = false
|
@State private var isRefreshing = false
|
||||||
|
|
||||||
|
private var todayItems: [NotificationItem] {
|
||||||
|
notifications.filter { isToday($0.createdAt) }
|
||||||
|
}
|
||||||
|
private var weekItems: [NotificationItem] {
|
||||||
|
notifications.filter { !isToday($0.createdAt) && isThisWeek($0.createdAt) }
|
||||||
|
}
|
||||||
|
private var olderItems: [NotificationItem] {
|
||||||
|
notifications.filter { !isThisWeek($0.createdAt) }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.zxBg0.ignoresSafeArea()
|
Color.zxBg0.ignoresSafeArea()
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if isLoading && notifications.isEmpty {
|
if isLoading && notifications.isEmpty {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04) }.padding(.top, 120)
|
||||||
ZXLoadingView(size: 36, lineWidth: 3)
|
|
||||||
Text("加载中…").font(.system(size: 13)).foregroundColor(Color.zxF04)
|
|
||||||
}.padding(.top, 120)
|
|
||||||
} else if notifications.isEmpty {
|
} else if notifications.isEmpty {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) { Image(systemName: "bell.slash").font(.system(size: 40)).foregroundColor(Color.zxF03); Text("暂无通知").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF03) }.padding(.top, 120)
|
||||||
Image(systemName: "bell.slash").font(.system(size: 40)).foregroundColor(Color.zxF03)
|
|
||||||
Text("暂无通知").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF03)
|
|
||||||
}.padding(.top, 120)
|
|
||||||
} else {
|
} else {
|
||||||
ForEach(Array(notifications.enumerated()), id: \.offset) { i, n in
|
if !todayItems.isEmpty { sectionView("今天", items: todayItems) }
|
||||||
ZXNotificationItemRow(item: n) {
|
if !weekItems.isEmpty { sectionView("本周", items: weekItems) }
|
||||||
Task { _ = try? await NotificationService.shared.markRead(id: n.id) }
|
if !olderItems.isEmpty { sectionView("更早", items: olderItems) }
|
||||||
}
|
}
|
||||||
if i < notifications.count - 1 {
|
}.padding(.bottom, 100)
|
||||||
ZXSettingDivider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
|
|
||||||
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
|
|
||||||
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
|
|
||||||
}
|
}
|
||||||
.scrollIndicators(.hidden)
|
.scrollIndicators(.hidden)
|
||||||
.zxPullToRefresh { await refresh() }
|
.zxPullToRefresh { await refresh() }
|
||||||
}
|
}
|
||||||
|
.navigationTitle("消息中心")
|
||||||
.navigationBarTitleDisplayMode(.inline).animatedTabBarHide()
|
.navigationBarTitleDisplayMode(.inline).animatedTabBarHide()
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
let _ = try? await NotificationService.shared.markAllRead()
|
||||||
|
await loadNotifications()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("全部已读").font(.system(size: 13))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.task { await loadNotifications() }
|
.task { await loadNotifications() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sectionView(_ title: String, items: [NotificationItem]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(title).font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).padding(.horizontal, 20).padding(.top, 16).padding(.bottom, 8)
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(Array(items.enumerated()), id: \.offset) { i, n in
|
||||||
|
ZXNotificationItemRow(item: n) {
|
||||||
|
Task { _ = try? await NotificationService.shared.markRead(id: n.id) }
|
||||||
|
}
|
||||||
|
if i < items.count - 1 { ZXSettingDivider() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func loadNotifications() async {
|
private func loadNotifications() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
do {
|
do { notifications = try await NotificationService.shared.list() } catch {}
|
||||||
notifications = try await NotificationService.shared.list()
|
|
||||||
} catch { /* keep empty state */ }
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refresh() async {
|
private func refresh() async {
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
do {
|
do { notifications = try await NotificationService.shared.list() } catch {}
|
||||||
notifications = try await NotificationService.shared.list()
|
|
||||||
} catch {}
|
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isToday(_ dateStr: String?) -> Bool {
|
||||||
|
guard let d = dateStr else { return false }
|
||||||
|
let today = ISO8601DateFormatter().string(from: Date())
|
||||||
|
return d.prefix(10) == today.prefix(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isThisWeek(_ dateStr: String?) -> Bool {
|
||||||
|
guard let d = dateStr, let date = ISO8601DateFormatter().date(from: String(d.prefix(19)) + "Z") else { return false }
|
||||||
|
return Date().timeIntervalSince(date) < 7 * 86400
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ZXNotificationItemRow: View {
|
struct ZXNotificationItemRow: View {
|
||||||
@ -66,18 +99,24 @@ struct ZXNotificationItemRow: View {
|
|||||||
|
|
||||||
private var iconName: String {
|
private var iconName: String {
|
||||||
switch item.type {
|
switch item.type {
|
||||||
case "review": return "arrow.triangle.2.circlepath"
|
case "review", "review_reminder": return "arrow.triangle.2.circlepath"
|
||||||
case "ai_analysis": return "sparkles"
|
case "import_complete": return "doc.fill.badge.ellipsis"
|
||||||
|
case "import_failed": return "doc.fill.badge.xmark"
|
||||||
|
case "quiz_ready": return "questionmark.circle"
|
||||||
|
case "ai_analysis", "ai_complete": return "brain.head.profile"
|
||||||
case "streak": return "flame.fill"
|
case "streak": return "flame.fill"
|
||||||
|
case "subscription", "subscription_update": return "bell.fill"
|
||||||
|
case "system": return "info.circle"
|
||||||
default: return "bell.fill"
|
default: return "bell.fill"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var iconColor: Color {
|
private var iconColor: Color {
|
||||||
switch item.type {
|
switch item.type {
|
||||||
case "review": return Color.zxOrange
|
case "review", "review_reminder": return Color.zxOrange
|
||||||
case "ai_analysis": return Color.zxPurple
|
case "ai_analysis", "ai_complete": return Color.zxPurple
|
||||||
case "streak": return Color.zxGreen
|
case "streak": return Color.zxGreen
|
||||||
|
case "import_failed": return Color.zxCoral
|
||||||
default: return Color.zxAccent
|
default: return Color.zxAccent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -90,18 +129,23 @@ struct ZXNotificationItemRow: View {
|
|||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(item.title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
|
Text(item.title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
|
||||||
if item.readAt == nil {
|
if item.readAt == nil { Circle().fill(Color.zxPurple).frame(width: 6, height: 6) }
|
||||||
Circle().fill(Color.zxPurple).frame(width: 6, height: 6)
|
|
||||||
}
|
}
|
||||||
}
|
if let content = item.content { Text(content).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(2) }
|
||||||
Text(item.content ?? "").font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(2)
|
|
||||||
if let createdAt = item.createdAt {
|
if let createdAt = item.createdAt {
|
||||||
Text(createdAt.prefix(10).description).font(.system(size: 10)).foregroundColor(Color.zxF03)
|
Text(formatDate(createdAt)).font(.system(size: 10)).foregroundColor(Color.zxF03)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.right").font(.system(size: 12)).foregroundColor(Color.zxF03)
|
|
||||||
}.padding(.horizontal, 16).padding(.vertical, 14)
|
}.padding(.horizontal, 16).padding(.vertical, 14)
|
||||||
}.foregroundColor(.primary)
|
}.foregroundColor(.primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func formatDate(_ iso: String) -> String {
|
||||||
|
if let d = ISO8601DateFormatter().date(from: String(iso.prefix(19)) + "Z") {
|
||||||
|
let f = DateFormatter(); f.locale = Locale(identifier: "zh_CN")
|
||||||
|
f.dateFormat = "MM-dd HH:mm"; return f.string(from: d)
|
||||||
|
}
|
||||||
|
return String(iso.prefix(10))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user