diff --git a/Sources/Bedrock/Branding/IconGeneratorView.swift b/Sources/Bedrock/Branding/IconGeneratorView.swift index 5a19574..789c766 100644 --- a/Sources/Bedrock/Branding/IconGeneratorView.swift +++ b/Sources/Bedrock/Branding/IconGeneratorView.swift @@ -2,32 +2,26 @@ // IconGeneratorView.swift // Bedrock // -// Development tool to generate and export app icon images. -// Run this view, tap the button, then find the icons in the Files app. +// Development tool to generate and export the app icon image. +// Run this view, tap the button, then find the icon in the Files app. // import SwiftUI -/// A development view that generates and saves app icon images. -/// After running, find the icons in Files app → On My iPhone → [App Name] +/// A development view that generates and saves the app icon image. +/// 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 { let config: AppIconConfig 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 generatedIcons: [GeneratedIconInfo] = [] + @State private var generatedIcon: GeneratedIconInfo? - // Standard iOS icon sizes (points) - private let iconSizes: [(size: CGFloat, scales: [Int], name: String)] = [ - (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") - ] + // iOS 18+ only needs 1024x1024 + private let iconSize: CGFloat = 1024 // Development view: fixed sizes acceptable private let previewSize: CGFloat = 200 @@ -57,7 +51,7 @@ public struct IconGeneratorView: View { // Generate button Button { Task { - await generateIcons() + await generateIcon() } } label: { HStack { @@ -65,7 +59,7 @@ public struct IconGeneratorView: View { ProgressView() .tint(.white) } - Text(isGenerating ? "Generating..." : "Generate & Save All Icons") + Text(isGenerating ? "Generating..." : "Generate & Save Icon") } .font(.headline) .foregroundStyle(.white) @@ -84,28 +78,26 @@ public struct IconGeneratorView: View { .multilineTextAlignment(.center) .padding(.horizontal) - // Generated icons list - if !generatedIcons.isEmpty { + // Generated icon + if let icon = generatedIcon { VStack(alignment: .leading, spacing: 8) { - Text("Generated Files:") + Text("Generated File:") .font(.headline) .padding(.horizontal) - ForEach(generatedIcons) { icon in - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text(icon.filename) - .font(.caption.monospaced()) - Spacer() - Text("\(Int(icon.size))px") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.horizontal) - .padding(.vertical, 4) - .background(Color.green.opacity(0.05)) + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text(icon.filename) + .font(.caption.monospaced()) + Spacer() + Text("\(Int(icon.size))px") + .font(.caption) + .foregroundStyle(.secondary) } + .padding(.horizontal) + .padding(.vertical, 4) + .background(Color.green.opacity(0.05)) } .padding(.vertical) .background(Color.green.opacity(0.05)) @@ -130,16 +122,16 @@ public struct IconGeneratorView: View { VStack(alignment: .leading, spacing: 8) { instructionRow(number: 1, text: "Open Files app on your device/simulator") 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: 5, text: "Drag into Xcode's Assets.xcassets/AppIcon") + instructionRow(number: 5, text: "Replace in Xcode's Assets.xcassets/AppIcon") } Divider() - Text("Note: Multiple sizes generated") + Text("iOS 18+ Single Icon") .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) .foregroundStyle(.secondary) } @@ -160,43 +152,54 @@ public struct IconGeneratorView: View { } @MainActor - private func generateIcons() async { + private func generateIcon() async { #if canImport(UIKit) isGenerating = true - generatedIcons = [] - status = "Generating icons..." + generatedIcon = nil + status = "Generating icon..." let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let filename = "AppIcon-1024px.png" - for iconDef in iconSizes { - for scale in iconDef.scales { - let pixelSize = iconDef.size * CGFloat(scale) - let filename = scale == 1 ? "AppIcon-\(iconDef.size)px.png" : "AppIcon-\(iconDef.size)px@\(scale)x.png" - - let view = AppIconView(config: config, size: pixelSize) - let renderer = ImageRenderer(content: view) - renderer.scale = 1.0 // We handle scale by pixel size - - if let uiImage = renderer.uiImage, - let data = uiImage.pngData() { - let fileURL = documentsPath.appending(path: filename) - - do { - try data.write(to: fileURL) - generatedIcons.append(GeneratedIconInfo(filename: filename, size: pixelSize)) - } catch { - status = "Error saving \(filename): \(error.localizedDescription)" - isGenerating = false - return - } - } + let view = AppIconView(config: config, size: iconSize) + let renderer = ImageRenderer(content: view) + renderer.scale = 1.0 + + if let uiImage = renderer.uiImage { + // Flatten onto opaque background to remove alpha channel + // Apple requires app icons to have no transparency + // Use scale 1.0 to ensure 1024x1024 pixels (not scaled by device) + let format = UIGraphicsImageRendererFormat() + format.scale = 1.0 + let opaqueImage = UIGraphicsImageRenderer(size: uiImage.size, format: format).image { context in + UIColor.black.setFill() + context.fill(CGRect(origin: .zero, size: uiImage.size)) + uiImage.draw(at: .zero) } + + 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 #else - status = "⚠️ Icon generation is only available on iOS" + status = "Icon generation is only available on iOS" #endif } } diff --git a/Sources/Bedrock/Resources/Localizable.xcstrings b/Sources/Bedrock/Resources/Localizable.xcstrings index 6ef5a00..aef8062 100644 --- a/Sources/Bedrock/Resources/Localizable.xcstrings +++ b/Sources/Bedrock/Resources/Localizable.xcstrings @@ -37,6 +37,14 @@ "comment" : "A heading for the instructions section of the IconGeneratorView.", "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" : { "comment" : "Title for the theme picker setting." }, @@ -46,20 +54,16 @@ "Dark" : { "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" : { "comment" : "A section header describing how to export app icons.", "isCommentAutoGenerated" : true }, "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 }, "Generating..." : { @@ -74,6 +78,14 @@ "comment" : "The title of the icon generator view.", "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 %@" : { "comment" : "A description of the last time the settings were synced, using relative time formatting.", "isCommentAutoGenerated" : true @@ -85,10 +97,6 @@ "Light" : { "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" : { "comment" : "Title of the view that lists open source licenses.", "isCommentAutoGenerated" : true @@ -121,9 +129,6 @@ "comment" : "Text indicating that the user's data is up-to-date and synced.", "isCommentAutoGenerated" : true }, - "System" : { - "comment" : "Theme option that follows the device system appearance." - }, "Syncing..." : { "comment" : "Text displayed in the iCloud sync status label when the initial sync is not yet complete.", "isCommentAutoGenerated" : true @@ -132,6 +137,9 @@ "comment" : "An accessibility hint for the iCloud sync settings section.", "isCommentAutoGenerated" : true }, + "System" : { + "comment" : "Theme option that follows the device system appearance." + }, "To Export" : { "comment" : "A section header explaining how to export branding assets.", "isCommentAutoGenerated" : true @@ -143,10 +151,6 @@ "View on GitHub" : { "comment" : "A label describing an action to view the license on GitHub.", "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" diff --git a/Sources/Bedrock/Views/Settings/iCloudSyncSettingsView.swift b/Sources/Bedrock/Views/Settings/iCloudSyncSettingsView.swift index 00ee1a5..3bb1168 100644 --- a/Sources/Bedrock/Views/Settings/iCloudSyncSettingsView.swift +++ b/Sources/Bedrock/Views/Settings/iCloudSyncSettingsView.swift @@ -114,23 +114,45 @@ public struct iCloudSyncSettingsView: View { // Sync status (show when enabled and available) if viewModel.iCloudEnabled && viewModel.iCloudAvailable { - HStack(spacing: Design.Spacing.small) { - SymbolIcon(syncStatusIcon, size: .inline, color: syncStatusColor) - - Text(syncStatusText).styled(.caption, emphasis: .tertiary) - - Spacer() - - Button { - viewModel.forceSync() - } label: { - Text(String(localized: "Sync Now")).styled(.captionEmphasis, emphasis: .custom(accentColor)) - } - } + SyncStatusRow( + viewModel: viewModel, + accentColor: accentColor, + successColor: successColor, + warningColor: warningColor + ) .padding(.top, Design.Spacing.xSmall) } } } +} + +// MARK: - Sync Status Row + +/// A row that displays sync status with auto-updating relative time. +private struct SyncStatusRow: 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 @@ -148,7 +170,7 @@ public struct iCloudSyncSettingsView: View { return successColor } - private var syncStatusText: String { + private func syncStatusText(at currentDate: Date) -> String { if !viewModel.hasCompletedInitialSync { return String(localized: "Syncing...") } @@ -156,7 +178,7 @@ public struct iCloudSyncSettingsView: View { if let lastSync = viewModel.lastSyncDate { let formatter = RelativeDateTimeFormatter() 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