Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
635751f906
commit
176830f712
@ -2,32 +2,26 @@
|
|||||||
// IconGeneratorView.swift
|
// IconGeneratorView.swift
|
||||||
// Bedrock
|
// Bedrock
|
||||||
//
|
//
|
||||||
// Development tool to generate and export app icon images.
|
// Development tool to generate and export the app icon image.
|
||||||
// Run this view, tap the button, then find the icons in the Files app.
|
// Run this view, tap the button, then find the icon in the Files app.
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// A development view that generates and saves app icon images.
|
/// A development view that generates and saves the app icon image.
|
||||||
/// After running, find the icons in Files app → On My iPhone → [App Name]
|
/// After running, find the icon in Files app → On My iPhone → [App Name]
|
||||||
|
///
|
||||||
|
/// iOS 18+ only requires a single 1024x1024 icon - iOS generates all other sizes automatically.
|
||||||
public struct IconGeneratorView: View {
|
public struct IconGeneratorView: View {
|
||||||
let config: AppIconConfig
|
let config: AppIconConfig
|
||||||
let appName: String
|
let appName: String
|
||||||
|
|
||||||
@State private var status: String = "Tap the button to generate all icon variants"
|
@State private var status: String = "Tap the button to generate the app icon"
|
||||||
@State private var isGenerating = false
|
@State private var isGenerating = false
|
||||||
@State private var generatedIcons: [GeneratedIconInfo] = []
|
@State private var generatedIcon: GeneratedIconInfo?
|
||||||
|
|
||||||
// Standard iOS icon sizes (points)
|
// iOS 18+ only needs 1024x1024
|
||||||
private let iconSizes: [(size: CGFloat, scales: [Int], name: String)] = [
|
private let iconSize: CGFloat = 1024
|
||||||
(20, [2, 3], "Notification"),
|
|
||||||
(29, [2, 3], "Settings"),
|
|
||||||
(40, [2, 3], "Spotlight"),
|
|
||||||
(60, [2, 3], "App"),
|
|
||||||
(76, [1, 2], "iPad App"),
|
|
||||||
(83.5, [2], "iPad Pro App"),
|
|
||||||
(1024, [1], "Marketing")
|
|
||||||
]
|
|
||||||
|
|
||||||
// Development view: fixed sizes acceptable
|
// Development view: fixed sizes acceptable
|
||||||
private let previewSize: CGFloat = 200
|
private let previewSize: CGFloat = 200
|
||||||
@ -57,7 +51,7 @@ public struct IconGeneratorView: View {
|
|||||||
// Generate button
|
// Generate button
|
||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
await generateIcons()
|
await generateIcon()
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
@ -65,7 +59,7 @@ public struct IconGeneratorView: View {
|
|||||||
ProgressView()
|
ProgressView()
|
||||||
.tint(.white)
|
.tint(.white)
|
||||||
}
|
}
|
||||||
Text(isGenerating ? "Generating..." : "Generate & Save All Icons")
|
Text(isGenerating ? "Generating..." : "Generate & Save Icon")
|
||||||
}
|
}
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
@ -84,28 +78,26 @@ public struct IconGeneratorView: View {
|
|||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
// Generated icons list
|
// Generated icon
|
||||||
if !generatedIcons.isEmpty {
|
if let icon = generatedIcon {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Generated Files:")
|
Text("Generated File:")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
ForEach(generatedIcons) { icon in
|
HStack {
|
||||||
HStack {
|
Image(systemName: "checkmark.circle.fill")
|
||||||
Image(systemName: "checkmark.circle.fill")
|
.foregroundStyle(.green)
|
||||||
.foregroundStyle(.green)
|
Text(icon.filename)
|
||||||
Text(icon.filename)
|
.font(.caption.monospaced())
|
||||||
.font(.caption.monospaced())
|
Spacer()
|
||||||
Spacer()
|
Text("\(Int(icon.size))px")
|
||||||
Text("\(Int(icon.size))px")
|
.font(.caption)
|
||||||
.font(.caption)
|
.foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(Color.green.opacity(0.05))
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.green.opacity(0.05))
|
||||||
}
|
}
|
||||||
.padding(.vertical)
|
.padding(.vertical)
|
||||||
.background(Color.green.opacity(0.05))
|
.background(Color.green.opacity(0.05))
|
||||||
@ -130,16 +122,16 @@ public struct IconGeneratorView: View {
|
|||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
instructionRow(number: 1, text: "Open Files app on your device/simulator")
|
instructionRow(number: 1, text: "Open Files app on your device/simulator")
|
||||||
instructionRow(number: 2, text: "Navigate to: On My iPhone → \(appName)")
|
instructionRow(number: 2, text: "Navigate to: On My iPhone → \(appName)")
|
||||||
instructionRow(number: 3, text: "Find the generated PNG files")
|
instructionRow(number: 3, text: "Find the generated PNG file")
|
||||||
instructionRow(number: 4, text: "AirDrop or share to your Mac")
|
instructionRow(number: 4, text: "AirDrop or share to your Mac")
|
||||||
instructionRow(number: 5, text: "Drag into Xcode's Assets.xcassets/AppIcon")
|
instructionRow(number: 5, text: "Replace in Xcode's Assets.xcassets/AppIcon")
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Text("Note: Multiple sizes generated")
|
Text("iOS 18+ Single Icon")
|
||||||
.font(.subheadline.bold())
|
.font(.subheadline.bold())
|
||||||
Text("While modern iOS can use a single 1024px icon, providing specific sizes ensures the best quality for notifications, settings, and spotlight.")
|
Text("iOS 18+ only requires a single 1024x1024 icon. iOS automatically generates all other sizes for notifications, settings, and spotlight.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@ -160,43 +152,54 @@ public struct IconGeneratorView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func generateIcons() async {
|
private func generateIcon() async {
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
isGenerating = true
|
isGenerating = true
|
||||||
generatedIcons = []
|
generatedIcon = nil
|
||||||
status = "Generating icons..."
|
status = "Generating icon..."
|
||||||
|
|
||||||
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
let filename = "AppIcon-1024px.png"
|
||||||
|
|
||||||
for iconDef in iconSizes {
|
let view = AppIconView(config: config, size: iconSize)
|
||||||
for scale in iconDef.scales {
|
let renderer = ImageRenderer(content: view)
|
||||||
let pixelSize = iconDef.size * CGFloat(scale)
|
renderer.scale = 1.0
|
||||||
let filename = scale == 1 ? "AppIcon-\(iconDef.size)px.png" : "AppIcon-\(iconDef.size)px@\(scale)x.png"
|
|
||||||
|
if let uiImage = renderer.uiImage {
|
||||||
let view = AppIconView(config: config, size: pixelSize)
|
// Flatten onto opaque background to remove alpha channel
|
||||||
let renderer = ImageRenderer(content: view)
|
// Apple requires app icons to have no transparency
|
||||||
renderer.scale = 1.0 // We handle scale by pixel size
|
// Use scale 1.0 to ensure 1024x1024 pixels (not scaled by device)
|
||||||
|
let format = UIGraphicsImageRendererFormat()
|
||||||
if let uiImage = renderer.uiImage,
|
format.scale = 1.0
|
||||||
let data = uiImage.pngData() {
|
let opaqueImage = UIGraphicsImageRenderer(size: uiImage.size, format: format).image { context in
|
||||||
let fileURL = documentsPath.appending(path: filename)
|
UIColor.black.setFill()
|
||||||
|
context.fill(CGRect(origin: .zero, size: uiImage.size))
|
||||||
do {
|
uiImage.draw(at: .zero)
|
||||||
try data.write(to: fileURL)
|
|
||||||
generatedIcons.append(GeneratedIconInfo(filename: filename, size: pixelSize))
|
|
||||||
} catch {
|
|
||||||
status = "Error saving \(filename): \(error.localizedDescription)"
|
|
||||||
isGenerating = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let data = opaqueImage.pngData() {
|
||||||
|
let fileURL = documentsPath.appending(path: filename)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try data.write(to: fileURL)
|
||||||
|
generatedIcon = GeneratedIconInfo(filename: filename, size: iconSize)
|
||||||
|
status = "Icon saved to Documents folder!\nOpen Files app to find it."
|
||||||
|
#if DEBUG
|
||||||
|
print("Generated icon: \(fileURL.path) (Size: 1024x1024px, No alpha)")
|
||||||
|
#endif
|
||||||
|
} catch {
|
||||||
|
status = "Error saving icon: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status = "Error: Failed to create PNG data"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status = "Error: Failed to render icon"
|
||||||
}
|
}
|
||||||
|
|
||||||
status = "✅ \(generatedIcons.count) icons saved to Documents folder!\nOpen Files app to find them."
|
|
||||||
isGenerating = false
|
isGenerating = false
|
||||||
#else
|
#else
|
||||||
status = "⚠️ Icon generation is only available on iOS"
|
status = "Icon generation is only available on iOS"
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,6 +37,14 @@
|
|||||||
"comment" : "A heading for the instructions section of the IconGeneratorView.",
|
"comment" : "A heading for the instructions section of the IconGeneratorView.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"App Icon" : {
|
||||||
|
"comment" : "A heading for the app icon preview section.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"App Icon Preview" : {
|
||||||
|
"comment" : "A heading describing the preview of the app icon.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Appearance" : {
|
"Appearance" : {
|
||||||
"comment" : "Title for the theme picker setting."
|
"comment" : "Title for the theme picker setting."
|
||||||
},
|
},
|
||||||
@ -46,20 +54,16 @@
|
|||||||
"Dark" : {
|
"Dark" : {
|
||||||
"comment" : "Theme option for dark mode appearance."
|
"comment" : "Theme option for dark mode appearance."
|
||||||
},
|
},
|
||||||
"App Icon" : {
|
|
||||||
"comment" : "A heading for the app icon preview section.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"App Icon Preview" : {
|
|
||||||
"comment" : "A heading describing the preview of the app icon.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Export Instructions" : {
|
"Export Instructions" : {
|
||||||
"comment" : "A section header describing how to export app icons.",
|
"comment" : "A section header describing how to export app icons.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Generate & Save Icon" : {
|
"Generate & Save Icon" : {
|
||||||
"comment" : "A button label that triggers icon generation and saving.",
|
"comment" : "A button label that triggers the icon generation process.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Generated File:" : {
|
||||||
|
"comment" : "A label displayed above the name of the generated icon file.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Generating..." : {
|
"Generating..." : {
|
||||||
@ -74,6 +78,14 @@
|
|||||||
"comment" : "The title of the icon generator view.",
|
"comment" : "The title of the icon generator view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"iOS 18+ only requires a single 1024x1024 icon. iOS automatically generates all other sizes for notifications, settings, and spotlight." : {
|
||||||
|
"comment" : "A description of the iOS 18+ icon requirement.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"iOS 18+ Single Icon" : {
|
||||||
|
"comment" : "A section header explaining the single 1024x1024 icon requirement for iOS 18+.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Last synced %@" : {
|
"Last synced %@" : {
|
||||||
"comment" : "A description of the last time the settings were synced, using relative time formatting.",
|
"comment" : "A description of the last time the settings were synced, using relative time formatting.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -85,10 +97,6 @@
|
|||||||
"Light" : {
|
"Light" : {
|
||||||
"comment" : "Theme option for light mode appearance."
|
"comment" : "Theme option for light mode appearance."
|
||||||
},
|
},
|
||||||
"Note: iOS uses a single 1024px icon" : {
|
|
||||||
"comment" : "A note explaining that iOS uses a single 1024px icon.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Open Source Licenses" : {
|
"Open Source Licenses" : {
|
||||||
"comment" : "Title of the view that lists open source licenses.",
|
"comment" : "Title of the view that lists open source licenses.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -121,9 +129,6 @@
|
|||||||
"comment" : "Text indicating that the user's data is up-to-date and synced.",
|
"comment" : "Text indicating that the user's data is up-to-date and synced.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"System" : {
|
|
||||||
"comment" : "Theme option that follows the device system appearance."
|
|
||||||
},
|
|
||||||
"Syncing..." : {
|
"Syncing..." : {
|
||||||
"comment" : "Text displayed in the iCloud sync status label when the initial sync is not yet complete.",
|
"comment" : "Text displayed in the iCloud sync status label when the initial sync is not yet complete.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -132,6 +137,9 @@
|
|||||||
"comment" : "An accessibility hint for the iCloud sync settings section.",
|
"comment" : "An accessibility hint for the iCloud sync settings section.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"System" : {
|
||||||
|
"comment" : "Theme option that follows the device system appearance."
|
||||||
|
},
|
||||||
"To Export" : {
|
"To Export" : {
|
||||||
"comment" : "A section header explaining how to export branding assets.",
|
"comment" : "A section header explaining how to export branding assets.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -143,10 +151,6 @@
|
|||||||
"View on GitHub" : {
|
"View on GitHub" : {
|
||||||
"comment" : "A label describing an action to view the license on GitHub.",
|
"comment" : "A label describing an action to view the license on GitHub.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
|
||||||
"Xcode automatically generates all required sizes from the 1024px source." : {
|
|
||||||
"comment" : "A footnote explaining that Xcode automatically creates all necessary icon sizes from the original 1024px image.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.1"
|
"version" : "1.1"
|
||||||
|
|||||||
@ -114,23 +114,45 @@ public struct iCloudSyncSettingsView<ViewModel: CloudSyncable>: View {
|
|||||||
|
|
||||||
// Sync status (show when enabled and available)
|
// Sync status (show when enabled and available)
|
||||||
if viewModel.iCloudEnabled && viewModel.iCloudAvailable {
|
if viewModel.iCloudEnabled && viewModel.iCloudAvailable {
|
||||||
HStack(spacing: Design.Spacing.small) {
|
SyncStatusRow(
|
||||||
SymbolIcon(syncStatusIcon, size: .inline, color: syncStatusColor)
|
viewModel: viewModel,
|
||||||
|
accentColor: accentColor,
|
||||||
Text(syncStatusText).styled(.caption, emphasis: .tertiary)
|
successColor: successColor,
|
||||||
|
warningColor: warningColor
|
||||||
Spacer()
|
)
|
||||||
|
|
||||||
Button {
|
|
||||||
viewModel.forceSync()
|
|
||||||
} label: {
|
|
||||||
Text(String(localized: "Sync Now")).styled(.captionEmphasis, emphasis: .custom(accentColor))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top, Design.Spacing.xSmall)
|
.padding(.top, Design.Spacing.xSmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync Status Row
|
||||||
|
|
||||||
|
/// A row that displays sync status with auto-updating relative time.
|
||||||
|
private struct SyncStatusRow<ViewModel: CloudSyncable>: View {
|
||||||
|
@Bindable var viewModel: ViewModel
|
||||||
|
let accentColor: Color
|
||||||
|
let successColor: Color
|
||||||
|
let warningColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// TimelineView updates the relative time display every 5 seconds
|
||||||
|
TimelineView(.periodic(from: .now, by: 5)) { context in
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
SymbolIcon(syncStatusIcon, size: .inline, color: syncStatusColor)
|
||||||
|
|
||||||
|
Text(syncStatusText(at: context.date)).styled(.caption, emphasis: .tertiary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.forceSync()
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: "Sync Now")).styled(.captionEmphasis, emphasis: .custom(accentColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Sync Status Helpers
|
// MARK: - Sync Status Helpers
|
||||||
|
|
||||||
@ -148,7 +170,7 @@ public struct iCloudSyncSettingsView<ViewModel: CloudSyncable>: View {
|
|||||||
return successColor
|
return successColor
|
||||||
}
|
}
|
||||||
|
|
||||||
private var syncStatusText: String {
|
private func syncStatusText(at currentDate: Date) -> String {
|
||||||
if !viewModel.hasCompletedInitialSync {
|
if !viewModel.hasCompletedInitialSync {
|
||||||
return String(localized: "Syncing...")
|
return String(localized: "Syncing...")
|
||||||
}
|
}
|
||||||
@ -156,7 +178,7 @@ public struct iCloudSyncSettingsView<ViewModel: CloudSyncable>: View {
|
|||||||
if let lastSync = viewModel.lastSyncDate {
|
if let lastSync = viewModel.lastSyncDate {
|
||||||
let formatter = RelativeDateTimeFormatter()
|
let formatter = RelativeDateTimeFormatter()
|
||||||
formatter.unitsStyle = .abbreviated
|
formatter.unitsStyle = .abbreviated
|
||||||
return String(localized: "Last synced \(formatter.localizedString(for: lastSync, relativeTo: Date()))")
|
return String(localized: "Last synced \(formatter.localizedString(for: lastSync, relativeTo: currentDate))")
|
||||||
}
|
}
|
||||||
|
|
||||||
return viewModel.syncStatus.isEmpty
|
return viewModel.syncStatus.isEmpty
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user