nn-widgets 0.1.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 +577 -0
- package/android/build.gradle +90 -0
- package/app.plugin.js +4 -0
- package/build/NNWidgets.types.d.ts +113 -0
- package/build/NNWidgets.types.d.ts.map +1 -0
- package/build/NNWidgets.types.js +2 -0
- package/build/NNWidgets.types.js.map +1 -0
- package/build/NNWidgetsModule.d.ts +3 -0
- package/build/NNWidgetsModule.d.ts.map +1 -0
- package/build/NNWidgetsModule.js +3 -0
- package/build/NNWidgetsModule.js.map +1 -0
- package/build/index.d.ts +11 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +152 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/NNWidgets.podspec +27 -0
- package/ios/NNWidgetsModule.swift +97 -0
- package/package.json +49 -0
- package/plugin/build/index.d.ts +9 -0
- package/plugin/build/index.d.ts.map +1 -0
- package/plugin/build/index.js +70 -0
- package/plugin/build/types.d.ts +353 -0
- package/plugin/build/types.d.ts.map +1 -0
- package/plugin/build/types.js +2 -0
- package/plugin/build/withAndroidWidget.d.ts +5 -0
- package/plugin/build/withAndroidWidget.d.ts.map +1 -0
- package/plugin/build/withAndroidWidget.js +700 -0
- package/plugin/build/withIosWidget.d.ts +6 -0
- package/plugin/build/withIosWidget.d.ts.map +1 -0
- package/plugin/build/withIosWidget.js +1589 -0
- package/plugin/tsconfig.tsbuildinfo +1 -0
- package/publish.sh +59 -0
|
@@ -0,0 +1,1589 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.withIosWidget = void 0;
|
|
37
|
+
exports.resolveIosProps = resolveIosProps;
|
|
38
|
+
const config_plugins_1 = require("@expo/config-plugins");
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
// ──────────────────────────────────────────────
|
|
42
|
+
// Swift code generation helpers
|
|
43
|
+
// ──────────────────────────────────────────────
|
|
44
|
+
function generateDataProviderCode(props, bundleId) {
|
|
45
|
+
const useAppGroups = props.ios.useAppGroups;
|
|
46
|
+
const appGroupId = props.ios.appGroupIdentifier || `group.${bundleId}`;
|
|
47
|
+
const hasListWidget = props.widgets.some((w) => w.type === "list");
|
|
48
|
+
const hasSingleWidget = props.widgets.some((w) => w.type === "single");
|
|
49
|
+
const storageCode = useAppGroups
|
|
50
|
+
? `
|
|
51
|
+
struct WidgetDataProvider {
|
|
52
|
+
static let appGroupIdentifier = "${appGroupId}"
|
|
53
|
+
|
|
54
|
+
static var userDefaults: UserDefaults? {
|
|
55
|
+
return UserDefaults(suiteName: appGroupIdentifier)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static func getValue<T>(forKey key: String) -> T? {
|
|
59
|
+
return userDefaults?.object(forKey: "widget_\\(key)") as? T
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static func getString(forKey key: String, default defaultValue: String = "") -> String {
|
|
63
|
+
return getValue(forKey: key) ?? defaultValue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static func getInt(forKey key: String, default defaultValue: Int = 0) -> Int {
|
|
67
|
+
return getValue(forKey: key) ?? defaultValue
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static func getBool(forKey key: String, default defaultValue: Bool = false) -> Bool {
|
|
71
|
+
return getValue(forKey: key) ?? defaultValue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static func getJSON(forKey key: String) -> Any? {
|
|
75
|
+
guard let jsonString: String = getValue(forKey: key),
|
|
76
|
+
let data = jsonString.data(using: .utf8) else { return nil }
|
|
77
|
+
return try? JSONSerialization.jsonObject(with: data)
|
|
78
|
+
}
|
|
79
|
+
}`
|
|
80
|
+
: `
|
|
81
|
+
// Note: App Groups is disabled. Widget cannot share data with main app.
|
|
82
|
+
// To enable data sharing, set ios.useAppGroups: true and configure App Groups in Apple Developer Portal.
|
|
83
|
+
struct WidgetDataProvider {
|
|
84
|
+
static var userDefaults: UserDefaults {
|
|
85
|
+
return UserDefaults.standard
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static func getValue<T>(forKey key: String) -> T? {
|
|
89
|
+
return userDefaults.object(forKey: "widget_\\(key)") as? T
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
static func getString(forKey key: String, default defaultValue: String = "") -> String {
|
|
93
|
+
return getValue(forKey: key) ?? defaultValue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
static func getInt(forKey key: String, default defaultValue: Int = 0) -> Int {
|
|
97
|
+
return getValue(forKey: key) ?? defaultValue
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
static func getBool(forKey key: String, default defaultValue: Bool = false) -> Bool {
|
|
101
|
+
return getValue(forKey: key) ?? defaultValue
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
static func getJSON(forKey key: String) -> Any? {
|
|
105
|
+
guard let jsonString: String = getValue(forKey: key),
|
|
106
|
+
let data = jsonString.data(using: .utf8) else { return nil }
|
|
107
|
+
return try? JSONSerialization.jsonObject(with: data)
|
|
108
|
+
}
|
|
109
|
+
}`;
|
|
110
|
+
// ── List item model (shared across list-type widgets) ──
|
|
111
|
+
const listItemModel = hasListWidget || hasSingleWidget
|
|
112
|
+
? `
|
|
113
|
+
|
|
114
|
+
// MARK: - Widget Item Models
|
|
115
|
+
|
|
116
|
+
struct WidgetIconConfig {
|
|
117
|
+
let url: String
|
|
118
|
+
let size: CGFloat
|
|
119
|
+
let radius: CGFloat
|
|
120
|
+
let backgroundColor: String?
|
|
121
|
+
|
|
122
|
+
init(from dict: [String: Any]) {
|
|
123
|
+
self.url = dict["url"] as? String ?? ""
|
|
124
|
+
self.size = CGFloat(dict["size"] as? Double ?? 32)
|
|
125
|
+
self.radius = CGFloat(dict["radius"] as? Double ?? 0)
|
|
126
|
+
self.backgroundColor = dict["backgroundColor"] as? String
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
init(url: String) {
|
|
130
|
+
self.url = url
|
|
131
|
+
self.size = 32
|
|
132
|
+
self.radius = 0
|
|
133
|
+
self.backgroundColor = nil
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
struct WidgetTextConfig {
|
|
138
|
+
let text: String
|
|
139
|
+
let color: String?
|
|
140
|
+
let fontSize: String?
|
|
141
|
+
let fontWeight: String?
|
|
142
|
+
|
|
143
|
+
init(from dict: [String: Any]) {
|
|
144
|
+
self.text = dict["text"] as? String ?? ""
|
|
145
|
+
self.color = dict["color"] as? String
|
|
146
|
+
self.fontSize = dict["fontSize"] as? String
|
|
147
|
+
self.fontWeight = dict["fontWeight"] as? String
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
init(text: String) {
|
|
151
|
+
self.text = text
|
|
152
|
+
self.color = nil
|
|
153
|
+
self.fontSize = nil
|
|
154
|
+
self.fontWeight = nil
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func resolvedFont(default defaultSize: Font = .body) -> Font {
|
|
158
|
+
guard let fs = fontSize else { return defaultSize }
|
|
159
|
+
switch fs {
|
|
160
|
+
case "caption2": return .caption2
|
|
161
|
+
case "caption": return .caption
|
|
162
|
+
case "footnote": return .footnote
|
|
163
|
+
case "subheadline": return .subheadline
|
|
164
|
+
case "body": return .body
|
|
165
|
+
case "headline": return .headline
|
|
166
|
+
case "title3": return .title3
|
|
167
|
+
case "title2": return .title2
|
|
168
|
+
case "title": return .title
|
|
169
|
+
default: return defaultSize
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
func resolvedWeight(default defaultWeight: Font.Weight = .regular) -> Font.Weight {
|
|
174
|
+
guard let fw = fontWeight else { return defaultWeight }
|
|
175
|
+
switch fw {
|
|
176
|
+
case "regular": return .regular
|
|
177
|
+
case "medium": return .medium
|
|
178
|
+
case "semibold": return .semibold
|
|
179
|
+
case "bold": return .bold
|
|
180
|
+
default: return defaultWeight
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
struct WidgetListItem: Identifiable {
|
|
186
|
+
let id: String
|
|
187
|
+
let icon: WidgetIconConfig?
|
|
188
|
+
let rightIcon: WidgetIconConfig?
|
|
189
|
+
let title: WidgetTextConfig
|
|
190
|
+
let description: WidgetTextConfig?
|
|
191
|
+
let deepLink: String?
|
|
192
|
+
|
|
193
|
+
init(from dict: [String: Any], index: Int) {
|
|
194
|
+
self.id = "item_\\(index)"
|
|
195
|
+
|
|
196
|
+
// Parse icon
|
|
197
|
+
if let iconDict = dict["icon"] as? [String: Any] {
|
|
198
|
+
self.icon = WidgetIconConfig(from: iconDict)
|
|
199
|
+
} else if let iconStr = dict["icon"] as? String {
|
|
200
|
+
self.icon = WidgetIconConfig(url: iconStr)
|
|
201
|
+
} else {
|
|
202
|
+
self.icon = nil
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Parse rightIcon
|
|
206
|
+
if let iconDict = dict["rightIcon"] as? [String: Any] {
|
|
207
|
+
self.rightIcon = WidgetIconConfig(from: iconDict)
|
|
208
|
+
} else if let iconStr = dict["rightIcon"] as? String {
|
|
209
|
+
self.rightIcon = WidgetIconConfig(url: iconStr)
|
|
210
|
+
} else {
|
|
211
|
+
self.rightIcon = nil
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Parse title
|
|
215
|
+
if let titleDict = dict["title"] as? [String: Any] {
|
|
216
|
+
self.title = WidgetTextConfig(from: titleDict)
|
|
217
|
+
} else if let titleStr = dict["title"] as? String {
|
|
218
|
+
self.title = WidgetTextConfig(text: titleStr)
|
|
219
|
+
} else {
|
|
220
|
+
self.title = WidgetTextConfig(text: "")
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Parse description
|
|
224
|
+
if let descDict = dict["description"] as? [String: Any] {
|
|
225
|
+
self.description = WidgetTextConfig(from: descDict)
|
|
226
|
+
} else if let descStr = dict["description"] as? String {
|
|
227
|
+
self.description = WidgetTextConfig(text: descStr)
|
|
228
|
+
} else {
|
|
229
|
+
self.description = nil
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
self.deepLink = dict["deepLink"] as? String
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// MARK: - Icon View Helper
|
|
237
|
+
|
|
238
|
+
struct WidgetIconView: View {
|
|
239
|
+
let config: WidgetIconConfig
|
|
240
|
+
let accentColor: Color
|
|
241
|
+
|
|
242
|
+
var body: some View {
|
|
243
|
+
ZStack {
|
|
244
|
+
if let bg = config.backgroundColor {
|
|
245
|
+
RoundedRectangle(cornerRadius: config.radius)
|
|
246
|
+
.fill(Color(hex: bg))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if UIImage(named: config.url) != nil {
|
|
250
|
+
Image(config.url)
|
|
251
|
+
.resizable()
|
|
252
|
+
.aspectRatio(contentMode: .fit)
|
|
253
|
+
.padding(config.size * 0.15)
|
|
254
|
+
} else {
|
|
255
|
+
Image(systemName: config.url)
|
|
256
|
+
.font(.system(size: config.size * 0.5))
|
|
257
|
+
.foregroundColor(
|
|
258
|
+
config.backgroundColor != nil ? .white : accentColor
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
.frame(width: config.size, height: config.size)
|
|
263
|
+
.clipShape(RoundedRectangle(cornerRadius: config.radius))
|
|
264
|
+
}
|
|
265
|
+
}`
|
|
266
|
+
: "";
|
|
267
|
+
return `${storageCode}
|
|
268
|
+
${listItemModel}`;
|
|
269
|
+
}
|
|
270
|
+
/** Generate the TimelineEntry, TimelineProvider, Views, and Widget struct for one widget */
|
|
271
|
+
function generateSingleWidgetCode(w) {
|
|
272
|
+
const deepLinkLine = w.deepLinkUrl
|
|
273
|
+
? `private let deepLinkUrl = URL(string: "${w.deepLinkUrl}")`
|
|
274
|
+
: `private let deepLinkUrl: URL? = nil`;
|
|
275
|
+
// ── Image-only mode ──
|
|
276
|
+
if (w.type === "image") {
|
|
277
|
+
return generateImageWidgetCode(w, deepLinkLine);
|
|
278
|
+
}
|
|
279
|
+
// ── List mode ──
|
|
280
|
+
if (w.type === "list") {
|
|
281
|
+
return generateListWidgetCode(w, deepLinkLine);
|
|
282
|
+
}
|
|
283
|
+
// ── Grid mode ──
|
|
284
|
+
if (w.type === "grid") {
|
|
285
|
+
return generateGridWidgetCode(w, deepLinkLine);
|
|
286
|
+
}
|
|
287
|
+
// ── Single mode (default) ──
|
|
288
|
+
return generateSingleTypeWidgetCode(w, deepLinkLine);
|
|
289
|
+
}
|
|
290
|
+
function generateImageWidgetCode(w, deepLinkLine) {
|
|
291
|
+
const hasImage = w.image && (w.image.small || w.image.medium || w.image.large);
|
|
292
|
+
const smallImage = w.image?.small
|
|
293
|
+
? `"${w.name}_small"`
|
|
294
|
+
: w.image?.medium
|
|
295
|
+
? `"${w.name}_medium"`
|
|
296
|
+
: `"${w.name}_large"`;
|
|
297
|
+
const mediumImage = w.image?.medium
|
|
298
|
+
? `"${w.name}_medium"`
|
|
299
|
+
: w.image?.large
|
|
300
|
+
? `"${w.name}_large"`
|
|
301
|
+
: smallImage;
|
|
302
|
+
const largeImage = w.image?.large ? `"${w.name}_large"` : mediumImage;
|
|
303
|
+
return `
|
|
304
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
305
|
+
// MARK: - ${w.name} (Image)
|
|
306
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
307
|
+
|
|
308
|
+
struct ${w.name}Entry: TimelineEntry {
|
|
309
|
+
let date: Date
|
|
310
|
+
static var placeholder: ${w.name}Entry { ${w.name}Entry(date: Date()) }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
struct ${w.name}Provider: TimelineProvider {
|
|
314
|
+
func placeholder(in context: Context) -> ${w.name}Entry { .placeholder }
|
|
315
|
+
func getSnapshot(in context: Context, completion: @escaping (${w.name}Entry) -> Void) { completion(.placeholder) }
|
|
316
|
+
func getTimeline(in context: Context, completion: @escaping (Timeline<${w.name}Entry>) -> Void) {
|
|
317
|
+
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
|
|
318
|
+
completion(Timeline(entries: [.placeholder], policy: .after(nextUpdate)))
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
struct ${w.name}EntryView: View {
|
|
323
|
+
@Environment(\\.widgetFamily) var family
|
|
324
|
+
let entry: ${w.name}Entry
|
|
325
|
+
|
|
326
|
+
var body: some View {
|
|
327
|
+
switch family {
|
|
328
|
+
case .systemSmall:
|
|
329
|
+
Image(${smallImage})
|
|
330
|
+
.resizable()
|
|
331
|
+
.aspectRatio(contentMode: .fill)
|
|
332
|
+
case .systemMedium:
|
|
333
|
+
Image(${mediumImage})
|
|
334
|
+
.resizable()
|
|
335
|
+
.aspectRatio(contentMode: .fill)
|
|
336
|
+
case .systemLarge, .systemExtraLarge:
|
|
337
|
+
Image(${largeImage})
|
|
338
|
+
.resizable()
|
|
339
|
+
.aspectRatio(contentMode: .fill)
|
|
340
|
+
default:
|
|
341
|
+
Image(${smallImage})
|
|
342
|
+
.resizable()
|
|
343
|
+
.aspectRatio(contentMode: .fill)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
struct ${w.name}: Widget {
|
|
349
|
+
let kind: String = "${w.name}"
|
|
350
|
+
${deepLinkLine}
|
|
351
|
+
|
|
352
|
+
var body: some WidgetConfiguration {
|
|
353
|
+
StaticConfiguration(kind: kind, provider: ${w.name}Provider()) { entry in
|
|
354
|
+
if #available(iOS 17.0, *) {
|
|
355
|
+
${w.name}EntryView(entry: entry)
|
|
356
|
+
.containerBackground(.clear, for: .widget)
|
|
357
|
+
.widgetURL(deepLinkUrl)
|
|
358
|
+
} else {
|
|
359
|
+
${w.name}EntryView(entry: entry)
|
|
360
|
+
.widgetURL(deepLinkUrl)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
.configurationDisplayName("${w.displayName}")
|
|
364
|
+
.description("${w.description}")
|
|
365
|
+
.supportedFamilies([${w.widgetFamilies.map((f) => `.${f}`).join(", ")}])
|
|
366
|
+
.contentMarginsDisabled()
|
|
367
|
+
}
|
|
368
|
+
}`;
|
|
369
|
+
}
|
|
370
|
+
function generateListWidgetCode(w, deepLinkLine) {
|
|
371
|
+
const bgColor = w.style?.backgroundColor
|
|
372
|
+
? `Color(hex: "${w.style.backgroundColor}")`
|
|
373
|
+
: ".clear";
|
|
374
|
+
const titleColor = w.style?.titleColor
|
|
375
|
+
? `Color(hex: "${w.style.titleColor}")`
|
|
376
|
+
: ".primary";
|
|
377
|
+
const subtitleColor = w.style?.subtitleColor
|
|
378
|
+
? `Color(hex: "${w.style.subtitleColor}")`
|
|
379
|
+
: ".secondary";
|
|
380
|
+
const accentColor = w.style?.accentColor
|
|
381
|
+
? `Color(hex: "${w.style.accentColor}")`
|
|
382
|
+
: ".accentColor";
|
|
383
|
+
return `
|
|
384
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
385
|
+
// MARK: - ${w.name} (List)
|
|
386
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
387
|
+
|
|
388
|
+
struct ${w.name}Entry: TimelineEntry {
|
|
389
|
+
let date: Date
|
|
390
|
+
let items: [WidgetListItem]
|
|
391
|
+
let fallbackTitle: String
|
|
392
|
+
let fallbackSubtitle: String
|
|
393
|
+
|
|
394
|
+
static var placeholder: ${w.name}Entry {
|
|
395
|
+
${w.name}Entry(
|
|
396
|
+
date: Date(),
|
|
397
|
+
items: [],
|
|
398
|
+
fallbackTitle: "${w.fallbackTitle}",
|
|
399
|
+
fallbackSubtitle: "${w.fallbackSubtitle}"
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
struct ${w.name}Provider: TimelineProvider {
|
|
405
|
+
func placeholder(in context: Context) -> ${w.name}Entry { .placeholder }
|
|
406
|
+
|
|
407
|
+
func getSnapshot(in context: Context, completion: @escaping (${w.name}Entry) -> Void) {
|
|
408
|
+
completion(createEntry())
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
func getTimeline(in context: Context, completion: @escaping (Timeline<${w.name}Entry>) -> Void) {
|
|
412
|
+
let entry = createEntry()
|
|
413
|
+
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
|
|
414
|
+
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private func createEntry() -> ${w.name}Entry {
|
|
418
|
+
// Read items JSON from shared UserDefaults
|
|
419
|
+
var items: [WidgetListItem] = []
|
|
420
|
+
if let rawItems = WidgetDataProvider.getJSON(forKey: "${w.name}_items") as? [[String: Any]] {
|
|
421
|
+
items = rawItems.enumerated().map { WidgetListItem(from: $0.element, index: $0.offset) }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return ${w.name}Entry(
|
|
425
|
+
date: Date(),
|
|
426
|
+
items: items,
|
|
427
|
+
fallbackTitle: WidgetDataProvider.getString(forKey: "${w.name}_title", default: "${w.fallbackTitle}"),
|
|
428
|
+
fallbackSubtitle: WidgetDataProvider.getString(forKey: "${w.name}_subtitle", default: "${w.fallbackSubtitle}")
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// MARK: ${w.name} List Item Row
|
|
434
|
+
|
|
435
|
+
struct ${w.name}ItemRow: View {
|
|
436
|
+
let item: WidgetListItem
|
|
437
|
+
let defaultTitleColor: Color
|
|
438
|
+
let defaultDescColor: Color
|
|
439
|
+
let defaultAccentColor: Color
|
|
440
|
+
|
|
441
|
+
var body: some View {
|
|
442
|
+
HStack(spacing: 12) {
|
|
443
|
+
if let icon = item.icon {
|
|
444
|
+
WidgetIconView(config: icon, accentColor: defaultAccentColor)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
448
|
+
Text(item.title.text)
|
|
449
|
+
.font(item.title.resolvedFont(default: .subheadline))
|
|
450
|
+
.fontWeight(item.title.resolvedWeight(default: .semibold))
|
|
451
|
+
.foregroundColor(
|
|
452
|
+
item.title.color != nil
|
|
453
|
+
? Color(hex: item.title.color!)
|
|
454
|
+
: defaultTitleColor
|
|
455
|
+
)
|
|
456
|
+
.lineLimit(1)
|
|
457
|
+
.truncationMode(.tail)
|
|
458
|
+
|
|
459
|
+
if let desc = item.description, !desc.text.isEmpty {
|
|
460
|
+
Text(desc.text)
|
|
461
|
+
.font(desc.resolvedFont(default: .caption))
|
|
462
|
+
.fontWeight(desc.resolvedWeight(default: .regular))
|
|
463
|
+
.foregroundColor(
|
|
464
|
+
desc.color != nil
|
|
465
|
+
? Color(hex: desc.color!)
|
|
466
|
+
: defaultDescColor
|
|
467
|
+
)
|
|
468
|
+
.lineLimit(1)
|
|
469
|
+
.truncationMode(.tail)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
Spacer()
|
|
474
|
+
|
|
475
|
+
if let rightIcon = item.rightIcon {
|
|
476
|
+
WidgetIconView(config: rightIcon, accentColor: defaultAccentColor)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// MARK: ${w.name} Views
|
|
483
|
+
|
|
484
|
+
struct ${w.name}EntryView: View {
|
|
485
|
+
@Environment(\\.widgetFamily) var family
|
|
486
|
+
let entry: ${w.name}Entry
|
|
487
|
+
|
|
488
|
+
private let titleColor: Color = ${titleColor}
|
|
489
|
+
private let subtitleColor: Color = ${subtitleColor}
|
|
490
|
+
private let accentColor: Color = ${accentColor}
|
|
491
|
+
|
|
492
|
+
var body: some View {
|
|
493
|
+
if entry.items.isEmpty {
|
|
494
|
+
// Fallback: no data set yet
|
|
495
|
+
VStack(spacing: 8) {${w.showAppIcon
|
|
496
|
+
? `
|
|
497
|
+
Image("AppIcon")
|
|
498
|
+
.resizable()
|
|
499
|
+
.frame(width: 40, height: 40)
|
|
500
|
+
.cornerRadius(10)`
|
|
501
|
+
: ""}
|
|
502
|
+
Text(entry.fallbackTitle)
|
|
503
|
+
.font(.headline)
|
|
504
|
+
.foregroundColor(titleColor)
|
|
505
|
+
Text(entry.fallbackSubtitle)
|
|
506
|
+
.font(.caption)
|
|
507
|
+
.foregroundColor(subtitleColor)
|
|
508
|
+
}
|
|
509
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
510
|
+
.background(${bgColor})
|
|
511
|
+
} else {
|
|
512
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
513
|
+
ForEach(entry.items.prefix(maxItems)) { item in
|
|
514
|
+
if let deepLink = item.deepLink, let url = URL(string: deepLink) {
|
|
515
|
+
Link(destination: url) {
|
|
516
|
+
${w.name}ItemRow(
|
|
517
|
+
item: item,
|
|
518
|
+
defaultTitleColor: titleColor,
|
|
519
|
+
defaultDescColor: subtitleColor,
|
|
520
|
+
defaultAccentColor: accentColor
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
} else {
|
|
524
|
+
${w.name}ItemRow(
|
|
525
|
+
item: item,
|
|
526
|
+
defaultTitleColor: titleColor,
|
|
527
|
+
defaultDescColor: subtitleColor,
|
|
528
|
+
defaultAccentColor: accentColor
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
.padding()
|
|
534
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
535
|
+
.background(${bgColor})
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private var maxItems: Int {
|
|
540
|
+
switch family {
|
|
541
|
+
case .systemSmall: return 3
|
|
542
|
+
case .systemMedium: return 3
|
|
543
|
+
case .systemLarge, .systemExtraLarge: return 7
|
|
544
|
+
default: return 3
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
struct ${w.name}: Widget {
|
|
550
|
+
let kind: String = "${w.name}"
|
|
551
|
+
${deepLinkLine}
|
|
552
|
+
|
|
553
|
+
var body: some WidgetConfiguration {
|
|
554
|
+
StaticConfiguration(kind: kind, provider: ${w.name}Provider()) { entry in
|
|
555
|
+
if #available(iOS 17.0, *) {
|
|
556
|
+
${w.name}EntryView(entry: entry)
|
|
557
|
+
.containerBackground(${bgColor}, for: .widget)
|
|
558
|
+
.widgetURL(deepLinkUrl)
|
|
559
|
+
} else {
|
|
560
|
+
${w.name}EntryView(entry: entry)
|
|
561
|
+
.padding()
|
|
562
|
+
.background(${bgColor})
|
|
563
|
+
.widgetURL(deepLinkUrl)
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
.configurationDisplayName("${w.displayName}")
|
|
567
|
+
.description("${w.description}")
|
|
568
|
+
.supportedFamilies([${w.widgetFamilies.map((f) => `.${f}`).join(", ")}])
|
|
569
|
+
}
|
|
570
|
+
}`;
|
|
571
|
+
}
|
|
572
|
+
function generateGridWidgetCode(w, deepLinkLine) {
|
|
573
|
+
// Parse gridLayout (e.g. "3x2" → 3 columns, 2 rows)
|
|
574
|
+
let gridCols = null;
|
|
575
|
+
let gridRows = null;
|
|
576
|
+
if (w.gridLayout) {
|
|
577
|
+
const match = w.gridLayout.match(/^(\d+)x(\d+)$/);
|
|
578
|
+
if (match) {
|
|
579
|
+
gridCols = parseInt(match[1], 10);
|
|
580
|
+
gridRows = parseInt(match[2], 10);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const bgColor = w.style?.backgroundColor
|
|
584
|
+
? `Color(hex: "${w.style.backgroundColor}")`
|
|
585
|
+
: ".clear";
|
|
586
|
+
const titleColor = w.style?.titleColor
|
|
587
|
+
? `Color(hex: "${w.style.titleColor}")`
|
|
588
|
+
: ".primary";
|
|
589
|
+
const subtitleColor = w.style?.subtitleColor
|
|
590
|
+
? `Color(hex: "${w.style.subtitleColor}")`
|
|
591
|
+
: ".secondary";
|
|
592
|
+
const accentColor = w.style?.accentColor
|
|
593
|
+
? `Color(hex: "${w.style.accentColor}")`
|
|
594
|
+
: ".accentColor";
|
|
595
|
+
return `
|
|
596
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
597
|
+
// MARK: - ${w.name} (Grid)
|
|
598
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
599
|
+
|
|
600
|
+
struct ${w.name}Entry: TimelineEntry {
|
|
601
|
+
let date: Date
|
|
602
|
+
let items: [WidgetListItem]
|
|
603
|
+
let fallbackTitle: String
|
|
604
|
+
let fallbackSubtitle: String
|
|
605
|
+
|
|
606
|
+
static var placeholder: ${w.name}Entry {
|
|
607
|
+
${w.name}Entry(
|
|
608
|
+
date: Date(),
|
|
609
|
+
items: [],
|
|
610
|
+
fallbackTitle: "${w.fallbackTitle}",
|
|
611
|
+
fallbackSubtitle: "${w.fallbackSubtitle}"
|
|
612
|
+
)
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
struct ${w.name}Provider: TimelineProvider {
|
|
617
|
+
func placeholder(in context: Context) -> ${w.name}Entry { .placeholder }
|
|
618
|
+
|
|
619
|
+
func getSnapshot(in context: Context, completion: @escaping (${w.name}Entry) -> Void) {
|
|
620
|
+
completion(createEntry())
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
func getTimeline(in context: Context, completion: @escaping (Timeline<${w.name}Entry>) -> Void) {
|
|
624
|
+
let entry = createEntry()
|
|
625
|
+
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
|
|
626
|
+
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private func createEntry() -> ${w.name}Entry {
|
|
630
|
+
var items: [WidgetListItem] = []
|
|
631
|
+
if let rawItems = WidgetDataProvider.getJSON(forKey: "${w.name}_items") as? [[String: Any]] {
|
|
632
|
+
items = rawItems.enumerated().map { WidgetListItem(from: $0.element, index: $0.offset) }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return ${w.name}Entry(
|
|
636
|
+
date: Date(),
|
|
637
|
+
items: items,
|
|
638
|
+
fallbackTitle: WidgetDataProvider.getString(forKey: "${w.name}_title", default: "${w.fallbackTitle}"),
|
|
639
|
+
fallbackSubtitle: WidgetDataProvider.getString(forKey: "${w.name}_subtitle", default: "${w.fallbackSubtitle}")
|
|
640
|
+
)
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// MARK: ${w.name} Grid Item Cell
|
|
645
|
+
|
|
646
|
+
struct ${w.name}GridCell: View {
|
|
647
|
+
let item: WidgetListItem
|
|
648
|
+
let defaultTitleColor: Color
|
|
649
|
+
let defaultAccentColor: Color
|
|
650
|
+
|
|
651
|
+
var body: some View {
|
|
652
|
+
VStack(spacing: 6) {
|
|
653
|
+
if let icon = item.icon {
|
|
654
|
+
WidgetIconView(config: icon, accentColor: defaultAccentColor)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
Text(item.title.text)
|
|
658
|
+
.font(item.title.resolvedFont(default: .caption))
|
|
659
|
+
.fontWeight(item.title.resolvedWeight(default: .medium))
|
|
660
|
+
.foregroundColor(
|
|
661
|
+
item.title.color != nil
|
|
662
|
+
? Color(hex: item.title.color!)
|
|
663
|
+
: defaultTitleColor
|
|
664
|
+
)
|
|
665
|
+
.lineLimit(1)
|
|
666
|
+
.truncationMode(.tail)
|
|
667
|
+
}
|
|
668
|
+
.frame(maxWidth: .infinity)
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// MARK: ${w.name} Views
|
|
673
|
+
|
|
674
|
+
struct ${w.name}EntryView: View {
|
|
675
|
+
@Environment(\\.widgetFamily) var family
|
|
676
|
+
let entry: ${w.name}Entry
|
|
677
|
+
|
|
678
|
+
private let titleColor: Color = ${titleColor}
|
|
679
|
+
private let subtitleColor: Color = ${subtitleColor}
|
|
680
|
+
private let accentColor: Color = ${accentColor}
|
|
681
|
+
|
|
682
|
+
var body: some View {
|
|
683
|
+
if entry.items.isEmpty {
|
|
684
|
+
VStack(spacing: 8) {${w.showAppIcon
|
|
685
|
+
? `
|
|
686
|
+
Image("AppIcon")
|
|
687
|
+
.resizable()
|
|
688
|
+
.frame(width: 40, height: 40)
|
|
689
|
+
.cornerRadius(10)`
|
|
690
|
+
: ""}
|
|
691
|
+
Text(entry.fallbackTitle)
|
|
692
|
+
.font(.headline)
|
|
693
|
+
.foregroundColor(titleColor)
|
|
694
|
+
Text(entry.fallbackSubtitle)
|
|
695
|
+
.font(.caption)
|
|
696
|
+
.foregroundColor(subtitleColor)
|
|
697
|
+
}
|
|
698
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
699
|
+
.background(${bgColor})
|
|
700
|
+
} else {
|
|
701
|
+
let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: gridColumns)
|
|
702
|
+
|
|
703
|
+
LazyVGrid(columns: columns, spacing: 12) {
|
|
704
|
+
ForEach(entry.items.prefix(maxItems)) { item in
|
|
705
|
+
if let deepLink = item.deepLink, let url = URL(string: deepLink) {
|
|
706
|
+
Link(destination: url) {
|
|
707
|
+
${w.name}GridCell(
|
|
708
|
+
item: item,
|
|
709
|
+
defaultTitleColor: titleColor,
|
|
710
|
+
defaultAccentColor: accentColor
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
} else {
|
|
714
|
+
${w.name}GridCell(
|
|
715
|
+
item: item,
|
|
716
|
+
defaultTitleColor: titleColor,
|
|
717
|
+
defaultAccentColor: accentColor
|
|
718
|
+
)
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
.padding()
|
|
723
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
724
|
+
.background(${bgColor})
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
private var gridColumns: Int {
|
|
729
|
+
${gridCols
|
|
730
|
+
? `return ${gridCols}`
|
|
731
|
+
: `switch family {
|
|
732
|
+
case .systemSmall: return 2
|
|
733
|
+
case .systemMedium: return 4
|
|
734
|
+
case .systemLarge, .systemExtraLarge: return 4
|
|
735
|
+
default: return 3
|
|
736
|
+
}`}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
private var maxItems: Int {
|
|
740
|
+
${gridCols && gridRows
|
|
741
|
+
? `return ${gridCols * gridRows}`
|
|
742
|
+
: `switch family {
|
|
743
|
+
case .systemSmall: return 4
|
|
744
|
+
case .systemMedium: return 4
|
|
745
|
+
case .systemLarge, .systemExtraLarge: return 8
|
|
746
|
+
default: return 4
|
|
747
|
+
}`}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
struct ${w.name}: Widget {
|
|
752
|
+
let kind: String = "${w.name}"
|
|
753
|
+
${deepLinkLine}
|
|
754
|
+
|
|
755
|
+
var body: some WidgetConfiguration {
|
|
756
|
+
StaticConfiguration(kind: kind, provider: ${w.name}Provider()) { entry in
|
|
757
|
+
if #available(iOS 17.0, *) {
|
|
758
|
+
${w.name}EntryView(entry: entry)
|
|
759
|
+
.containerBackground(${bgColor}, for: .widget)
|
|
760
|
+
.widgetURL(deepLinkUrl)
|
|
761
|
+
} else {
|
|
762
|
+
${w.name}EntryView(entry: entry)
|
|
763
|
+
.padding()
|
|
764
|
+
.background(${bgColor})
|
|
765
|
+
.widgetURL(deepLinkUrl)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
.configurationDisplayName("${w.displayName}")
|
|
769
|
+
.description("${w.description}")
|
|
770
|
+
.supportedFamilies([${w.widgetFamilies.map((f) => `.${f}`).join(", ")}])
|
|
771
|
+
}
|
|
772
|
+
}`;
|
|
773
|
+
}
|
|
774
|
+
function generateSingleTypeWidgetCode(w, deepLinkLine) {
|
|
775
|
+
const bgColor = w.style?.backgroundColor
|
|
776
|
+
? `Color(hex: "${w.style.backgroundColor}")`
|
|
777
|
+
: ".clear";
|
|
778
|
+
const titleColor = w.style?.titleColor
|
|
779
|
+
? `Color(hex: "${w.style.titleColor}")`
|
|
780
|
+
: ".primary";
|
|
781
|
+
const subtitleColor = w.style?.subtitleColor
|
|
782
|
+
? `Color(hex: "${w.style.subtitleColor}")`
|
|
783
|
+
: ".secondary";
|
|
784
|
+
const accentColor = w.style?.accentColor
|
|
785
|
+
? `Color(hex: "${w.style.accentColor}")`
|
|
786
|
+
: ".accentColor";
|
|
787
|
+
const appIconSmall = w.showAppIcon
|
|
788
|
+
? `Image("AppIcon")
|
|
789
|
+
.resizable()
|
|
790
|
+
.frame(width: 32, height: 32)
|
|
791
|
+
.cornerRadius(8)`
|
|
792
|
+
: "";
|
|
793
|
+
const appIconMedium = w.showAppIcon
|
|
794
|
+
? `Image("AppIcon")
|
|
795
|
+
.resizable()
|
|
796
|
+
.frame(width: 48, height: 48)
|
|
797
|
+
.cornerRadius(12)`
|
|
798
|
+
: "";
|
|
799
|
+
const appIconLarge = w.showAppIcon
|
|
800
|
+
? `Image("AppIcon")
|
|
801
|
+
.resizable()
|
|
802
|
+
.frame(width: 56, height: 56)
|
|
803
|
+
.cornerRadius(14)`
|
|
804
|
+
: "";
|
|
805
|
+
return `
|
|
806
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
807
|
+
// MARK: - ${w.name}
|
|
808
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
809
|
+
|
|
810
|
+
struct ${w.name}Entry: TimelineEntry {
|
|
811
|
+
let date: Date
|
|
812
|
+
let title: String
|
|
813
|
+
let subtitle: String
|
|
814
|
+
let value: Int
|
|
815
|
+
|
|
816
|
+
static var placeholder: ${w.name}Entry {
|
|
817
|
+
${w.name}Entry(
|
|
818
|
+
date: Date(),
|
|
819
|
+
title: "${w.displayName}",
|
|
820
|
+
subtitle: "",
|
|
821
|
+
value: 0
|
|
822
|
+
)
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
struct ${w.name}Provider: TimelineProvider {
|
|
827
|
+
func placeholder(in context: Context) -> ${w.name}Entry {
|
|
828
|
+
return .placeholder
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
func getSnapshot(in context: Context, completion: @escaping (${w.name}Entry) -> Void) {
|
|
832
|
+
completion(createEntry())
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
func getTimeline(in context: Context, completion: @escaping (Timeline<${w.name}Entry>) -> Void) {
|
|
836
|
+
let entry = createEntry()
|
|
837
|
+
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
|
|
838
|
+
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
|
|
839
|
+
completion(timeline)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
private func createEntry() -> ${w.name}Entry {
|
|
843
|
+
return ${w.name}Entry(
|
|
844
|
+
date: Date(),
|
|
845
|
+
title: WidgetDataProvider.getString(forKey: "${w.name}_title", default: "${w.displayName}"),
|
|
846
|
+
subtitle: WidgetDataProvider.getString(forKey: "${w.name}_subtitle", default: ""),
|
|
847
|
+
value: WidgetDataProvider.getInt(forKey: "${w.name}_value", default: 0)
|
|
848
|
+
)
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// MARK: ${w.name} Views
|
|
853
|
+
|
|
854
|
+
struct ${w.name}SmallView: View {
|
|
855
|
+
let entry: ${w.name}Entry
|
|
856
|
+
|
|
857
|
+
var body: some View {
|
|
858
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
859
|
+
HStack {
|
|
860
|
+
${appIconSmall}
|
|
861
|
+
Text(entry.title)
|
|
862
|
+
.font(.headline)
|
|
863
|
+
.fontWeight(.bold)
|
|
864
|
+
.foregroundColor(${titleColor})
|
|
865
|
+
Spacer()
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if !entry.subtitle.isEmpty {
|
|
869
|
+
Text(entry.subtitle)
|
|
870
|
+
.font(.caption)
|
|
871
|
+
.foregroundColor(${subtitleColor})
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
Spacer()
|
|
875
|
+
|
|
876
|
+
Text("\\(entry.value)")
|
|
877
|
+
.font(.title)
|
|
878
|
+
.fontWeight(.bold)
|
|
879
|
+
.foregroundColor(${accentColor})
|
|
880
|
+
}
|
|
881
|
+
.padding()
|
|
882
|
+
.background(${bgColor})
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
struct ${w.name}MediumView: View {
|
|
887
|
+
let entry: ${w.name}Entry
|
|
888
|
+
|
|
889
|
+
var body: some View {
|
|
890
|
+
HStack {
|
|
891
|
+
${appIconMedium}
|
|
892
|
+
|
|
893
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
894
|
+
Text(entry.title)
|
|
895
|
+
.font(.headline)
|
|
896
|
+
.fontWeight(.bold)
|
|
897
|
+
.foregroundColor(${titleColor})
|
|
898
|
+
|
|
899
|
+
if !entry.subtitle.isEmpty {
|
|
900
|
+
Text(entry.subtitle)
|
|
901
|
+
.font(.subheadline)
|
|
902
|
+
.foregroundColor(${subtitleColor})
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
Spacer()
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
Spacer()
|
|
909
|
+
|
|
910
|
+
VStack {
|
|
911
|
+
Spacer()
|
|
912
|
+
Text("\\(entry.value)")
|
|
913
|
+
.font(.largeTitle)
|
|
914
|
+
.fontWeight(.bold)
|
|
915
|
+
.foregroundColor(${accentColor})
|
|
916
|
+
Spacer()
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
.padding()
|
|
920
|
+
.background(${bgColor})
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
struct ${w.name}LargeView: View {
|
|
925
|
+
let entry: ${w.name}Entry
|
|
926
|
+
|
|
927
|
+
var body: some View {
|
|
928
|
+
VStack(alignment: .leading, spacing: 12) {
|
|
929
|
+
HStack {
|
|
930
|
+
${appIconLarge}
|
|
931
|
+
VStack(alignment: .leading) {
|
|
932
|
+
Text(entry.title)
|
|
933
|
+
.font(.title2)
|
|
934
|
+
.fontWeight(.bold)
|
|
935
|
+
.foregroundColor(${titleColor})
|
|
936
|
+
if !entry.subtitle.isEmpty {
|
|
937
|
+
Text(entry.subtitle)
|
|
938
|
+
.font(.body)
|
|
939
|
+
.foregroundColor(${subtitleColor})
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
Spacer()
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
Spacer()
|
|
946
|
+
|
|
947
|
+
HStack {
|
|
948
|
+
Spacer()
|
|
949
|
+
Text("\\(entry.value)")
|
|
950
|
+
.font(.system(size: 64))
|
|
951
|
+
.fontWeight(.bold)
|
|
952
|
+
.foregroundColor(${accentColor})
|
|
953
|
+
Spacer()
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
Spacer()
|
|
957
|
+
}
|
|
958
|
+
.padding()
|
|
959
|
+
.background(${bgColor})
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
struct ${w.name}EntryView: View {
|
|
964
|
+
@Environment(\\.widgetFamily) var family
|
|
965
|
+
let entry: ${w.name}Entry
|
|
966
|
+
|
|
967
|
+
var body: some View {
|
|
968
|
+
switch family {
|
|
969
|
+
case .systemSmall:
|
|
970
|
+
${w.name}SmallView(entry: entry)
|
|
971
|
+
case .systemMedium:
|
|
972
|
+
${w.name}MediumView(entry: entry)
|
|
973
|
+
case .systemLarge, .systemExtraLarge:
|
|
974
|
+
${w.name}LargeView(entry: entry)
|
|
975
|
+
default:
|
|
976
|
+
${w.name}SmallView(entry: entry)
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
struct ${w.name}: Widget {
|
|
982
|
+
let kind: String = "${w.name}"
|
|
983
|
+
${deepLinkLine}
|
|
984
|
+
|
|
985
|
+
var body: some WidgetConfiguration {
|
|
986
|
+
StaticConfiguration(kind: kind, provider: ${w.name}Provider()) { entry in
|
|
987
|
+
if #available(iOS 17.0, *) {
|
|
988
|
+
${w.name}EntryView(entry: entry)
|
|
989
|
+
.containerBackground(${bgColor}, for: .widget)
|
|
990
|
+
.widgetURL(deepLinkUrl)
|
|
991
|
+
} else {
|
|
992
|
+
${w.name}EntryView(entry: entry)
|
|
993
|
+
.padding()
|
|
994
|
+
.background(${bgColor})
|
|
995
|
+
.widgetURL(deepLinkUrl)
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
.configurationDisplayName("${w.displayName}")
|
|
999
|
+
.description("${w.description}")
|
|
1000
|
+
.supportedFamilies([${w.widgetFamilies.map((f) => `.${f}`).join(", ")}])
|
|
1001
|
+
}
|
|
1002
|
+
}`;
|
|
1003
|
+
}
|
|
1004
|
+
/** Generate the full Swift file with all widgets + WidgetBundle */
|
|
1005
|
+
function generateWidgetSwiftCode(props, bundleId) {
|
|
1006
|
+
const dataProvider = generateDataProviderCode(props, bundleId);
|
|
1007
|
+
const widgetBlocks = props.widgets
|
|
1008
|
+
.map((w) => generateSingleWidgetCode(w))
|
|
1009
|
+
.join("\n");
|
|
1010
|
+
const bundleBody = props.widgets.map((w) => ` ${w.name}()`).join("\n");
|
|
1011
|
+
// Include Color hex extension if any widget uses style or is list/single type
|
|
1012
|
+
// (list/single items can have per-item colors set at runtime)
|
|
1013
|
+
const needsColorExt = props.widgets.some((w) => w.type === "list" ||
|
|
1014
|
+
w.type === "single" ||
|
|
1015
|
+
w.style?.backgroundColor ||
|
|
1016
|
+
w.style?.titleColor ||
|
|
1017
|
+
w.style?.subtitleColor ||
|
|
1018
|
+
w.style?.accentColor);
|
|
1019
|
+
const colorExtension = needsColorExt
|
|
1020
|
+
? `
|
|
1021
|
+
// MARK: - Color Hex Extension
|
|
1022
|
+
|
|
1023
|
+
extension Color {
|
|
1024
|
+
init(hex: String) {
|
|
1025
|
+
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
|
1026
|
+
var int: UInt64 = 0
|
|
1027
|
+
Scanner(string: hex).scanHexInt64(&int)
|
|
1028
|
+
let a, r, g, b: UInt64
|
|
1029
|
+
switch hex.count {
|
|
1030
|
+
case 6: (a, r, g, b) = (255, (int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF)
|
|
1031
|
+
case 8: (a, r, g, b) = ((int >> 24) & 0xFF, (int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF)
|
|
1032
|
+
default: (a, r, g, b) = (255, 0, 0, 0)
|
|
1033
|
+
}
|
|
1034
|
+
self.init(
|
|
1035
|
+
.sRGB,
|
|
1036
|
+
red: Double(r) / 255,
|
|
1037
|
+
green: Double(g) / 255,
|
|
1038
|
+
blue: Double(b) / 255,
|
|
1039
|
+
opacity: Double(a) / 255
|
|
1040
|
+
)
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
`
|
|
1044
|
+
: "";
|
|
1045
|
+
// Determine if we need UIKit import (for UIImage check in icon rendering)
|
|
1046
|
+
const needsUIKit = props.widgets.some((w) => w.type === "list" || w.type === "single");
|
|
1047
|
+
const uiKitImport = needsUIKit ? "\nimport UIKit" : "";
|
|
1048
|
+
return `//
|
|
1049
|
+
// Widgets.swift
|
|
1050
|
+
// ${props.extensionName}
|
|
1051
|
+
//
|
|
1052
|
+
// Auto-generated by nn-widgets
|
|
1053
|
+
//
|
|
1054
|
+
|
|
1055
|
+
import WidgetKit
|
|
1056
|
+
import SwiftUI${uiKitImport}
|
|
1057
|
+
${colorExtension}
|
|
1058
|
+
// MARK: - Widget Data Provider
|
|
1059
|
+
${dataProvider}
|
|
1060
|
+
${widgetBlocks}
|
|
1061
|
+
|
|
1062
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1063
|
+
// MARK: - Widget Bundle
|
|
1064
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1065
|
+
|
|
1066
|
+
@main
|
|
1067
|
+
struct AppWidgetsBundle: WidgetBundle {
|
|
1068
|
+
var body: some Widget {
|
|
1069
|
+
${bundleBody}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
`;
|
|
1073
|
+
}
|
|
1074
|
+
// ──────────────────────────────────────────────
|
|
1075
|
+
// Plist / entitlements helpers
|
|
1076
|
+
// ──────────────────────────────────────────────
|
|
1077
|
+
function generateWidgetEntitlements(appGroupId, useAppGroups) {
|
|
1078
|
+
if (!useAppGroups || !appGroupId) {
|
|
1079
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1080
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1081
|
+
<plist version="1.0">
|
|
1082
|
+
<dict>
|
|
1083
|
+
</dict>
|
|
1084
|
+
</plist>
|
|
1085
|
+
`;
|
|
1086
|
+
}
|
|
1087
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1088
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1089
|
+
<plist version="1.0">
|
|
1090
|
+
<dict>
|
|
1091
|
+
<key>com.apple.security.application-groups</key>
|
|
1092
|
+
<array>
|
|
1093
|
+
<string>${appGroupId}</string>
|
|
1094
|
+
</array>
|
|
1095
|
+
</dict>
|
|
1096
|
+
</plist>
|
|
1097
|
+
`;
|
|
1098
|
+
}
|
|
1099
|
+
function generateWidgetInfoPlist(props, bundleId) {
|
|
1100
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
1101
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1102
|
+
<plist version="1.0">
|
|
1103
|
+
<dict>
|
|
1104
|
+
<key>CFBundleDevelopmentRegion</key>
|
|
1105
|
+
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
|
1106
|
+
<key>CFBundleDisplayName</key>
|
|
1107
|
+
<string>${props.extensionName}</string>
|
|
1108
|
+
<key>CFBundleExecutable</key>
|
|
1109
|
+
<string>$(EXECUTABLE_NAME)</string>
|
|
1110
|
+
<key>CFBundleIdentifier</key>
|
|
1111
|
+
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
1112
|
+
<key>CFBundleInfoDictionaryVersion</key>
|
|
1113
|
+
<string>6.0</string>
|
|
1114
|
+
<key>CFBundleName</key>
|
|
1115
|
+
<string>$(PRODUCT_NAME)</string>
|
|
1116
|
+
<key>CFBundlePackageType</key>
|
|
1117
|
+
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
|
1118
|
+
<key>CFBundleShortVersionString</key>
|
|
1119
|
+
<string>$(MARKETING_VERSION)</string>
|
|
1120
|
+
<key>CFBundleVersion</key>
|
|
1121
|
+
<string>$(CURRENT_PROJECT_VERSION)</string>
|
|
1122
|
+
<key>NSExtension</key>
|
|
1123
|
+
<dict>
|
|
1124
|
+
<key>NSExtensionPointIdentifier</key>
|
|
1125
|
+
<string>com.apple.widgetkit-extension</string>
|
|
1126
|
+
</dict>
|
|
1127
|
+
</dict>
|
|
1128
|
+
</plist>
|
|
1129
|
+
`;
|
|
1130
|
+
}
|
|
1131
|
+
// ──────────────────────────────────────────────
|
|
1132
|
+
// Config plugin
|
|
1133
|
+
// ──────────────────────────────────────────────
|
|
1134
|
+
const withIosWidget = (config, props = {}) => {
|
|
1135
|
+
const bundleIdentifier = config.ios?.bundleIdentifier || "com.app.widget";
|
|
1136
|
+
// Resolve props with defaults (normalisation happens in index.ts,
|
|
1137
|
+
// but we handle it here as well for standalone usage)
|
|
1138
|
+
const resolvedProps = resolveIosProps(props, config.name || "Widget", bundleIdentifier);
|
|
1139
|
+
const extensionName = resolvedProps.extensionName;
|
|
1140
|
+
const useAppGroups = resolvedProps.ios.useAppGroups;
|
|
1141
|
+
const appGroupId = resolvedProps.ios.appGroupIdentifier;
|
|
1142
|
+
// Add App Groups to main app entitlements (only if enabled)
|
|
1143
|
+
if (useAppGroups && appGroupId) {
|
|
1144
|
+
config = (0, config_plugins_1.withEntitlementsPlist)(config, (config) => {
|
|
1145
|
+
config.modResults["com.apple.security.application-groups"] =
|
|
1146
|
+
config.modResults["com.apple.security.application-groups"] || [];
|
|
1147
|
+
const groups = config.modResults["com.apple.security.application-groups"];
|
|
1148
|
+
if (!groups.includes(appGroupId)) {
|
|
1149
|
+
groups.push(appGroupId);
|
|
1150
|
+
}
|
|
1151
|
+
return config;
|
|
1152
|
+
});
|
|
1153
|
+
config = (0, config_plugins_1.withInfoPlist)(config, (config) => {
|
|
1154
|
+
config.modResults["NNWidgetsAppGroup"] = appGroupId;
|
|
1155
|
+
return config;
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
// Configure Xcode project with widget extension
|
|
1159
|
+
config = (0, config_plugins_1.withXcodeProject)(config, async (config) => {
|
|
1160
|
+
const xcodeProject = config.modResults;
|
|
1161
|
+
const projectRoot = config.modRequest.projectRoot;
|
|
1162
|
+
const projectName = config.modRequest.projectName || "App";
|
|
1163
|
+
const iosPath = path.join(projectRoot, "ios");
|
|
1164
|
+
const extensionPath = path.join(iosPath, extensionName);
|
|
1165
|
+
const extensionBundleId = `${bundleIdentifier}.${extensionName}`;
|
|
1166
|
+
// Check if target already exists
|
|
1167
|
+
const existingTarget = xcodeProject.pbxTargetByName(extensionName);
|
|
1168
|
+
if (existingTarget) {
|
|
1169
|
+
console.log(`[nn-widgets] Widget extension target "${extensionName}" already exists, skipping...`);
|
|
1170
|
+
return config;
|
|
1171
|
+
}
|
|
1172
|
+
// Create extension directory
|
|
1173
|
+
if (!fs.existsSync(extensionPath)) {
|
|
1174
|
+
fs.mkdirSync(extensionPath, { recursive: true });
|
|
1175
|
+
}
|
|
1176
|
+
// Write single Swift file containing ALL widgets
|
|
1177
|
+
const swiftCode = generateWidgetSwiftCode(resolvedProps, bundleIdentifier);
|
|
1178
|
+
const swiftFileName = "Widgets.swift";
|
|
1179
|
+
fs.writeFileSync(path.join(extensionPath, swiftFileName), swiftCode);
|
|
1180
|
+
// Write entitlements
|
|
1181
|
+
const entitlements = generateWidgetEntitlements(appGroupId, useAppGroups);
|
|
1182
|
+
const entitlementsFileName = `${extensionName}.entitlements`;
|
|
1183
|
+
fs.writeFileSync(path.join(extensionPath, entitlementsFileName), entitlements);
|
|
1184
|
+
// Write Info.plist
|
|
1185
|
+
const infoPlist = generateWidgetInfoPlist(resolvedProps, bundleIdentifier);
|
|
1186
|
+
fs.writeFileSync(path.join(extensionPath, "Info.plist"), infoPlist);
|
|
1187
|
+
// Create Assets.xcassets for widget extension
|
|
1188
|
+
const assetsPath = path.join(extensionPath, "Assets.xcassets");
|
|
1189
|
+
if (!fs.existsSync(assetsPath)) {
|
|
1190
|
+
fs.mkdirSync(assetsPath, { recursive: true });
|
|
1191
|
+
fs.writeFileSync(path.join(assetsPath, "Contents.json"), JSON.stringify({ info: { author: "xcode", version: 1 } }, null, 2));
|
|
1192
|
+
// Create AccentColor
|
|
1193
|
+
const accentColorPath = path.join(assetsPath, "AccentColor.colorset");
|
|
1194
|
+
fs.mkdirSync(accentColorPath, { recursive: true });
|
|
1195
|
+
fs.writeFileSync(path.join(accentColorPath, "Contents.json"), JSON.stringify({
|
|
1196
|
+
colors: [{ idiom: "universal" }],
|
|
1197
|
+
info: { author: "xcode", version: 1 },
|
|
1198
|
+
}, null, 2));
|
|
1199
|
+
// Create WidgetBackground
|
|
1200
|
+
const widgetBgPath = path.join(assetsPath, "WidgetBackground.colorset");
|
|
1201
|
+
fs.mkdirSync(widgetBgPath, { recursive: true });
|
|
1202
|
+
fs.writeFileSync(path.join(widgetBgPath, "Contents.json"), JSON.stringify({
|
|
1203
|
+
colors: [{ idiom: "universal" }],
|
|
1204
|
+
info: { author: "xcode", version: 1 },
|
|
1205
|
+
}, null, 2));
|
|
1206
|
+
// Copy AppIcon if ANY widget has showAppIcon enabled
|
|
1207
|
+
const needsAppIcon = resolvedProps.widgets.some((w) => w.showAppIcon);
|
|
1208
|
+
if (needsAppIcon) {
|
|
1209
|
+
const mainAppAssetsPath = path.join(iosPath, projectName, "Images.xcassets", "AppIcon.appiconset");
|
|
1210
|
+
const widgetAppIconPath = path.join(assetsPath, "AppIcon.imageset");
|
|
1211
|
+
if (fs.existsSync(mainAppAssetsPath)) {
|
|
1212
|
+
fs.mkdirSync(widgetAppIconPath, { recursive: true });
|
|
1213
|
+
const iconFiles = fs
|
|
1214
|
+
.readdirSync(mainAppAssetsPath)
|
|
1215
|
+
.filter((f) => f.endsWith(".png"));
|
|
1216
|
+
if (iconFiles.length > 0) {
|
|
1217
|
+
const iconToCopy = iconFiles.find((f) => f.includes("1024") || f.includes("512") || f.includes("180")) || iconFiles[0];
|
|
1218
|
+
const sourcePath = path.join(mainAppAssetsPath, iconToCopy);
|
|
1219
|
+
const destPath = path.join(widgetAppIconPath, "AppIcon.png");
|
|
1220
|
+
fs.copyFileSync(sourcePath, destPath);
|
|
1221
|
+
fs.writeFileSync(path.join(widgetAppIconPath, "Contents.json"), JSON.stringify({
|
|
1222
|
+
images: [
|
|
1223
|
+
{
|
|
1224
|
+
filename: "AppIcon.png",
|
|
1225
|
+
idiom: "universal",
|
|
1226
|
+
scale: "1x",
|
|
1227
|
+
},
|
|
1228
|
+
{ idiom: "universal", scale: "2x" },
|
|
1229
|
+
{ idiom: "universal", scale: "3x" },
|
|
1230
|
+
],
|
|
1231
|
+
info: { author: "xcode", version: 1 },
|
|
1232
|
+
}, null, 2));
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
// ── Copy widget images to Assets.xcassets ──
|
|
1238
|
+
for (const w of resolvedProps.widgets) {
|
|
1239
|
+
if (!w.image)
|
|
1240
|
+
continue;
|
|
1241
|
+
const sizes = [
|
|
1242
|
+
{ key: "small", path: w.image.small },
|
|
1243
|
+
{ key: "medium", path: w.image.medium },
|
|
1244
|
+
{ key: "large", path: w.image.large },
|
|
1245
|
+
];
|
|
1246
|
+
for (const size of sizes) {
|
|
1247
|
+
if (!size.path)
|
|
1248
|
+
continue;
|
|
1249
|
+
const assetName = `${w.name}_${size.key}`;
|
|
1250
|
+
const imagesetPath = path.join(assetsPath, `${assetName}.imageset`);
|
|
1251
|
+
fs.mkdirSync(imagesetPath, { recursive: true });
|
|
1252
|
+
// Resolve source path relative to projectRoot
|
|
1253
|
+
const imgSourcePath = path.isAbsolute(size.path)
|
|
1254
|
+
? size.path
|
|
1255
|
+
: path.join(projectRoot, size.path);
|
|
1256
|
+
if (!fs.existsSync(imgSourcePath)) {
|
|
1257
|
+
console.warn(`[nn-widgets] Widget image not found: ${imgSourcePath} (widget: ${w.name}, size: ${size.key})`);
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
const ext = path.extname(imgSourcePath);
|
|
1261
|
+
const destFileName = `${assetName}${ext}`;
|
|
1262
|
+
fs.copyFileSync(imgSourcePath, path.join(imagesetPath, destFileName));
|
|
1263
|
+
fs.writeFileSync(path.join(imagesetPath, "Contents.json"), JSON.stringify({
|
|
1264
|
+
images: [
|
|
1265
|
+
{ filename: destFileName, idiom: "universal", scale: "1x" },
|
|
1266
|
+
{ idiom: "universal", scale: "2x" },
|
|
1267
|
+
{ idiom: "universal", scale: "3x" },
|
|
1268
|
+
],
|
|
1269
|
+
info: { author: "xcode", version: 1 },
|
|
1270
|
+
}, null, 2));
|
|
1271
|
+
console.log(`[nn-widgets] Copied widget image: ${assetName} → ${imagesetPath}`);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
// ── Copy widget icons to Assets.xcassets ──
|
|
1275
|
+
for (const w of resolvedProps.widgets) {
|
|
1276
|
+
if (!w.icons)
|
|
1277
|
+
continue;
|
|
1278
|
+
for (const [iconName, iconPath] of Object.entries(w.icons)) {
|
|
1279
|
+
const imagesetPath = path.join(assetsPath, `${iconName}.imageset`);
|
|
1280
|
+
fs.mkdirSync(imagesetPath, { recursive: true });
|
|
1281
|
+
const imgSourcePath = path.isAbsolute(iconPath)
|
|
1282
|
+
? iconPath
|
|
1283
|
+
: path.join(projectRoot, iconPath);
|
|
1284
|
+
if (!fs.existsSync(imgSourcePath)) {
|
|
1285
|
+
console.warn(`[nn-widgets] Widget icon not found: ${imgSourcePath} (widget: ${w.name}, icon: ${iconName})`);
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
const ext = path.extname(imgSourcePath);
|
|
1289
|
+
const destFileName = `${iconName}${ext}`;
|
|
1290
|
+
fs.copyFileSync(imgSourcePath, path.join(imagesetPath, destFileName));
|
|
1291
|
+
fs.writeFileSync(path.join(imagesetPath, "Contents.json"), JSON.stringify({
|
|
1292
|
+
images: [
|
|
1293
|
+
{
|
|
1294
|
+
filename: destFileName,
|
|
1295
|
+
idiom: "universal",
|
|
1296
|
+
scale: "1x",
|
|
1297
|
+
},
|
|
1298
|
+
{ idiom: "universal", scale: "2x" },
|
|
1299
|
+
{ idiom: "universal", scale: "3x" },
|
|
1300
|
+
],
|
|
1301
|
+
info: { author: "xcode", version: 1 },
|
|
1302
|
+
}, null, 2));
|
|
1303
|
+
console.log(`[nn-widgets] Copied widget icon: ${iconName} → ${imagesetPath}`);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
// ── Xcode project manipulation ──────────────────
|
|
1307
|
+
// Create PBXGroup for extension
|
|
1308
|
+
const widgetGroup = xcodeProject.addPbxGroup([], extensionName, extensionName);
|
|
1309
|
+
// Add group to the main project group
|
|
1310
|
+
const projectSection = xcodeProject.pbxProjectSection();
|
|
1311
|
+
for (const key in projectSection) {
|
|
1312
|
+
if (projectSection[key] && projectSection[key].mainGroup) {
|
|
1313
|
+
const mainGroupUuid = projectSection[key].mainGroup;
|
|
1314
|
+
const pbxGroupSection = xcodeProject.hash.project.objects["PBXGroup"];
|
|
1315
|
+
if (pbxGroupSection?.[mainGroupUuid]?.children) {
|
|
1316
|
+
pbxGroupSection[mainGroupUuid].children.push({
|
|
1317
|
+
value: widgetGroup.uuid,
|
|
1318
|
+
comment: extensionName,
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
break;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
// Create the widget extension target
|
|
1325
|
+
const target = xcodeProject.addTarget(extensionName, "app_extension", extensionName, extensionBundleId);
|
|
1326
|
+
if (!target) {
|
|
1327
|
+
console.error("[nn-widgets] Failed to create widget extension target");
|
|
1328
|
+
return config;
|
|
1329
|
+
}
|
|
1330
|
+
const targetUuid = target.uuid;
|
|
1331
|
+
// Add Swift file to sources build phase
|
|
1332
|
+
const swiftFileRef = xcodeProject.generateUuid();
|
|
1333
|
+
const pbxFileReferenceSection = xcodeProject.hash.project.objects["PBXFileReference"];
|
|
1334
|
+
pbxFileReferenceSection[swiftFileRef] = {
|
|
1335
|
+
isa: "PBXFileReference",
|
|
1336
|
+
lastKnownFileType: "sourcecode.swift",
|
|
1337
|
+
path: `${extensionName}/${swiftFileName}`,
|
|
1338
|
+
sourceTree: '"<group>"',
|
|
1339
|
+
};
|
|
1340
|
+
pbxFileReferenceSection[`${swiftFileRef}_comment`] = swiftFileName;
|
|
1341
|
+
const sourcesBuildPhase = xcodeProject.addBuildPhase([], "PBXSourcesBuildPhase", "Sources", targetUuid);
|
|
1342
|
+
if (sourcesBuildPhase?.buildPhase) {
|
|
1343
|
+
const buildFileUuid = xcodeProject.generateUuid();
|
|
1344
|
+
const pbxBuildFileSection = xcodeProject.hash.project.objects["PBXBuildFile"];
|
|
1345
|
+
pbxBuildFileSection[buildFileUuid] = {
|
|
1346
|
+
isa: "PBXBuildFile",
|
|
1347
|
+
fileRef: swiftFileRef,
|
|
1348
|
+
fileRef_comment: swiftFileName,
|
|
1349
|
+
};
|
|
1350
|
+
pbxBuildFileSection[`${buildFileUuid}_comment`] =
|
|
1351
|
+
`${swiftFileName} in Sources`;
|
|
1352
|
+
sourcesBuildPhase.buildPhase.files.push({
|
|
1353
|
+
value: buildFileUuid,
|
|
1354
|
+
comment: `${swiftFileName} in Sources`,
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
// Resources & Frameworks build phases
|
|
1358
|
+
const resourcesBuildPhase = xcodeProject.addBuildPhase([], "PBXResourcesBuildPhase", "Resources", targetUuid);
|
|
1359
|
+
// Add Assets.xcassets to the widget extension's Resources build phase
|
|
1360
|
+
const assetsFileRef = xcodeProject.generateUuid();
|
|
1361
|
+
pbxFileReferenceSection[assetsFileRef] = {
|
|
1362
|
+
isa: "PBXFileReference",
|
|
1363
|
+
lastKnownFileType: "folder.assetcatalog",
|
|
1364
|
+
path: `${extensionName}/Assets.xcassets`,
|
|
1365
|
+
sourceTree: '"<group>"',
|
|
1366
|
+
};
|
|
1367
|
+
pbxFileReferenceSection[`${assetsFileRef}_comment`] = "Assets.xcassets";
|
|
1368
|
+
if (resourcesBuildPhase?.buildPhase) {
|
|
1369
|
+
const assetsBuildFileUuid = xcodeProject.generateUuid();
|
|
1370
|
+
const pbxBuildFileSection2 = xcodeProject.hash.project.objects["PBXBuildFile"];
|
|
1371
|
+
pbxBuildFileSection2[assetsBuildFileUuid] = {
|
|
1372
|
+
isa: "PBXBuildFile",
|
|
1373
|
+
fileRef: assetsFileRef,
|
|
1374
|
+
fileRef_comment: "Assets.xcassets",
|
|
1375
|
+
};
|
|
1376
|
+
pbxBuildFileSection2[`${assetsBuildFileUuid}_comment`] =
|
|
1377
|
+
"Assets.xcassets in Resources";
|
|
1378
|
+
resourcesBuildPhase.buildPhase.files.push({
|
|
1379
|
+
value: assetsBuildFileUuid,
|
|
1380
|
+
comment: "Assets.xcassets in Resources",
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
xcodeProject.addBuildPhase([], "PBXFrameworksBuildPhase", "Frameworks", targetUuid);
|
|
1384
|
+
// Configure build settings
|
|
1385
|
+
const configurations = xcodeProject.pbxXCBuildConfigurationSection();
|
|
1386
|
+
for (const configKey in configurations) {
|
|
1387
|
+
const configObj = configurations[configKey];
|
|
1388
|
+
if (typeof configObj === "object" && configObj.buildSettings) {
|
|
1389
|
+
const bs = configObj.buildSettings;
|
|
1390
|
+
if (bs.PRODUCT_BUNDLE_IDENTIFIER === `"${extensionBundleId}"` ||
|
|
1391
|
+
bs.PRODUCT_BUNDLE_IDENTIFIER === extensionBundleId ||
|
|
1392
|
+
bs.PRODUCT_NAME === `"${extensionName}"` ||
|
|
1393
|
+
bs.PRODUCT_NAME === extensionName) {
|
|
1394
|
+
bs.ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = '"AccentColor"';
|
|
1395
|
+
bs.ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME =
|
|
1396
|
+
'"WidgetBackground"';
|
|
1397
|
+
bs.CODE_SIGN_ENTITLEMENTS = `"${extensionName}/${entitlementsFileName}"`;
|
|
1398
|
+
bs.CODE_SIGN_STYLE = "Automatic";
|
|
1399
|
+
bs.CURRENT_PROJECT_VERSION = "1";
|
|
1400
|
+
bs.GENERATE_INFOPLIST_FILE = "NO";
|
|
1401
|
+
bs.INFOPLIST_FILE = `"${extensionName}/Info.plist"`;
|
|
1402
|
+
bs.INFOPLIST_KEY_CFBundleDisplayName = `"${resolvedProps.extensionName}"`;
|
|
1403
|
+
bs.INFOPLIST_KEY_NSHumanReadableCopyright = '""';
|
|
1404
|
+
bs.IPHONEOS_DEPLOYMENT_TARGET = resolvedProps.ios.deploymentTarget;
|
|
1405
|
+
bs.LD_RUNPATH_SEARCH_PATHS =
|
|
1406
|
+
'"$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"';
|
|
1407
|
+
bs.MARKETING_VERSION = "1.0";
|
|
1408
|
+
bs.PRODUCT_BUNDLE_IDENTIFIER = `"${extensionBundleId}"`;
|
|
1409
|
+
bs.PRODUCT_NAME = '"$(TARGET_NAME)"';
|
|
1410
|
+
bs.SKIP_INSTALL = "YES";
|
|
1411
|
+
bs.SWIFT_EMIT_LOC_STRINGS = "YES";
|
|
1412
|
+
bs.SWIFT_VERSION = "5.0";
|
|
1413
|
+
bs.TARGETED_DEVICE_FAMILY = '"1,2"';
|
|
1414
|
+
if (resolvedProps.ios.devTeam) {
|
|
1415
|
+
bs.DEVELOPMENT_TEAM = resolvedProps.ios.devTeam;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
// Embed extension in main app
|
|
1421
|
+
const mainTarget = xcodeProject.getFirstTarget();
|
|
1422
|
+
if (mainTarget?.uuid) {
|
|
1423
|
+
xcodeProject.addTargetDependency(mainTarget.uuid, [targetUuid]);
|
|
1424
|
+
const nativeTargetSection = xcodeProject.hash.project.objects["PBXNativeTarget"];
|
|
1425
|
+
let productRef = null;
|
|
1426
|
+
for (const key in nativeTargetSection) {
|
|
1427
|
+
if (typeof nativeTargetSection[key] === "object" &&
|
|
1428
|
+
nativeTargetSection[key].name === extensionName) {
|
|
1429
|
+
productRef = nativeTargetSection[key].productReference;
|
|
1430
|
+
break;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
if (productRef) {
|
|
1434
|
+
const copyFilesBuildPhaseUuid = xcodeProject.generateUuid();
|
|
1435
|
+
const buildFileUuid = xcodeProject.generateUuid();
|
|
1436
|
+
const pbxBuildFileSection = xcodeProject.hash.project.objects["PBXBuildFile"];
|
|
1437
|
+
pbxBuildFileSection[buildFileUuid] = {
|
|
1438
|
+
isa: "PBXBuildFile",
|
|
1439
|
+
fileRef: productRef,
|
|
1440
|
+
fileRef_comment: `${extensionName}.appex`,
|
|
1441
|
+
settings: { ATTRIBUTES: ["RemoveHeadersOnCopy"] },
|
|
1442
|
+
};
|
|
1443
|
+
pbxBuildFileSection[`${buildFileUuid}_comment`] =
|
|
1444
|
+
`${extensionName}.appex in Embed Foundation Extensions`;
|
|
1445
|
+
const pbxCopyFilesBuildPhaseSection = xcodeProject.hash.project.objects["PBXCopyFilesBuildPhase"] || {};
|
|
1446
|
+
xcodeProject.hash.project.objects["PBXCopyFilesBuildPhase"] =
|
|
1447
|
+
pbxCopyFilesBuildPhaseSection;
|
|
1448
|
+
pbxCopyFilesBuildPhaseSection[copyFilesBuildPhaseUuid] = {
|
|
1449
|
+
isa: "PBXCopyFilesBuildPhase",
|
|
1450
|
+
buildActionMask: 2147483647,
|
|
1451
|
+
dstPath: '""',
|
|
1452
|
+
dstSubfolderSpec: 13,
|
|
1453
|
+
files: [
|
|
1454
|
+
{
|
|
1455
|
+
value: buildFileUuid,
|
|
1456
|
+
comment: `${extensionName}.appex in Embed Foundation Extensions`,
|
|
1457
|
+
},
|
|
1458
|
+
],
|
|
1459
|
+
name: '"Embed Foundation Extensions"',
|
|
1460
|
+
runOnlyForDeploymentPostprocessing: 0,
|
|
1461
|
+
};
|
|
1462
|
+
pbxCopyFilesBuildPhaseSection[`${copyFilesBuildPhaseUuid}_comment`] =
|
|
1463
|
+
"Embed Foundation Extensions";
|
|
1464
|
+
for (const key in nativeTargetSection) {
|
|
1465
|
+
if (typeof nativeTargetSection[key] === "object" &&
|
|
1466
|
+
nativeTargetSection[key].name === projectName) {
|
|
1467
|
+
if (!nativeTargetSection[key].buildPhases) {
|
|
1468
|
+
nativeTargetSection[key].buildPhases = [];
|
|
1469
|
+
}
|
|
1470
|
+
nativeTargetSection[key].buildPhases.push({
|
|
1471
|
+
value: copyFilesBuildPhaseUuid,
|
|
1472
|
+
comment: "Embed Foundation Extensions",
|
|
1473
|
+
});
|
|
1474
|
+
break;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
const widgetNames = resolvedProps.widgets.map((w) => w.name).join(", ");
|
|
1480
|
+
console.log(`[nn-widgets] Created iOS widget extension "${extensionName}" with widgets: ${widgetNames}`);
|
|
1481
|
+
return config;
|
|
1482
|
+
});
|
|
1483
|
+
return config;
|
|
1484
|
+
};
|
|
1485
|
+
exports.withIosWidget = withIosWidget;
|
|
1486
|
+
// ──────────────────────────────────────────────
|
|
1487
|
+
// Props resolution helper
|
|
1488
|
+
// ──────────────────────────────────────────────
|
|
1489
|
+
function resolveIosProps(props, appName, bundleIdentifier) {
|
|
1490
|
+
const useAppGroups = props.ios?.useAppGroups !== false;
|
|
1491
|
+
// Normalise widgets array (handle legacy single-widget format)
|
|
1492
|
+
let widgets;
|
|
1493
|
+
if (props.widgets && props.widgets.length > 0) {
|
|
1494
|
+
widgets = props.widgets.map((w) => {
|
|
1495
|
+
// Resolve image: normalise string → per-size object
|
|
1496
|
+
let resolvedImage;
|
|
1497
|
+
if (w.image) {
|
|
1498
|
+
if (typeof w.image === "string") {
|
|
1499
|
+
resolvedImage = { small: w.image, medium: w.image, large: w.image };
|
|
1500
|
+
}
|
|
1501
|
+
else {
|
|
1502
|
+
resolvedImage = {
|
|
1503
|
+
small: w.image.small,
|
|
1504
|
+
medium: w.image.medium,
|
|
1505
|
+
large: w.image.large,
|
|
1506
|
+
};
|
|
1507
|
+
// Only include if at least one size is set
|
|
1508
|
+
if (!resolvedImage.small &&
|
|
1509
|
+
!resolvedImage.medium &&
|
|
1510
|
+
!resolvedImage.large) {
|
|
1511
|
+
resolvedImage = undefined;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
// Auto-detect type: if image is set and type is not, use "image"
|
|
1516
|
+
const hasImage = resolvedImage &&
|
|
1517
|
+
(resolvedImage.small || resolvedImage.medium || resolvedImage.large);
|
|
1518
|
+
let resolvedType = w.type || (hasImage ? "image" : "single");
|
|
1519
|
+
return {
|
|
1520
|
+
name: w.name,
|
|
1521
|
+
displayName: w.displayName,
|
|
1522
|
+
description: w.description || "A widget for your app",
|
|
1523
|
+
type: resolvedType,
|
|
1524
|
+
deepLinkUrl: w.deepLinkUrl,
|
|
1525
|
+
showAppIcon: w.showAppIcon !== false,
|
|
1526
|
+
widgetFamilies: w.widgetFamilies || [
|
|
1527
|
+
"systemSmall",
|
|
1528
|
+
"systemMedium",
|
|
1529
|
+
"systemLarge",
|
|
1530
|
+
],
|
|
1531
|
+
image: resolvedImage,
|
|
1532
|
+
icons: w.icons,
|
|
1533
|
+
gridLayout: w.gridLayout,
|
|
1534
|
+
style: w.style,
|
|
1535
|
+
fallbackTitle: w.fallbackTitle || w.displayName,
|
|
1536
|
+
fallbackSubtitle: w.fallbackSubtitle || "Open app to start",
|
|
1537
|
+
android: {
|
|
1538
|
+
minWidth: w.android?.minWidth || props.android?.minWidth || 110,
|
|
1539
|
+
minHeight: w.android?.minHeight || props.android?.minHeight || 40,
|
|
1540
|
+
resizeMode: w.android?.resizeMode ||
|
|
1541
|
+
props.android?.resizeMode ||
|
|
1542
|
+
"horizontal|vertical",
|
|
1543
|
+
},
|
|
1544
|
+
};
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
else {
|
|
1548
|
+
// Legacy single-widget format → convert to widgets[]
|
|
1549
|
+
widgets = [
|
|
1550
|
+
{
|
|
1551
|
+
name: props.widgetName || "AppWidget",
|
|
1552
|
+
displayName: props.displayName || appName,
|
|
1553
|
+
description: props.description || "A widget for your app",
|
|
1554
|
+
type: "single",
|
|
1555
|
+
deepLinkUrl: props.ios?.deepLinkUrl || `${bundleIdentifier}://widget`,
|
|
1556
|
+
showAppIcon: props.ios?.showAppIcon !== false,
|
|
1557
|
+
widgetFamilies: props.ios?.widgetFamilies || [
|
|
1558
|
+
"systemSmall",
|
|
1559
|
+
"systemMedium",
|
|
1560
|
+
"systemLarge",
|
|
1561
|
+
],
|
|
1562
|
+
fallbackTitle: props.displayName || appName,
|
|
1563
|
+
fallbackSubtitle: "Open app to start",
|
|
1564
|
+
android: {
|
|
1565
|
+
minWidth: props.android?.minWidth || 110,
|
|
1566
|
+
minHeight: props.android?.minHeight || 40,
|
|
1567
|
+
resizeMode: props.android?.resizeMode || "horizontal|vertical",
|
|
1568
|
+
},
|
|
1569
|
+
},
|
|
1570
|
+
];
|
|
1571
|
+
}
|
|
1572
|
+
return {
|
|
1573
|
+
extensionName: "WidgetsExtension",
|
|
1574
|
+
widgets,
|
|
1575
|
+
ios: {
|
|
1576
|
+
deploymentTarget: props.ios?.deploymentTarget || "17.0",
|
|
1577
|
+
useAppGroups,
|
|
1578
|
+
appGroupIdentifier: useAppGroups
|
|
1579
|
+
? props.ios?.appGroupIdentifier || `group.${bundleIdentifier}`
|
|
1580
|
+
: undefined,
|
|
1581
|
+
devTeam: props.ios?.devTeam,
|
|
1582
|
+
},
|
|
1583
|
+
android: {
|
|
1584
|
+
minSdkVersion: props.android?.minSdkVersion || 26,
|
|
1585
|
+
updatePeriodMillis: props.android?.updatePeriodMillis || 1800000,
|
|
1586
|
+
},
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
exports.default = exports.withIosWidget;
|