expo-tvos-search 1.2.3 → 1.3.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/README.md +325 -17
- package/build/index.d.ts +115 -4
- package/build/index.d.ts.map +1 -1
- package/build/index.js +54 -6
- package/ios/ExpoTvosSearchModule.swift +67 -4
- package/ios/ExpoTvosSearchView.swift +325 -61
- package/ios/MarqueeText.swift +2 -2
- package/package.json +9 -1
- package/src/__tests__/index.test.tsx +456 -1
- package/src/index.tsx +197 -9
|
@@ -12,17 +12,32 @@ public class ExpoTvosSearchModule: Module {
|
|
|
12
12
|
Name("ExpoTvosSearch")
|
|
13
13
|
|
|
14
14
|
View(ExpoTvosSearchView.self) {
|
|
15
|
-
Events("onSearch", "onSelectItem")
|
|
15
|
+
Events("onSearch", "onSelectItem", "onError", "onValidationWarning")
|
|
16
16
|
|
|
17
17
|
Prop("results") { (view: ExpoTvosSearchView, results: [[String: Any]]) in
|
|
18
18
|
// Limit results array size to prevent memory issues
|
|
19
19
|
let limitedResults = Array(results.prefix(Self.maxResults))
|
|
20
|
+
if results.count > Self.maxResults {
|
|
21
|
+
view.onValidationWarning([
|
|
22
|
+
"type": "value_clamped",
|
|
23
|
+
"message": "Results array truncated from \(results.count) to \(Self.maxResults) items",
|
|
24
|
+
"context": "maxResults=\(Self.maxResults)"
|
|
25
|
+
])
|
|
26
|
+
}
|
|
20
27
|
view.updateResults(limitedResults)
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
Prop("columns") { (view: ExpoTvosSearchView, columns: Int) in
|
|
24
31
|
// Clamp columns between min and max for safe grid layout
|
|
25
|
-
|
|
32
|
+
let clampedValue = min(max(Self.minColumns, columns), Self.maxColumns)
|
|
33
|
+
if clampedValue != columns {
|
|
34
|
+
view.onValidationWarning([
|
|
35
|
+
"type": "value_clamped",
|
|
36
|
+
"message": "columns value \(columns) was clamped to range [\(Self.minColumns), \(Self.maxColumns)]",
|
|
37
|
+
"context": "columns=\(clampedValue)"
|
|
38
|
+
])
|
|
39
|
+
}
|
|
40
|
+
view.columns = clampedValue
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
Prop("placeholder") { (view: ExpoTvosSearchView, placeholder: String) in
|
|
@@ -48,7 +63,15 @@ public class ExpoTvosSearchModule: Module {
|
|
|
48
63
|
|
|
49
64
|
Prop("topInset") { (view: ExpoTvosSearchView, topInset: Double) in
|
|
50
65
|
// Clamp to non-negative values (max 500 points reasonable for any screen)
|
|
51
|
-
|
|
66
|
+
let clampedValue = min(max(0, topInset), 500)
|
|
67
|
+
if clampedValue != topInset {
|
|
68
|
+
view.onValidationWarning([
|
|
69
|
+
"type": "value_clamped",
|
|
70
|
+
"message": "topInset value \(topInset) was clamped to range [0, 500]",
|
|
71
|
+
"context": "topInset=\(clampedValue)"
|
|
72
|
+
])
|
|
73
|
+
}
|
|
74
|
+
view.topInset = CGFloat(clampedValue)
|
|
52
75
|
}
|
|
53
76
|
|
|
54
77
|
Prop("showTitleOverlay") { (view: ExpoTvosSearchView, show: Bool) in
|
|
@@ -61,7 +84,15 @@ public class ExpoTvosSearchModule: Module {
|
|
|
61
84
|
|
|
62
85
|
Prop("marqueeDelay") { (view: ExpoTvosSearchView, delay: Double) in
|
|
63
86
|
// Clamp between 0 and maxMarqueeDelay seconds
|
|
64
|
-
|
|
87
|
+
let clampedValue = min(max(0, delay), Self.maxMarqueeDelay)
|
|
88
|
+
if clampedValue != delay {
|
|
89
|
+
view.onValidationWarning([
|
|
90
|
+
"type": "value_clamped",
|
|
91
|
+
"message": "marqueeDelay value \(delay) was clamped to range [0, \(Self.maxMarqueeDelay)]",
|
|
92
|
+
"context": "marqueeDelay=\(clampedValue)"
|
|
93
|
+
])
|
|
94
|
+
}
|
|
95
|
+
view.marqueeDelay = clampedValue
|
|
65
96
|
}
|
|
66
97
|
|
|
67
98
|
Prop("emptyStateText") { (view: ExpoTvosSearchView, text: String) in
|
|
@@ -79,6 +110,38 @@ public class ExpoTvosSearchModule: Module {
|
|
|
79
110
|
Prop("noResultsHintText") { (view: ExpoTvosSearchView, text: String) in
|
|
80
111
|
view.noResultsHintText = String(text.prefix(Self.maxStringLength))
|
|
81
112
|
}
|
|
113
|
+
|
|
114
|
+
Prop("textColor") { (view: ExpoTvosSearchView, colorHex: String?) in
|
|
115
|
+
view.textColor = colorHex
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
Prop("accentColor") { (view: ExpoTvosSearchView, colorHex: String) in
|
|
119
|
+
view.accentColor = colorHex
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
Prop("cardWidth") { (view: ExpoTvosSearchView, width: Double) in
|
|
123
|
+
view.cardWidth = CGFloat(max(50, min(1000, width))) // Clamp to reasonable range
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
Prop("cardHeight") { (view: ExpoTvosSearchView, height: Double) in
|
|
127
|
+
view.cardHeight = CGFloat(max(50, min(1000, height))) // Clamp to reasonable range
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
Prop("imageContentMode") { (view: ExpoTvosSearchView, mode: String) in
|
|
131
|
+
view.imageContentMode = mode
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
Prop("cardMargin") { (view: ExpoTvosSearchView, margin: Double) in
|
|
135
|
+
view.cardMargin = CGFloat(max(0, min(200, margin))) // Clamp to reasonable range
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
Prop("cardPadding") { (view: ExpoTvosSearchView, padding: Double) in
|
|
139
|
+
view.cardPadding = CGFloat(max(0, min(100, padding))) // Clamp to reasonable range
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
Prop("overlayTitleSize") { (view: ExpoTvosSearchView, size: Double) in
|
|
143
|
+
view.overlayTitleSize = CGFloat(max(8, min(72, size))) // Clamp to reasonable font size range
|
|
144
|
+
}
|
|
82
145
|
}
|
|
83
146
|
}
|
|
84
147
|
}
|
|
@@ -3,6 +3,41 @@ import SwiftUI
|
|
|
3
3
|
|
|
4
4
|
#if os(tvOS)
|
|
5
5
|
|
|
6
|
+
/// Custom shape for cards with selectively rounded corners
|
|
7
|
+
/// Provides backwards compatibility for tvOS versions before 16.0
|
|
8
|
+
struct SelectiveRoundedRectangle: Shape {
|
|
9
|
+
var topLeadingRadius: CGFloat
|
|
10
|
+
var topTrailingRadius: CGFloat
|
|
11
|
+
var bottomLeadingRadius: CGFloat
|
|
12
|
+
var bottomTrailingRadius: CGFloat
|
|
13
|
+
|
|
14
|
+
func path(in rect: CGRect) -> Path {
|
|
15
|
+
var path = Path()
|
|
16
|
+
|
|
17
|
+
let tl = min(topLeadingRadius, min(rect.width, rect.height) / 2)
|
|
18
|
+
let tr = min(topTrailingRadius, min(rect.width, rect.height) / 2)
|
|
19
|
+
let bl = min(bottomLeadingRadius, min(rect.width, rect.height) / 2)
|
|
20
|
+
let br = min(bottomTrailingRadius, min(rect.width, rect.height) / 2)
|
|
21
|
+
|
|
22
|
+
path.move(to: CGPoint(x: rect.minX + tl, y: rect.minY))
|
|
23
|
+
path.addLine(to: CGPoint(x: rect.maxX - tr, y: rect.minY))
|
|
24
|
+
path.addArc(center: CGPoint(x: rect.maxX - tr, y: rect.minY + tr),
|
|
25
|
+
radius: tr, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false)
|
|
26
|
+
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - br))
|
|
27
|
+
path.addArc(center: CGPoint(x: rect.maxX - br, y: rect.maxY - br),
|
|
28
|
+
radius: br, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)
|
|
29
|
+
path.addLine(to: CGPoint(x: rect.minX + bl, y: rect.maxY))
|
|
30
|
+
path.addArc(center: CGPoint(x: rect.minX + bl, y: rect.maxY - bl),
|
|
31
|
+
radius: bl, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false)
|
|
32
|
+
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + tl))
|
|
33
|
+
path.addArc(center: CGPoint(x: rect.minX + tl, y: rect.minY + tl),
|
|
34
|
+
radius: tl, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)
|
|
35
|
+
path.closeSubpath()
|
|
36
|
+
|
|
37
|
+
return path
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
6
41
|
struct SearchResultItem: Identifiable, Equatable {
|
|
7
42
|
let id: String
|
|
8
43
|
let title: String
|
|
@@ -38,13 +73,29 @@ class SearchViewModel: ObservableObject {
|
|
|
38
73
|
var searchingText: String = "Searching..."
|
|
39
74
|
var noResultsText: String = "No results found"
|
|
40
75
|
var noResultsHintText: String = "Try a different search term"
|
|
76
|
+
|
|
77
|
+
// Color customization options (configurable from JS)
|
|
78
|
+
var textColor: Color? = nil
|
|
79
|
+
var accentColor: Color = Color(red: 1, green: 0.765, blue: 0.07) // #FFC312 (gold)
|
|
80
|
+
|
|
81
|
+
// Card dimension options (configurable from JS)
|
|
82
|
+
var cardWidth: CGFloat = 280
|
|
83
|
+
var cardHeight: CGFloat = 420
|
|
84
|
+
|
|
85
|
+
// Image display options (configurable from JS)
|
|
86
|
+
var imageContentMode: ContentMode = .fill
|
|
87
|
+
|
|
88
|
+
// Layout spacing options (configurable from JS)
|
|
89
|
+
var cardMargin: CGFloat = 40 // Spacing between cards
|
|
90
|
+
var cardPadding: CGFloat = 16 // Padding inside cards
|
|
91
|
+
var overlayTitleSize: CGFloat = 20 // Font size for overlay title
|
|
41
92
|
}
|
|
42
93
|
|
|
43
94
|
struct TvosSearchContentView: View {
|
|
44
95
|
@ObservedObject var viewModel: SearchViewModel
|
|
45
96
|
|
|
46
97
|
private var gridColumns: [GridItem] {
|
|
47
|
-
Array(repeating: GridItem(.flexible(), spacing:
|
|
98
|
+
Array(repeating: GridItem(.flexible(), spacing: viewModel.cardMargin), count: viewModel.columns)
|
|
48
99
|
}
|
|
49
100
|
|
|
50
101
|
var body: some View {
|
|
@@ -82,10 +133,10 @@ struct TvosSearchContentView: View {
|
|
|
82
133
|
VStack(spacing: 20) {
|
|
83
134
|
Image(systemName: "magnifyingglass")
|
|
84
135
|
.font(.system(size: 80))
|
|
85
|
-
.foregroundColor(.secondary)
|
|
136
|
+
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
86
137
|
Text(viewModel.emptyStateText)
|
|
87
138
|
.font(.headline)
|
|
88
|
-
.foregroundColor(.secondary)
|
|
139
|
+
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
89
140
|
}
|
|
90
141
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
91
142
|
}
|
|
@@ -96,7 +147,7 @@ struct TvosSearchContentView: View {
|
|
|
96
147
|
.scaleEffect(1.5)
|
|
97
148
|
Text(viewModel.searchingText)
|
|
98
149
|
.font(.headline)
|
|
99
|
-
.foregroundColor(.secondary)
|
|
150
|
+
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
100
151
|
}
|
|
101
152
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
102
153
|
}
|
|
@@ -105,13 +156,13 @@ struct TvosSearchContentView: View {
|
|
|
105
156
|
VStack(spacing: 20) {
|
|
106
157
|
Image(systemName: "film.stack")
|
|
107
158
|
.font(.system(size: 80))
|
|
108
|
-
.foregroundColor(.secondary)
|
|
159
|
+
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
109
160
|
Text(viewModel.noResultsText)
|
|
110
161
|
.font(.headline)
|
|
111
|
-
.foregroundColor(.secondary)
|
|
162
|
+
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
112
163
|
Text(viewModel.noResultsHintText)
|
|
113
164
|
.font(.subheadline)
|
|
114
|
-
.foregroundColor(.secondary)
|
|
165
|
+
.foregroundColor(viewModel.textColor ?? .secondary)
|
|
115
166
|
}
|
|
116
167
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
117
168
|
}
|
|
@@ -133,7 +184,7 @@ struct TvosSearchContentView: View {
|
|
|
133
184
|
|
|
134
185
|
private var resultsGridView: some View {
|
|
135
186
|
ScrollView {
|
|
136
|
-
LazyVGrid(columns: gridColumns, spacing:
|
|
187
|
+
LazyVGrid(columns: gridColumns, spacing: viewModel.cardMargin) {
|
|
137
188
|
ForEach(viewModel.results) { item in
|
|
138
189
|
SearchResultCard(
|
|
139
190
|
item: item,
|
|
@@ -143,6 +194,13 @@ struct TvosSearchContentView: View {
|
|
|
143
194
|
showTitleOverlay: viewModel.showTitleOverlay,
|
|
144
195
|
enableMarquee: viewModel.enableMarquee,
|
|
145
196
|
marqueeDelay: viewModel.marqueeDelay,
|
|
197
|
+
textColor: viewModel.textColor,
|
|
198
|
+
accentColor: viewModel.accentColor,
|
|
199
|
+
cardWidth: viewModel.cardWidth,
|
|
200
|
+
cardHeight: viewModel.cardHeight,
|
|
201
|
+
imageContentMode: viewModel.imageContentMode,
|
|
202
|
+
cardPadding: viewModel.cardPadding,
|
|
203
|
+
overlayTitleSize: viewModel.overlayTitleSize,
|
|
146
204
|
onSelect: { viewModel.onSelectItem?(item.id) }
|
|
147
205
|
)
|
|
148
206
|
}
|
|
@@ -161,21 +219,20 @@ struct SearchResultCard: View {
|
|
|
161
219
|
let showTitleOverlay: Bool
|
|
162
220
|
let enableMarquee: Bool
|
|
163
221
|
let marqueeDelay: Double
|
|
222
|
+
let textColor: Color?
|
|
223
|
+
let accentColor: Color
|
|
224
|
+
let cardWidth: CGFloat
|
|
225
|
+
let cardHeight: CGFloat
|
|
226
|
+
let imageContentMode: ContentMode
|
|
227
|
+
let cardPadding: CGFloat
|
|
228
|
+
let overlayTitleSize: CGFloat
|
|
164
229
|
let onSelect: () -> Void
|
|
165
230
|
@FocusState private var isFocused: Bool
|
|
166
231
|
|
|
167
232
|
private let placeholderColor = Color(white: 0.2)
|
|
168
|
-
private let focusedBorderColor = Color(red: 1, green: 0.765, blue: 0.07) // #FFC312
|
|
169
|
-
|
|
170
|
-
// Fixed card dimensions for consistent grid layout
|
|
171
|
-
// Width calculated for 5 columns: (1920 - 120 padding - 160 spacing) / 5 ≈ 280
|
|
172
|
-
private let cardWidth: CGFloat = 280
|
|
173
|
-
private var cardHeight: CGFloat { cardWidth * 1.5 } // 2:3 aspect ratio
|
|
174
233
|
|
|
175
234
|
// Title overlay constants
|
|
176
|
-
private
|
|
177
|
-
private let titleBarHeight: CGFloat = 36
|
|
178
|
-
private let overlayOpacity: Double = 0.8
|
|
235
|
+
private var overlayHeight: CGFloat { cardHeight * 0.25 } // 25% of card
|
|
179
236
|
|
|
180
237
|
var body: some View {
|
|
181
238
|
Button(action: onSelect) {
|
|
@@ -193,7 +250,7 @@ struct SearchResultCard: View {
|
|
|
193
250
|
case .success(let image):
|
|
194
251
|
image
|
|
195
252
|
.resizable()
|
|
196
|
-
.aspectRatio(contentMode:
|
|
253
|
+
.aspectRatio(contentMode: imageContentMode)
|
|
197
254
|
.frame(width: cardWidth, height: cardHeight)
|
|
198
255
|
case .failure:
|
|
199
256
|
placeholderIcon
|
|
@@ -209,47 +266,53 @@ struct SearchResultCard: View {
|
|
|
209
266
|
.frame(width: cardWidth, height: cardHeight)
|
|
210
267
|
.clipped()
|
|
211
268
|
|
|
212
|
-
// Title overlay
|
|
269
|
+
// Title overlay with native material blur
|
|
213
270
|
if showTitleOverlay {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
)
|
|
271
|
+
ZStack {
|
|
272
|
+
Rectangle()
|
|
273
|
+
.fill(.ultraThinMaterial)
|
|
274
|
+
.frame(width: cardWidth, height: overlayHeight)
|
|
275
|
+
|
|
276
|
+
if enableMarquee {
|
|
277
|
+
MarqueeText(
|
|
278
|
+
item.title,
|
|
279
|
+
font: .system(size: overlayTitleSize, weight: .semibold),
|
|
280
|
+
leftFade: 12,
|
|
281
|
+
rightFade: 12,
|
|
282
|
+
startDelay: marqueeDelay,
|
|
283
|
+
animate: isFocused
|
|
284
|
+
)
|
|
285
|
+
.foregroundColor(.white)
|
|
286
|
+
.padding(.horizontal, cardPadding)
|
|
287
|
+
} else {
|
|
288
|
+
Text(item.title)
|
|
289
|
+
.font(.system(size: overlayTitleSize, weight: .semibold))
|
|
234
290
|
.foregroundColor(.white)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
.foregroundColor(.white)
|
|
239
|
-
.lineLimit(1)
|
|
240
|
-
}
|
|
291
|
+
.lineLimit(2)
|
|
292
|
+
.multilineTextAlignment(.center)
|
|
293
|
+
.padding(.horizontal, cardPadding)
|
|
241
294
|
}
|
|
242
|
-
.padding(.horizontal, 12)
|
|
243
|
-
.frame(width: cardWidth, height: titleBarHeight, alignment: .leading)
|
|
244
|
-
.background(Color.black.opacity(overlayOpacity))
|
|
245
295
|
}
|
|
296
|
+
.frame(width: cardWidth, height: overlayHeight)
|
|
246
297
|
}
|
|
247
298
|
}
|
|
248
299
|
.frame(width: cardWidth, height: cardHeight)
|
|
249
|
-
.clipShape(
|
|
300
|
+
.clipShape(
|
|
301
|
+
SelectiveRoundedRectangle(
|
|
302
|
+
topLeadingRadius: 12,
|
|
303
|
+
topTrailingRadius: 12,
|
|
304
|
+
bottomLeadingRadius: (showTitle || showSubtitle) ? 0 : 12,
|
|
305
|
+
bottomTrailingRadius: (showTitle || showSubtitle) ? 0 : 12
|
|
306
|
+
)
|
|
307
|
+
)
|
|
250
308
|
.overlay(
|
|
251
|
-
|
|
252
|
-
|
|
309
|
+
SelectiveRoundedRectangle(
|
|
310
|
+
topLeadingRadius: 12,
|
|
311
|
+
topTrailingRadius: 12,
|
|
312
|
+
bottomLeadingRadius: (showTitle || showSubtitle) ? 0 : 12,
|
|
313
|
+
bottomTrailingRadius: (showTitle || showSubtitle) ? 0 : 12
|
|
314
|
+
)
|
|
315
|
+
.stroke(showFocusBorder && isFocused ? accentColor : Color.clear, lineWidth: 4)
|
|
253
316
|
)
|
|
254
317
|
|
|
255
318
|
if showTitle || showSubtitle {
|
|
@@ -266,10 +329,11 @@ struct SearchResultCard: View {
|
|
|
266
329
|
if showSubtitle, let subtitle = item.subtitle {
|
|
267
330
|
Text(subtitle)
|
|
268
331
|
.font(.caption)
|
|
269
|
-
.foregroundColor(.secondary)
|
|
332
|
+
.foregroundColor(textColor ?? .secondary)
|
|
270
333
|
.lineLimit(1)
|
|
271
334
|
}
|
|
272
335
|
}
|
|
336
|
+
.padding(cardPadding)
|
|
273
337
|
.frame(width: cardWidth, alignment: .leading)
|
|
274
338
|
}
|
|
275
339
|
}
|
|
@@ -279,9 +343,15 @@ struct SearchResultCard: View {
|
|
|
279
343
|
}
|
|
280
344
|
|
|
281
345
|
private var placeholderIcon: some View {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
346
|
+
ZStack {
|
|
347
|
+
Circle()
|
|
348
|
+
.fill(Color.white.opacity(0.1))
|
|
349
|
+
.frame(width: 120, height: 120)
|
|
350
|
+
|
|
351
|
+
Image(systemName: "photo")
|
|
352
|
+
.font(.system(size: 60, weight: .light))
|
|
353
|
+
.foregroundColor(.white.opacity(0.7))
|
|
354
|
+
}
|
|
285
355
|
}
|
|
286
356
|
}
|
|
287
357
|
|
|
@@ -373,8 +443,69 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
373
443
|
}
|
|
374
444
|
}
|
|
375
445
|
|
|
446
|
+
var textColor: String? = nil {
|
|
447
|
+
didSet {
|
|
448
|
+
if let hexColor = textColor {
|
|
449
|
+
viewModel.textColor = Color(hex: hexColor)
|
|
450
|
+
} else {
|
|
451
|
+
viewModel.textColor = nil
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
var accentColor: String = "#FFC312" {
|
|
457
|
+
didSet {
|
|
458
|
+
viewModel.accentColor = Color(hex: accentColor) ?? Color(red: 1, green: 0.765, blue: 0.07)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
var cardWidth: CGFloat = 280 {
|
|
463
|
+
didSet {
|
|
464
|
+
viewModel.cardWidth = cardWidth
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
var cardHeight: CGFloat = 420 {
|
|
469
|
+
didSet {
|
|
470
|
+
viewModel.cardHeight = cardHeight
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
var imageContentMode: String = "fill" {
|
|
475
|
+
didSet {
|
|
476
|
+
switch imageContentMode.lowercased() {
|
|
477
|
+
case "fit":
|
|
478
|
+
viewModel.imageContentMode = .fit
|
|
479
|
+
case "contain":
|
|
480
|
+
viewModel.imageContentMode = .fit // SwiftUI uses .fit for contain
|
|
481
|
+
default:
|
|
482
|
+
viewModel.imageContentMode = .fill
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
var cardMargin: CGFloat = 40 {
|
|
488
|
+
didSet {
|
|
489
|
+
viewModel.cardMargin = cardMargin
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
var cardPadding: CGFloat = 16 {
|
|
494
|
+
didSet {
|
|
495
|
+
viewModel.cardPadding = cardPadding
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
var overlayTitleSize: CGFloat = 20 {
|
|
500
|
+
didSet {
|
|
501
|
+
viewModel.overlayTitleSize = overlayTitleSize
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
376
505
|
let onSearch = EventDispatcher()
|
|
377
506
|
let onSelectItem = EventDispatcher()
|
|
507
|
+
let onError = EventDispatcher()
|
|
508
|
+
let onValidationWarning = EventDispatcher()
|
|
378
509
|
|
|
379
510
|
required init(appContext: AppContext? = nil) {
|
|
380
511
|
super.init(appContext: appContext)
|
|
@@ -417,11 +548,24 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
417
548
|
func updateResults(_ results: [[String: Any]]) {
|
|
418
549
|
var validResults: [SearchResultItem] = []
|
|
419
550
|
var skippedCount = 0
|
|
551
|
+
var urlValidationFailures = 0
|
|
552
|
+
var truncatedFields = 0
|
|
420
553
|
|
|
421
|
-
for dict in results {
|
|
422
|
-
|
|
423
|
-
|
|
554
|
+
for (index, dict) in results.enumerated() {
|
|
555
|
+
// Validate required fields
|
|
556
|
+
guard let id = dict["id"] as? String, !id.isEmpty else {
|
|
424
557
|
skippedCount += 1
|
|
558
|
+
#if DEBUG
|
|
559
|
+
print("[expo-tvos-search] Result at index \(index) skipped: missing or empty 'id' field")
|
|
560
|
+
#endif
|
|
561
|
+
continue
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
guard let title = dict["title"] as? String, !title.isEmpty else {
|
|
565
|
+
skippedCount += 1
|
|
566
|
+
#if DEBUG
|
|
567
|
+
print("[expo-tvos-search] Result at index \(index) (id: '\(id)') skipped: missing or empty 'title' field")
|
|
568
|
+
#endif
|
|
425
569
|
continue
|
|
426
570
|
}
|
|
427
571
|
|
|
@@ -433,6 +577,11 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
433
577
|
let scheme = url.scheme?.lowercased(),
|
|
434
578
|
scheme == "http" || scheme == "https" {
|
|
435
579
|
validatedImageUrl = imageUrl
|
|
580
|
+
} else {
|
|
581
|
+
urlValidationFailures += 1
|
|
582
|
+
#if DEBUG
|
|
583
|
+
print("[expo-tvos-search] Result '\(title)' (id: '\(id)'): invalid imageUrl '\(imageUrl)'. Only HTTP/HTTPS URLs are supported for security reasons.")
|
|
584
|
+
#endif
|
|
436
585
|
}
|
|
437
586
|
}
|
|
438
587
|
|
|
@@ -441,21 +590,126 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
441
590
|
let maxTitleLength = 500
|
|
442
591
|
let maxSubtitleLength = 500
|
|
443
592
|
|
|
593
|
+
// Track if any fields were truncated
|
|
594
|
+
let idTruncated = id.count > maxIdLength
|
|
595
|
+
let titleTruncated = title.count > maxTitleLength
|
|
596
|
+
let subtitle = dict["subtitle"] as? String
|
|
597
|
+
let subtitleTruncated = (subtitle?.count ?? 0) > maxSubtitleLength
|
|
598
|
+
|
|
599
|
+
if idTruncated || titleTruncated || subtitleTruncated {
|
|
600
|
+
truncatedFields += 1
|
|
601
|
+
#if DEBUG
|
|
602
|
+
var truncatedList: [String] = []
|
|
603
|
+
if idTruncated { truncatedList.append("id (\(id.count) chars)") }
|
|
604
|
+
if titleTruncated { truncatedList.append("title (\(title.count) chars)") }
|
|
605
|
+
if subtitleTruncated { truncatedList.append("subtitle (\(subtitle?.count ?? 0) chars)") }
|
|
606
|
+
print("[expo-tvos-search] Result '\(title)' (id: '\(id)'): truncated fields: \(truncatedList.joined(separator: ", "))")
|
|
607
|
+
#endif
|
|
608
|
+
}
|
|
609
|
+
|
|
444
610
|
validResults.append(SearchResultItem(
|
|
445
611
|
id: String(id.prefix(maxIdLength)),
|
|
446
612
|
title: String(title.prefix(maxTitleLength)),
|
|
447
|
-
subtitle:
|
|
613
|
+
subtitle: subtitle.map { String($0.prefix(maxSubtitleLength)) },
|
|
448
614
|
imageUrl: validatedImageUrl
|
|
449
615
|
))
|
|
450
616
|
}
|
|
451
617
|
|
|
618
|
+
// Log summary of validation issues and emit warnings
|
|
452
619
|
#if DEBUG
|
|
453
620
|
if skippedCount > 0 {
|
|
454
|
-
print("[expo-tvos-search] Skipped \(skippedCount)
|
|
621
|
+
print("[expo-tvos-search] ⚠️ Skipped \(skippedCount) result(s) due to missing required fields (id or title)")
|
|
622
|
+
}
|
|
623
|
+
if urlValidationFailures > 0 {
|
|
624
|
+
print("[expo-tvos-search] ⚠️ \(urlValidationFailures) image URL(s) failed validation (non-HTTP/HTTPS or malformed)")
|
|
625
|
+
}
|
|
626
|
+
if truncatedFields > 0 {
|
|
627
|
+
print("[expo-tvos-search] ℹ️ Truncated \(truncatedFields) result(s) with fields exceeding maximum length (500 chars)")
|
|
628
|
+
}
|
|
629
|
+
if validResults.count > 0 {
|
|
630
|
+
print("[expo-tvos-search] ✓ Processed \(validResults.count) valid result(s)")
|
|
455
631
|
}
|
|
456
632
|
#endif
|
|
457
633
|
|
|
458
|
-
|
|
634
|
+
// Emit validation warnings for production monitoring
|
|
635
|
+
if skippedCount > 0 {
|
|
636
|
+
#if DEBUG
|
|
637
|
+
let skipContext = "validResults=\(validResults.count), skipped=\(skippedCount)"
|
|
638
|
+
#else
|
|
639
|
+
let skipContext = "validation completed"
|
|
640
|
+
#endif
|
|
641
|
+
|
|
642
|
+
onValidationWarning([
|
|
643
|
+
"type": "validation_failed",
|
|
644
|
+
"message": "Skipped \(skippedCount) result(s) due to missing required fields",
|
|
645
|
+
"context": skipContext
|
|
646
|
+
])
|
|
647
|
+
}
|
|
648
|
+
if urlValidationFailures > 0 {
|
|
649
|
+
#if DEBUG
|
|
650
|
+
let urlContext = "Non-HTTP/HTTPS or malformed URLs"
|
|
651
|
+
#else
|
|
652
|
+
let urlContext = "validation completed"
|
|
653
|
+
#endif
|
|
654
|
+
|
|
655
|
+
onValidationWarning([
|
|
656
|
+
"type": "url_invalid",
|
|
657
|
+
"message": "\(urlValidationFailures) image URL(s) failed validation",
|
|
658
|
+
"context": urlContext
|
|
659
|
+
])
|
|
660
|
+
}
|
|
661
|
+
if truncatedFields > 0 {
|
|
662
|
+
#if DEBUG
|
|
663
|
+
let truncContext = "Check id, title, or subtitle field lengths"
|
|
664
|
+
#else
|
|
665
|
+
let truncContext = "validation completed"
|
|
666
|
+
#endif
|
|
667
|
+
|
|
668
|
+
onValidationWarning([
|
|
669
|
+
"type": "field_truncated",
|
|
670
|
+
"message": "Truncated \(truncatedFields) result(s) with fields exceeding 500 characters",
|
|
671
|
+
"context": truncContext
|
|
672
|
+
])
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Ensure UI updates happen on main thread
|
|
676
|
+
DispatchQueue.main.async { [weak self] in
|
|
677
|
+
self?.viewModel.results = validResults
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// MARK: - Color Extension for Hex String Parsing
|
|
683
|
+
extension Color {
|
|
684
|
+
/// Initialize a Color from a hex string (e.g., "#FFFFFF", "#FF5733", "FFC312")
|
|
685
|
+
/// Returns nil if the string cannot be parsed as a valid hex color
|
|
686
|
+
init?(hex: String) {
|
|
687
|
+
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
|
688
|
+
var int: UInt64 = 0
|
|
689
|
+
|
|
690
|
+
guard Scanner(string: hex).scanHexInt64(&int) else {
|
|
691
|
+
return nil
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
let a, r, g, b: UInt64
|
|
695
|
+
switch hex.count {
|
|
696
|
+
case 3: // RGB (12-bit)
|
|
697
|
+
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
|
698
|
+
case 6: // RGB (24-bit)
|
|
699
|
+
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
|
700
|
+
case 8: // ARGB (32-bit)
|
|
701
|
+
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
|
702
|
+
default:
|
|
703
|
+
return nil
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
self.init(
|
|
707
|
+
.sRGB,
|
|
708
|
+
red: Double(r) / 255,
|
|
709
|
+
green: Double(g) / 255,
|
|
710
|
+
blue: Double(b) / 255,
|
|
711
|
+
opacity: Double(a) / 255
|
|
712
|
+
)
|
|
459
713
|
}
|
|
460
714
|
}
|
|
461
715
|
|
|
@@ -477,9 +731,19 @@ class ExpoTvosSearchView: ExpoView {
|
|
|
477
731
|
var searchingText: String = "Searching..."
|
|
478
732
|
var noResultsText: String = "No results found"
|
|
479
733
|
var noResultsHintText: String = "Try a different search term"
|
|
734
|
+
var textColor: String? = nil
|
|
735
|
+
var accentColor: String = "#FFC312"
|
|
736
|
+
var cardWidth: CGFloat = 280
|
|
737
|
+
var cardHeight: CGFloat = 420
|
|
738
|
+
var imageContentMode: String = "fill"
|
|
739
|
+
var cardMargin: CGFloat = 40
|
|
740
|
+
var cardPadding: CGFloat = 16
|
|
741
|
+
var overlayTitleSize: CGFloat = 20
|
|
480
742
|
|
|
481
743
|
let onSearch = EventDispatcher()
|
|
482
744
|
let onSelectItem = EventDispatcher()
|
|
745
|
+
let onError = EventDispatcher()
|
|
746
|
+
let onValidationWarning = EventDispatcher()
|
|
483
747
|
|
|
484
748
|
required init(appContext: AppContext? = nil) {
|
|
485
749
|
super.init(appContext: appContext)
|
package/ios/MarqueeText.swift
CHANGED
|
@@ -41,7 +41,7 @@ struct MarqueeText: View {
|
|
|
41
41
|
|
|
42
42
|
var body: some View {
|
|
43
43
|
GeometryReader { geometry in
|
|
44
|
-
ZStack(alignment: .leading) {
|
|
44
|
+
ZStack(alignment: Alignment(horizontal: .leading, vertical: .center)) {
|
|
45
45
|
// Hidden text to measure actual width
|
|
46
46
|
Text(text)
|
|
47
47
|
.font(font)
|
|
@@ -69,7 +69,7 @@ struct MarqueeText: View {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
-
.frame(width: geometry.size.width, alignment: .leading)
|
|
72
|
+
.frame(width: geometry.size.width, height: geometry.size.height, alignment: Alignment(horizontal: .leading, vertical: .center))
|
|
73
73
|
.clipped()
|
|
74
74
|
.mask(fadeMask)
|
|
75
75
|
.onPreferenceChange(TextWidthKey.self) { width in
|