@zellio/chat-react-native 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +239 -2
- package/ZellioChatReactNative.podspec +30 -0
- package/android/build.gradle +62 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/ai/zellio/chat/reactnative/ZellioChatModule.kt +214 -0
- package/android/src/main/java/ai/zellio/chat/reactnative/ZellioChatPackage.kt +25 -0
- package/app.plugin.js +4 -0
- package/ios/RNZellioChat.h +5 -0
- package/ios/RNZellioChat.mm +116 -0
- package/ios/ZCRNBridge.swift +217 -0
- package/ios/vendor/resources/PrivacyInfo.xcprivacy +99 -0
- package/ios/vendor/swift/ZellioChat/Model/ChatModel.swift +261 -0
- package/ios/vendor/swift/ZellioChat/Platform/Platform.swift +124 -0
- package/ios/vendor/swift/ZellioChat/Platform/URLSessionTransport.swift +175 -0
- package/ios/vendor/swift/ZellioChat/Preview/Previews.swift +62 -0
- package/ios/vendor/swift/ZellioChat/Public/InAppBanner.swift +155 -0
- package/ios/vendor/swift/ZellioChat/Public/PublicTypes.swift +141 -0
- package/ios/vendor/swift/ZellioChat/Public/Runtime.swift +406 -0
- package/ios/vendor/swift/ZellioChat/Public/ZellioChat.swift +209 -0
- package/ios/vendor/swift/ZellioChat/Theme/ChatTheme.swift +185 -0
- package/ios/vendor/swift/ZellioChat/Thread/AttachmentPicker.swift +354 -0
- package/ios/vendor/swift/ZellioChat/Thread/ComposerBar.swift +355 -0
- package/ios/vendor/swift/ZellioChat/Thread/ThreadCells.swift +776 -0
- package/ios/vendor/swift/ZellioChat/Thread/ThreadViewController.swift +703 -0
- package/ios/vendor/swift/ZellioChat/Views/ChatRootView.swift +154 -0
- package/ios/vendor/swift/ZellioChat/Views/Components.swift +335 -0
- package/ios/vendor/swift/ZellioChat/Views/HomeView.swift +413 -0
- package/ios/vendor/swift/ZellioChatCore/ChatModels.swift +373 -0
- package/ios/vendor/swift/ZellioChatCore/ConfigModels.swift +389 -0
- package/ios/vendor/swift/ZellioChatCore/Engine.swift +325 -0
- package/ios/vendor/swift/ZellioChatCore/Errors.swift +158 -0
- package/ios/vendor/swift/ZellioChatCore/Generated/ChatStringTable.swift +125 -0
- package/ios/vendor/swift/ZellioChatCore/Outbox.swift +223 -0
- package/ios/vendor/swift/ZellioChatCore/Push.swift +74 -0
- package/ios/vendor/swift/ZellioChatCore/Realtime.swift +328 -0
- package/ios/vendor/swift/ZellioChatCore/Session.swift +210 -0
- package/ios/vendor/swift/ZellioChatCore/Storage.swift +201 -0
- package/ios/vendor/swift/ZellioChatCore/Strings.swift +104 -0
- package/ios/vendor/swift/ZellioChatCore/Theme.swift +238 -0
- package/ios/vendor/swift/ZellioChatCore/ThreadController.swift +319 -0
- package/ios/vendor/swift/ZellioChatCore/Timeline.swift +157 -0
- package/ios/vendor/swift/ZellioChatCore/Transport.swift +236 -0
- package/ios/vendor/swift/ZellioChatCore/Util.swift +137 -0
- package/lib/NativeZellioChat.d.ts +34 -0
- package/lib/NativeZellioChat.js +4 -0
- package/lib/index.d.ts +37 -0
- package/lib/index.js +241 -0
- package/lib/types.d.ts +54 -0
- package/lib/types.js +2 -0
- package/lib/version.d.ts +4 -0
- package/lib/version.js +7 -0
- package/package.json +75 -4
- package/plugin/build/index.js +73 -0
- package/src/NativeZellioChat.ts +30 -0
- package/src/index.ts +237 -0
- package/src/types.ts +56 -0
- package/src/version.ts +5 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#if canImport(SwiftUI) && canImport(UIKit)
|
|
2
|
+
import SwiftUI
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
struct ThreadRepresentable: UIViewControllerRepresentable {
|
|
6
|
+
let controller: ThreadController
|
|
7
|
+
let theme: ChatTheme
|
|
8
|
+
let services: ThreadServices
|
|
9
|
+
|
|
10
|
+
func makeUIViewController(context: Context) -> ThreadViewController {
|
|
11
|
+
ThreadViewController(controller: controller, theme: theme, services: services)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
func updateUIViewController(_ uiViewController: ThreadViewController, context: Context) {
|
|
15
|
+
uiViewController.update(theme: theme, services: services)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static func dismantleUIViewController(_ uiViewController: ThreadViewController, coordinator: ()) {
|
|
19
|
+
uiViewController.teardown()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
struct ThreadScreen: View {
|
|
24
|
+
@Environment(\.chatTheme) private var theme
|
|
25
|
+
@ObservedObject var model: ChatModel
|
|
26
|
+
let onClose: () -> Void
|
|
27
|
+
|
|
28
|
+
private var title: String {
|
|
29
|
+
if case .thread(let id?) = model.route, let name = model.conversations.first(where: { $0.id == id })?.agentName, !name.isEmpty { return name }
|
|
30
|
+
return theme.brandName
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private var subtitle: String {
|
|
34
|
+
let strings = theme.strings
|
|
35
|
+
if model.config?.online == false { return strings.string("banner.outsideHours") }
|
|
36
|
+
return model.config?.snapshot.behavior.replyExpectation.resolve(theme.locale) ?? strings.string("banner.online")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private var services: ThreadServices {
|
|
40
|
+
let engine = model.engine
|
|
41
|
+
let config = model.config
|
|
42
|
+
return ThreadServices(
|
|
43
|
+
canAttach: model.allowsAttachments,
|
|
44
|
+
greeting: config?.snapshot.screens.greeting.resolve(theme.locale) ?? theme.strings.string("thread.greeting"),
|
|
45
|
+
soundEnabled: config?.snapshot.appearance.soundEnabled ?? false,
|
|
46
|
+
upload: { data, name, mime, progress in try await engine.upload(data: data, fileName: name, mimeType: mime, progress: progress) }
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
var body: some View {
|
|
51
|
+
VStack(spacing: 0) {
|
|
52
|
+
bar
|
|
53
|
+
DividerLine()
|
|
54
|
+
if let banner = model.banner { BannerBar(banner: banner) }
|
|
55
|
+
if let controller = model.activeThread {
|
|
56
|
+
ThreadRepresentable(controller: controller, theme: theme, services: services)
|
|
57
|
+
.ignoresSafeArea(.keyboard, edges: .bottom)
|
|
58
|
+
.id(ObjectIdentifier(controller))
|
|
59
|
+
} else {
|
|
60
|
+
Spacer()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
.background(theme.bg.ignoresSafeArea())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private var bar: some View {
|
|
67
|
+
let strings = theme.strings
|
|
68
|
+
return HStack(spacing: 6) {
|
|
69
|
+
if model.canGoBack && !model.isChatOnly {
|
|
70
|
+
IconButton(symbol: "chevron.backward", label: strings.string("common.back"), color: theme.ink, action: { model.goBack() })
|
|
71
|
+
} else {
|
|
72
|
+
Spacer().frame(width: 8)
|
|
73
|
+
}
|
|
74
|
+
LogoView(logoURL: model.config?.logoUrl, size: 32, radius: 10)
|
|
75
|
+
VStack(alignment: .leading, spacing: 0) {
|
|
76
|
+
Text(title).chatFont(.screenTitle).foregroundColor(theme.ink).lineLimit(1)
|
|
77
|
+
.accessibilityAddTraits(.isHeader)
|
|
78
|
+
HStack(spacing: 5) {
|
|
79
|
+
if model.config?.online != false { StatusDot(color: theme.ok) }
|
|
80
|
+
Text(subtitle).chatFont(.secondary, size: 12.5).foregroundColor(theme.ink2).lineLimit(1)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
.padding(.leading, 4)
|
|
84
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
85
|
+
IconButton(symbol: "xmark", label: strings.string("common.close"), color: theme.ink2, action: onClose)
|
|
86
|
+
}
|
|
87
|
+
.padding(.horizontal, 8)
|
|
88
|
+
.padding(.vertical, 4)
|
|
89
|
+
.background(theme.bg)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// The whole chat UI: routes between screens and sets language, direction and theme for everything inside.
|
|
94
|
+
struct ChatRootView: View {
|
|
95
|
+
@ObservedObject var model: ChatModel
|
|
96
|
+
@Environment(\.colorScheme) private var colorScheme
|
|
97
|
+
let onClose: () -> Void
|
|
98
|
+
var onStatusBarStyle: ((UIStatusBarStyle) -> Void)?
|
|
99
|
+
|
|
100
|
+
/// On Home the status bar sits on the header. Everywhere else it sits on the background.
|
|
101
|
+
private func statusBarStyle(_ theme: ChatTheme) -> UIStatusBarStyle {
|
|
102
|
+
let lightContent: Bool
|
|
103
|
+
if model.route == .home, theme.headerStyle != .light {
|
|
104
|
+
lightContent = Contrast.luminance(theme.palette.onPrimary) > 0.5
|
|
105
|
+
} else {
|
|
106
|
+
lightContent = theme.palette.isDark
|
|
107
|
+
}
|
|
108
|
+
return lightContent ? .lightContent : .darkContent
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
var body: some View {
|
|
112
|
+
let theme = model.theme(systemDark: colorScheme == .dark)
|
|
113
|
+
let style = statusBarStyle(theme)
|
|
114
|
+
content(theme)
|
|
115
|
+
.onAppear { onStatusBarStyle?(style) }
|
|
116
|
+
.onChange(of: style) { onStatusBarStyle?($0) }
|
|
117
|
+
.environment(\.chatTheme, theme)
|
|
118
|
+
.environment(\.layoutDirection, theme.layoutDirection)
|
|
119
|
+
.environment(\.locale, ChatFormat.formatter(theme.locale))
|
|
120
|
+
.task { await model.start() }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@ViewBuilder
|
|
124
|
+
private func content(_ theme: ChatTheme) -> some View {
|
|
125
|
+
ZStack {
|
|
126
|
+
if model.route != .consent {
|
|
127
|
+
theme.bg.ignoresSafeArea()
|
|
128
|
+
}
|
|
129
|
+
switch model.route {
|
|
130
|
+
case .loading:
|
|
131
|
+
SkeletonView()
|
|
132
|
+
case .consent:
|
|
133
|
+
ConsentView(model: model, onDecline: onClose)
|
|
134
|
+
case .home:
|
|
135
|
+
HomeView(model: model, onClose: onClose)
|
|
136
|
+
case .list:
|
|
137
|
+
ConversationListView(model: model, onClose: onClose)
|
|
138
|
+
case .thread:
|
|
139
|
+
ThreadScreen(model: model, onClose: onClose)
|
|
140
|
+
case .unavailable:
|
|
141
|
+
MessageStateView(symbol: "headphones", text: theme.strings.string("state.unavailable"),
|
|
142
|
+
buttonTitle: theme.strings.string("attachment.close"), onClose: onClose, onButton: onClose)
|
|
143
|
+
case .loginNeeded:
|
|
144
|
+
MessageStateView(symbol: "person.crop.circle", text: theme.strings.string("state.loginNeeded"),
|
|
145
|
+
buttonTitle: theme.strings.string("attachment.close"), onClose: onClose, onButton: onClose)
|
|
146
|
+
case .loadFailed:
|
|
147
|
+
MessageStateView(symbol: "wifi.slash", text: theme.strings.string("state.loadFailed"),
|
|
148
|
+
buttonTitle: theme.strings.string("state.retry"), onClose: onClose, onButton: { Task { await model.retry() } })
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
.animation(MotionPolicy.reduced ? nil : .easeOut(duration: 0.18), value: model.route)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
#endif
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
#if canImport(SwiftUI) && canImport(UIKit)
|
|
2
|
+
import SwiftUI
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
/// Loads images lazily. Memory cache plus a size-limited disk cache. Only https.
|
|
6
|
+
final class ImageLoader {
|
|
7
|
+
static let shared = ImageLoader()
|
|
8
|
+
|
|
9
|
+
private let memory = NSCache<NSString, UIImage>()
|
|
10
|
+
private let session: URLSession
|
|
11
|
+
|
|
12
|
+
private init() {
|
|
13
|
+
memory.totalCostLimit = 40 * 1024 * 1024
|
|
14
|
+
let configuration = URLSessionConfiguration.default
|
|
15
|
+
configuration.urlCache = URLCache(memoryCapacity: 8 * 1024 * 1024, diskCapacity: 100 * 1024 * 1024, diskPath: "ai.zellio.chat.images")
|
|
16
|
+
configuration.requestCachePolicy = .returnCacheDataElseLoad
|
|
17
|
+
configuration.timeoutIntervalForRequest = 30
|
|
18
|
+
session = URLSession(configuration: configuration)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func cached(_ urlString: String) -> UIImage? { memory.object(forKey: urlString as NSString) }
|
|
22
|
+
|
|
23
|
+
/// Logout: customer photos must not stay on the phone.
|
|
24
|
+
func clear() {
|
|
25
|
+
memory.removeAllObjects()
|
|
26
|
+
session.configuration.urlCache?.removeAllCachedResponses()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
enum Failure: Error { case notAllowed, expired, failed }
|
|
30
|
+
|
|
31
|
+
/// A larger file is never downloaded.
|
|
32
|
+
private static let maxDownloadBytes = 25 * 1024 * 1024
|
|
33
|
+
/// The long edge after decoding, so one photo cannot take hundreds of MB of memory.
|
|
34
|
+
private static let maxPixelEdge = 2048
|
|
35
|
+
|
|
36
|
+
/// Throws `.expired` for 403 so the caller can read the message page once more.
|
|
37
|
+
func load(_ urlString: String) async throws -> UIImage {
|
|
38
|
+
if let image = cached(urlString) { return image }
|
|
39
|
+
guard let url = URL(string: urlString), url.scheme?.lowercased() == "https" else { throw Failure.notAllowed }
|
|
40
|
+
do {
|
|
41
|
+
let (bytes, response) = try await session.bytes(from: url)
|
|
42
|
+
if let http = response as? HTTPURLResponse {
|
|
43
|
+
if http.statusCode == 403 { throw Failure.expired }
|
|
44
|
+
if !(200..<300).contains(http.statusCode) { throw Failure.failed }
|
|
45
|
+
}
|
|
46
|
+
let declared = response.expectedContentLength
|
|
47
|
+
if declared > Int64(Self.maxDownloadBytes) { throw Failure.failed }
|
|
48
|
+
var buffer = [UInt8]()
|
|
49
|
+
if declared > 0 { buffer.reserveCapacity(Int(declared)) }
|
|
50
|
+
for try await byte in bytes {
|
|
51
|
+
buffer.append(byte)
|
|
52
|
+
if buffer.count > Self.maxDownloadBytes { throw Failure.failed }
|
|
53
|
+
}
|
|
54
|
+
guard let image = ImagePrep.decode(Data(buffer), maxPixelEdge: Self.maxPixelEdge) else { throw Failure.failed }
|
|
55
|
+
let cost = image.cgImage.map { $0.bytesPerRow * $0.height } ?? buffer.count
|
|
56
|
+
memory.setObject(image, forKey: urlString as NSString, cost: cost)
|
|
57
|
+
return image
|
|
58
|
+
} catch let failure as Failure {
|
|
59
|
+
throw failure
|
|
60
|
+
} catch {
|
|
61
|
+
throw Failure.failed
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
struct RemoteImage<Placeholder: View>: View {
|
|
67
|
+
let urlString: String?
|
|
68
|
+
let placeholder: Placeholder
|
|
69
|
+
@State private var image: UIImage?
|
|
70
|
+
|
|
71
|
+
init(urlString: String?, @ViewBuilder placeholder: () -> Placeholder) {
|
|
72
|
+
self.urlString = urlString
|
|
73
|
+
self.placeholder = placeholder()
|
|
74
|
+
if let urlString = urlString { _image = State(initialValue: ImageLoader.shared.cached(urlString)) }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
var body: some View {
|
|
78
|
+
Group {
|
|
79
|
+
if let image = image {
|
|
80
|
+
Image(uiImage: image).resizable().scaledToFill()
|
|
81
|
+
} else {
|
|
82
|
+
placeholder
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
.task(id: urlString) {
|
|
86
|
+
guard let urlString = urlString, image == nil else { return }
|
|
87
|
+
image = try? await ImageLoader.shared.load(urlString)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// The brand logo, or a monogram of the first letter.
|
|
93
|
+
struct LogoView: View {
|
|
94
|
+
@Environment(\.chatTheme) private var theme
|
|
95
|
+
let logoURL: String?
|
|
96
|
+
let size: CGFloat
|
|
97
|
+
let radius: CGFloat
|
|
98
|
+
var onGradient = false
|
|
99
|
+
|
|
100
|
+
var body: some View {
|
|
101
|
+
let monogram = String(theme.brandName.first ?? "Z").uppercased()
|
|
102
|
+
RemoteImage(urlString: logoURL) {
|
|
103
|
+
ZStack {
|
|
104
|
+
(onGradient ? Color.white.opacity(0.2) : theme.primary)
|
|
105
|
+
Text(monogram)
|
|
106
|
+
.font(theme.fontName.map { Font.custom($0, fixedSize: size * 0.45) } ?? Font.system(size: size * 0.45, weight: .bold))
|
|
107
|
+
.foregroundColor(onGradient ? theme.onPrimary : theme.onPrimary)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
.frame(width: size, height: size)
|
|
111
|
+
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
|
|
112
|
+
.accessibilityHidden(true)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
struct AvatarView: View {
|
|
117
|
+
@Environment(\.chatTheme) private var theme
|
|
118
|
+
let name: String?
|
|
119
|
+
let urlString: String?
|
|
120
|
+
let size: CGFloat
|
|
121
|
+
|
|
122
|
+
var body: some View {
|
|
123
|
+
let initial = String((name ?? theme.brandName).first ?? "Z").uppercased()
|
|
124
|
+
RemoteImage(urlString: urlString) {
|
|
125
|
+
ZStack {
|
|
126
|
+
theme.avatarTone
|
|
127
|
+
Text(initial)
|
|
128
|
+
.font(Font.system(size: size * 0.42, weight: .semibold))
|
|
129
|
+
.foregroundColor(theme.avatarInk)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
.frame(width: size, height: size)
|
|
133
|
+
.clipShape(Circle())
|
|
134
|
+
.accessibilityHidden(true)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
struct IconButton: View {
|
|
139
|
+
let symbol: String
|
|
140
|
+
let label: String
|
|
141
|
+
let color: Color
|
|
142
|
+
var background: Color = .clear
|
|
143
|
+
let action: () -> Void
|
|
144
|
+
|
|
145
|
+
var body: some View {
|
|
146
|
+
Button(action: action) {
|
|
147
|
+
Image(systemName: symbol)
|
|
148
|
+
.font(.system(size: 18, weight: .semibold))
|
|
149
|
+
.foregroundColor(color)
|
|
150
|
+
.frame(width: 44, height: 44)
|
|
151
|
+
.background(Circle().fill(background))
|
|
152
|
+
.contentShape(Circle())
|
|
153
|
+
}
|
|
154
|
+
.buttonStyle(PlainButtonStyle())
|
|
155
|
+
.accessibilityLabel(Text(label))
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
struct ChatCard<Content: View>: View {
|
|
160
|
+
@Environment(\.chatTheme) private var theme
|
|
161
|
+
let content: Content
|
|
162
|
+
|
|
163
|
+
init(@ViewBuilder content: () -> Content) { self.content = content() }
|
|
164
|
+
|
|
165
|
+
var body: some View {
|
|
166
|
+
VStack(spacing: 0) { content }
|
|
167
|
+
.background(theme.card)
|
|
168
|
+
.clipShape(RoundedRectangle(cornerRadius: CGFloat(Metrics.cardRadius), style: .continuous))
|
|
169
|
+
.overlay(RoundedRectangle(cornerRadius: CGFloat(Metrics.cardRadius), style: .continuous).stroke(theme.line, lineWidth: 1))
|
|
170
|
+
.shadow(color: theme.cardShadow, radius: 10, x: 0, y: 4)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
struct DividerLine: View {
|
|
175
|
+
@Environment(\.chatTheme) private var theme
|
|
176
|
+
var body: some View { Rectangle().fill(theme.line).frame(height: 1) }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
struct StatusDot: View {
|
|
180
|
+
let color: Color
|
|
181
|
+
var size: CGFloat = 7
|
|
182
|
+
var body: some View { Circle().fill(color).frame(width: size, height: size).accessibilityHidden(true) }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
struct AttributionView: View {
|
|
186
|
+
@Environment(\.chatTheme) private var theme
|
|
187
|
+
var body: some View {
|
|
188
|
+
if theme.showAttribution {
|
|
189
|
+
Text(theme.strings.string("common.poweredBy"))
|
|
190
|
+
.chatFont(.caption)
|
|
191
|
+
.foregroundColor(theme.ink2)
|
|
192
|
+
.frame(maxWidth: .infinity)
|
|
193
|
+
.padding(.top, 14)
|
|
194
|
+
.padding(.bottom, 20)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/// A thin bar under the top bar.
|
|
200
|
+
struct BannerBar: View {
|
|
201
|
+
@Environment(\.chatTheme) private var theme
|
|
202
|
+
let banner: ChatBanner
|
|
203
|
+
|
|
204
|
+
var body: some View {
|
|
205
|
+
let colors = palette
|
|
206
|
+
HStack(spacing: 8) {
|
|
207
|
+
Image(systemName: symbol).font(.system(size: 14, weight: .semibold)).accessibilityHidden(true)
|
|
208
|
+
Text(text).chatFont(.secondary, size: 13)
|
|
209
|
+
Spacer(minLength: 0)
|
|
210
|
+
}
|
|
211
|
+
.foregroundColor(colors.1)
|
|
212
|
+
.padding(.horizontal, 16)
|
|
213
|
+
.padding(.vertical, 9)
|
|
214
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
215
|
+
.background(colors.0)
|
|
216
|
+
.accessibilityElement(children: .combine)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private var symbol: String {
|
|
220
|
+
switch banner {
|
|
221
|
+
case .reconnecting: return "wifi.slash"
|
|
222
|
+
case .outsideHours: return "clock"
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private var text: String {
|
|
227
|
+
switch banner {
|
|
228
|
+
case .reconnecting: return theme.strings.string("banner.reconnecting")
|
|
229
|
+
case .outsideHours(let text): return text
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private var palette: (Color, Color) {
|
|
234
|
+
switch banner {
|
|
235
|
+
case .reconnecting:
|
|
236
|
+
return theme.palette.isDark
|
|
237
|
+
? (Color(HexColor("#2E1A0E") ?? .black), Color(HexColor("#FDBA74") ?? .white))
|
|
238
|
+
: (Color(HexColor("#FFF3E8") ?? .white), Color(HexColor("#9A3D00") ?? .black))
|
|
239
|
+
case .outsideHours:
|
|
240
|
+
return (theme.bgAlt, theme.ink2)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
struct PillButton: View {
|
|
246
|
+
@Environment(\.chatTheme) private var theme
|
|
247
|
+
let title: String
|
|
248
|
+
let symbol: String?
|
|
249
|
+
let action: () -> Void
|
|
250
|
+
|
|
251
|
+
var body: some View {
|
|
252
|
+
Button(action: action) {
|
|
253
|
+
HStack(spacing: 8) {
|
|
254
|
+
if let symbol = symbol { Image(systemName: symbol).font(.system(size: 18, weight: .semibold)).accessibilityHidden(true) }
|
|
255
|
+
Text(title).chatFont(.message, weight: .semibold)
|
|
256
|
+
}
|
|
257
|
+
.foregroundColor(theme.onPrimary)
|
|
258
|
+
.padding(.horizontal, 24)
|
|
259
|
+
.frame(minHeight: 52)
|
|
260
|
+
.background(Capsule().fill(theme.primary))
|
|
261
|
+
.shadow(color: theme.primary.opacity(0.35), radius: 12, x: 0, y: 8)
|
|
262
|
+
}
|
|
263
|
+
.buttonStyle(PlainButtonStyle())
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/// A calm placeholder while the first config loads.
|
|
268
|
+
struct SkeletonView: View {
|
|
269
|
+
@Environment(\.chatTheme) private var theme
|
|
270
|
+
@State private var pulse = false
|
|
271
|
+
|
|
272
|
+
var body: some View {
|
|
273
|
+
VStack(alignment: .leading, spacing: 16) {
|
|
274
|
+
RoundedRectangle(cornerRadius: 12).fill(theme.bgAlt).frame(width: 40, height: 40)
|
|
275
|
+
RoundedRectangle(cornerRadius: 8).fill(theme.bgAlt).frame(width: 220, height: 28)
|
|
276
|
+
RoundedRectangle(cornerRadius: 8).fill(theme.bgAlt).frame(width: 160, height: 16)
|
|
277
|
+
RoundedRectangle(cornerRadius: CGFloat(Metrics.cardRadius)).fill(theme.bgAlt).frame(height: 120)
|
|
278
|
+
RoundedRectangle(cornerRadius: CGFloat(Metrics.cardRadius)).fill(theme.bgAlt).frame(height: 72)
|
|
279
|
+
Spacer()
|
|
280
|
+
}
|
|
281
|
+
.padding(20)
|
|
282
|
+
.opacity(pulse ? 0.55 : 1)
|
|
283
|
+
.onAppear {
|
|
284
|
+
if !MotionPolicy.reduced { withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) { pulse = true } }
|
|
285
|
+
}
|
|
286
|
+
.accessibilityElement(children: .ignore)
|
|
287
|
+
.accessibilityLabel(Text(theme.strings.string("state.loading")))
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/// An icon, one line, and a button. No technical words.
|
|
292
|
+
struct MessageStateView: View {
|
|
293
|
+
@Environment(\.chatTheme) private var theme
|
|
294
|
+
let symbol: String
|
|
295
|
+
let text: String
|
|
296
|
+
let buttonTitle: String
|
|
297
|
+
let onClose: () -> Void
|
|
298
|
+
let onButton: () -> Void
|
|
299
|
+
|
|
300
|
+
var body: some View {
|
|
301
|
+
VStack(spacing: 0) {
|
|
302
|
+
HStack {
|
|
303
|
+
Spacer()
|
|
304
|
+
IconButton(symbol: "xmark", label: theme.strings.string("common.close"), color: theme.ink2, action: onClose)
|
|
305
|
+
}
|
|
306
|
+
.padding(.horizontal, 8)
|
|
307
|
+
Spacer()
|
|
308
|
+
VStack(spacing: 14) {
|
|
309
|
+
Image(systemName: symbol)
|
|
310
|
+
.font(.system(size: 28, weight: .regular))
|
|
311
|
+
.foregroundColor(theme.ink2)
|
|
312
|
+
.frame(width: 64, height: 64)
|
|
313
|
+
.background(RoundedRectangle(cornerRadius: 20, style: .continuous).fill(theme.bgAlt))
|
|
314
|
+
.accessibilityHidden(true)
|
|
315
|
+
Text(text)
|
|
316
|
+
.chatFont(.screenTitle, weight: .medium)
|
|
317
|
+
.foregroundColor(theme.ink)
|
|
318
|
+
.multilineTextAlignment(.center)
|
|
319
|
+
Button(action: onButton) {
|
|
320
|
+
Text(buttonTitle)
|
|
321
|
+
.chatFont(.message, size: 15.5, weight: .medium)
|
|
322
|
+
.foregroundColor(theme.ink)
|
|
323
|
+
.padding(.horizontal, 26)
|
|
324
|
+
.frame(minHeight: 48)
|
|
325
|
+
.overlay(Capsule().stroke(theme.line, lineWidth: 1))
|
|
326
|
+
}
|
|
327
|
+
.buttonStyle(PlainButtonStyle())
|
|
328
|
+
}
|
|
329
|
+
.padding(.horizontal, 44)
|
|
330
|
+
Spacer()
|
|
331
|
+
Spacer().frame(height: 80)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
#endif
|