mirror of
https://github.com/phranck/TUIkit.git
synced 2026-05-21 09:50:35 +00:00
Feat: add fire-and-forget notification system with NotificationService
Centralized NotificationService with .notificationHost() modifier for rendering stacked, auto-dismissing notifications in the top-right corner. Notifications use a single style with theme border colors — severity differentiation belongs to Alerts, not notifications. - NotificationService with static accessor and environment key - NotificationHostModifier with fade-in/out animation and vertical stacking - NotificationTiming for opacity interpolation and word-wrap - Box(lines:) convenience init with BufferView for pre-styled content - LifecycleManager.resetAppearance(token:) for re-triggering animations - 17 tests covering service, timing, word-wrap, and rendering
This commit is contained in:
@@ -18,6 +18,7 @@ private enum OverlayDemo: Int, CaseIterable {
|
||||
case dialog
|
||||
case dialogWithFooter
|
||||
case modalCustom
|
||||
case notification
|
||||
|
||||
/// Display label for the menu.
|
||||
var label: String {
|
||||
@@ -30,6 +31,7 @@ private enum OverlayDemo: Int, CaseIterable {
|
||||
case .dialog: "Dialog"
|
||||
case .dialogWithFooter: "Dialog with Footer"
|
||||
case .modalCustom: "Modal (Custom)"
|
||||
case .notification: "Notification"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +54,8 @@ private enum OverlayDemo: Int, CaseIterable {
|
||||
"A Dialog with a footer section for action buttons, separated by a divider line."
|
||||
case .modalCustom:
|
||||
"A custom modal overlay using .modal(isPresented:). Accepts any view as content."
|
||||
case .notification:
|
||||
"A fire-and-forget notification. Fades in, stays 3s, fades out. Posted via NotificationService."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,8 +78,15 @@ private enum OverlayDemo: Int, CaseIterable {
|
||||
".modal(isPresented: $show) { Dialog(title: \"...\") { content } footer: { buttons } }"
|
||||
case .modalCustom:
|
||||
".modal(isPresented: $show) { VStack { ... } }"
|
||||
case .notification:
|
||||
"NotificationService.current.post(\"Saved!\")"
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this demo variant is a notification (not a modal).
|
||||
var isNotification: Bool {
|
||||
self == .notification
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Overlays Page
|
||||
@@ -102,6 +113,7 @@ struct OverlaysPage: View {
|
||||
.modal(isPresented: $showOverlay) {
|
||||
overlayContent(for: selectedDemo)
|
||||
}
|
||||
.notificationHost()
|
||||
.statusBarItems(statusBarItems)
|
||||
}
|
||||
|
||||
@@ -140,7 +152,13 @@ struct OverlaysPage: View {
|
||||
},
|
||||
selection: $menuSelection,
|
||||
onSelect: { _ in
|
||||
showOverlay = true
|
||||
if selectedDemo.isNotification {
|
||||
NotificationService.current.post(
|
||||
"Operation completed successfully!"
|
||||
)
|
||||
} else {
|
||||
showOverlay = true
|
||||
}
|
||||
},
|
||||
selectedColor: .palette.accent,
|
||||
borderColor: .palette.border
|
||||
@@ -153,11 +171,13 @@ struct OverlaysPage: View {
|
||||
DemoSection("How It Works") {
|
||||
Text("All overlays use the SwiftUI-style presentation API:")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
Text(" .alert(isPresented:) — for Alert views")
|
||||
Text(" .alert(isPresented:) — for Alert views")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
Text(" .modal(isPresented:) — for Dialog, custom content")
|
||||
Text(" .modal(isPresented:) — for Dialog, custom content")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
Text("The background is automatically dimmed. Status bar stays visible.")
|
||||
Text(" NotificationService.current.post() — fire-and-forget with fade")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
Text("Modals dim the background. Notifications stay non-blocking.")
|
||||
.bold()
|
||||
.foregroundColor(.palette.accent)
|
||||
}
|
||||
@@ -166,7 +186,7 @@ struct OverlaysPage: View {
|
||||
}
|
||||
.appHeader {
|
||||
HStack {
|
||||
Text("Overlays & Modals Demo").bold().foregroundColor(.palette.accent)
|
||||
Text("Overlays, Modals & Notifications Demo").bold().foregroundColor(.palette.accent)
|
||||
Spacer()
|
||||
Text("TUIkit v\(tuiKitVersion)").foregroundColor(.palette.foregroundTertiary)
|
||||
}
|
||||
@@ -200,66 +220,16 @@ struct OverlaysPage: View {
|
||||
@ViewBuilder
|
||||
private func overlayContent(for demo: OverlayDemo) -> some View {
|
||||
switch demo {
|
||||
case .alertStandard:
|
||||
Alert(
|
||||
title: "Standard Alert",
|
||||
message: "This is a standard alert with default theme colors.",
|
||||
borderColor: .palette.border,
|
||||
titleColor: .palette.accent
|
||||
) {
|
||||
dismissButton
|
||||
}
|
||||
.frame(width: 50)
|
||||
|
||||
case .alertWarning:
|
||||
Alert(
|
||||
title: "Warning",
|
||||
message: "Something might go wrong. Please check your input.",
|
||||
titleColor: .palette.warning
|
||||
) {
|
||||
dismissButton
|
||||
}
|
||||
.frame(width: 50)
|
||||
|
||||
case .alertError:
|
||||
Alert(
|
||||
title: "Error",
|
||||
message: "An unexpected error occurred. Please try again.",
|
||||
titleColor: .palette.error
|
||||
) {
|
||||
dismissButton
|
||||
}
|
||||
.frame(width: 50)
|
||||
|
||||
case .alertInfo:
|
||||
Alert(
|
||||
title: "Info",
|
||||
message: "This is an informational message for the user.",
|
||||
titleColor: .palette.info
|
||||
) {
|
||||
dismissButton
|
||||
}
|
||||
.frame(width: 50)
|
||||
|
||||
case .alertSuccess:
|
||||
Alert(
|
||||
title: "Success",
|
||||
message: "Operation completed successfully!",
|
||||
titleColor: .palette.success
|
||||
) {
|
||||
dismissButton
|
||||
}
|
||||
.frame(width: 50)
|
||||
case .alertStandard, .alertWarning, .alertError,
|
||||
.alertInfo, .alertSuccess:
|
||||
alertContent(for: demo)
|
||||
|
||||
case .dialog:
|
||||
Dialog(title: "Settings", borderColor: .palette.border, titleColor: .palette.accent) {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Theme: Dark")
|
||||
.foregroundColor(.palette.foreground)
|
||||
Text("Language: English")
|
||||
.foregroundColor(.palette.foreground)
|
||||
Text("Notifications: On")
|
||||
.foregroundColor(.palette.foreground)
|
||||
Text("Theme: Dark").foregroundColor(.palette.foreground)
|
||||
Text("Language: English").foregroundColor(.palette.foreground)
|
||||
Text("Notifications: On").foregroundColor(.palette.foreground)
|
||||
Text("")
|
||||
dismissButton
|
||||
}
|
||||
@@ -267,15 +237,9 @@ struct OverlaysPage: View {
|
||||
.frame(width: 50)
|
||||
|
||||
case .dialogWithFooter:
|
||||
Dialog(
|
||||
title: "Confirm Action",
|
||||
borderColor: .palette.border,
|
||||
titleColor: .palette.accent
|
||||
) {
|
||||
Text("Are you sure you want to proceed?")
|
||||
.foregroundColor(.palette.foreground)
|
||||
Text("This action cannot be undone.")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
Dialog(title: "Confirm Action", borderColor: .palette.border, titleColor: .palette.accent) {
|
||||
Text("Are you sure you want to proceed?").foregroundColor(.palette.foreground)
|
||||
Text("This action cannot be undone.").foregroundColor(.palette.foregroundSecondary)
|
||||
} footer: {
|
||||
dismissButton
|
||||
}
|
||||
@@ -283,21 +247,59 @@ struct OverlaysPage: View {
|
||||
|
||||
case .modalCustom:
|
||||
VStack(spacing: 1) {
|
||||
Text("Custom Modal Content")
|
||||
.bold()
|
||||
.foregroundColor(.palette.accent)
|
||||
Text("Custom Modal Content").bold().foregroundColor(.palette.accent)
|
||||
Text("")
|
||||
Text("This modal uses .modal(isPresented:)")
|
||||
.foregroundColor(.palette.foreground)
|
||||
Text("with completely custom view content.")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
Text("No Alert or Dialog — just any View!")
|
||||
.foregroundColor(.palette.foregroundSecondary)
|
||||
Text("This modal uses .modal(isPresented:)").foregroundColor(.palette.foreground)
|
||||
Text("with completely custom view content.").foregroundColor(.palette.foregroundSecondary)
|
||||
Text("No Alert or Dialog — just any View!").foregroundColor(.palette.foregroundSecondary)
|
||||
Text("")
|
||||
dismissButton
|
||||
}
|
||||
.padding(EdgeInsets(horizontal: 2, vertical: 1))
|
||||
.border(color: .palette.border)
|
||||
|
||||
case .notification:
|
||||
// Notifications are posted via NotificationService, not shown as modal content.
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an alert for the given demo variant.
|
||||
@ViewBuilder
|
||||
private func alertContent(for demo: OverlayDemo) -> some View {
|
||||
switch demo {
|
||||
case .alertStandard:
|
||||
Alert(
|
||||
title: "Standard Alert",
|
||||
message: "This is a standard alert with default theme colors.",
|
||||
borderColor: .palette.border, titleColor: .palette.accent
|
||||
) { dismissButton }.frame(width: 50)
|
||||
case .alertWarning:
|
||||
Alert(
|
||||
title: "Warning",
|
||||
message: "Something might go wrong. Please check your input.",
|
||||
titleColor: .palette.warning
|
||||
) { dismissButton }.frame(width: 50)
|
||||
case .alertError:
|
||||
Alert(
|
||||
title: "Error",
|
||||
message: "An unexpected error occurred. Please try again.",
|
||||
titleColor: .palette.error
|
||||
) { dismissButton }.frame(width: 50)
|
||||
case .alertInfo:
|
||||
Alert(
|
||||
title: "Info",
|
||||
message: "This is an informational message for the user.",
|
||||
titleColor: .palette.info
|
||||
) { dismissButton }.frame(width: 50)
|
||||
case .alertSuccess:
|
||||
Alert(
|
||||
title: "Success",
|
||||
message: "Operation completed successfully!",
|
||||
titleColor: .palette.success
|
||||
) { dismissButton }.frame(width: 50)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user