diff --git a/wled/Changelog/7.0.0.md b/wled/Changelog/7.0.0.md new file mode 100644 index 0000000..fc36a47 --- /dev/null +++ b/wled/Changelog/7.0.0.md @@ -0,0 +1,17 @@ +### 👋 New Name, Same Magic +We’ve officially dropped the "Native" and are now just **WLED**! It’s the same app you know and love, just with a shorter, punchier name that gets right to the point. + +### ✹ A Fresh Coat of Paint +We’ve given the whole app a major makeover. From more vibrant colors and better contrast to a beautifully redesigned device list, everything is easier on the eyes and a joy to use. + +### 🚀 Lightning-Fast Updates +Checking your favorite device is now snappier than ever thanks to brand new real-time communication. You’ll see exactly what’s happening with your lights the moment it happens! + +### ⚙ Your New Control Center +We’ve introduced a dedicated Settings view to help you manage your app just the way you like it. Plus, adding new devices is now smoother and more intuitive. + +### 🧭 Effortless Navigation +Whether you're using a phone or a tablet, our new layout makes moving around the app feel like a breeze. We’ve even added a handy status indicator so you always know your lights are ready to glow. + +### đŸ› ïž Solid as a Rock +We’ve done a massive "under-the-hood" spring cleaning. We modernized the code, squashed some tricky bugs, and improved how the app handles your data to ensure everything runs perfectly. diff --git a/wled/Changelog/7.1.0.md b/wled/Changelog/7.1.0.md new file mode 100644 index 0000000..8de574f --- /dev/null +++ b/wled/Changelog/7.1.0.md @@ -0,0 +1,17 @@ +### đŸ€ Always Connected +Ever feel like the app was ignoring you the moment you looked away? We’ve fixed a pesky bug where the connection would drop too fast when you switched apps or peeked at the app switcher. Now, your lights stay ready and waiting for you! + +### đŸ—ș "Found You!" +Lost in the dark? We’ve added a helpful warning banner to let you know if the app is missing its "Local Network" permission. It’s like a little compass to help the app find your devices again. + +### đŸŽïž Zoom Zoom +Your device list just got a turbocharger! Loading, scrolling, filtering, and sorting should all feel much snappier and smoother now. No more waiting around for the glow to show! + +### 🆙 Better and Better +Teaching your lights new tricks is easier than ever. We’ve added support for installing over-the-air (OTA) updates on devices running WLED 0.16.0 and newer. Fresh features, incoming! + +### đŸ“± iOS 16 Love +To our friends on iOS 16: we fixed that awkward moment where trying to toggle the power felt like a wrestling match with your screen. Toggling is now as easy as a gentle tap, just like it should be. + +### đŸ§č Spring Cleaning +We’ve been busy behind the scenes scrubbing away some code cobwebs and making everything extra stable. It’s the same WLED you love, just with a little more polish and shine! diff --git a/wled/Changelog/CHANGELOG_GUIDE.md b/wled/Changelog/CHANGELOG_GUIDE.md new file mode 100644 index 0000000..2d6031d --- /dev/null +++ b/wled/Changelog/CHANGELOG_GUIDE.md @@ -0,0 +1,21 @@ +# Changelog Directory + +This directory contains markdown versions of the "What's New" release notes. + +## Rules + +1. Files should be named with their version in SemVer format. Examples: `7.1.0.md`, `v7.1.0.md` or `v8.0.0-beta.md`. +2. Do not include large headers inside the Markdown since the app automatically prepends a header with the version name. +3. Keep it brief and focused on new features! +4. It's recommended to include an empty line between bullets or paragraphs for better readability when rendered. +5. Try to keep the latest changelog up to date with your changes when creating a new PR. + +## Development Changelog + +When submitting new code to the repository via Pull Requests, **always append your changes to the `dev.md` file.** +Do **NOT** create a new versioned markdown file or append to an existing production version file unless explicitly asked +to do so during a release phase. + +**Release Protocol:** +Before a final production build is compiled and released, a maintainer **must** rename `dev.md` to the targeted Semantic +Version string (e.g. `dev.md` -> `7.2.0.md`). The file `dev.md` should not be included in stable releases. diff --git a/wled/Changelog/dev.md b/wled/Changelog/dev.md new file mode 100644 index 0000000..250243d --- /dev/null +++ b/wled/Changelog/dev.md @@ -0,0 +1,7 @@ +### Latest Dev Changes + +This update brings you: +- The highly-anticipated updates changelog (the one you are reading right now!) +- Several bug fixes and performance improvements behind the scenes. + +Enjoy your lights! diff --git a/wled/Localizable.xcstrings b/wled/Localizable.xcstrings index cd1c150..78a906f 100644 --- a/wled/Localizable.xcstrings +++ b/wled/Localizable.xcstrings @@ -253,6 +253,23 @@ } } }, + "Awesome" : { + "comment" : "Dismiss button label on the changelog bottom sheet.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Awesome" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "GĂ©nial" + } + } + } + }, "Beta" : { "extractionState" : "manual", "localizations" : { @@ -324,6 +341,23 @@ } } }, + "Close" : { + "comment" : "Accessibility label for the close button on the changelog sheet.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fermer" + } + } + } + }, "Controls" : { "extractionState" : "manual", "localizations" : { @@ -698,6 +732,57 @@ } } }, + "Local Network Access Required" : { + "comment" : "Title of the warning banner that appears when the local network permission has been denied.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Local Network Access Required" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "AccĂšs au rĂ©seau local requis" + } + } + } + }, + "local_network_instructions" : { + "comment" : "Step-by-step instructions for enabling local network permission in iOS Settings.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Go to Settings → WLED → Local Network" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aller dans RĂ©glages → WLED → RĂ©seau local" + } + } + } + }, + "local_network_warning_body" : { + "comment" : "Body text of the warning banner explaining why local network access is needed.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "WLED needs Local Network access to discover and control your devices. Please enable it in Settings." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "WLED a besoin de l'accĂšs au rĂ©seau local pour dĂ©couvrir et contrĂŽler vos appareils. Veuillez l'activer dans les RĂ©glages." + } + } + } + }, "Mac Address: %@" : { "comment" : "A label displaying the MAC address of a device.", "isCommentAutoGenerated" : true, @@ -857,6 +942,23 @@ } } }, + "Open Settings" : { + "comment" : "Button label that opens the app's Settings page so the user can enable Local Network permission.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Settings" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir les RĂ©glages" + } + } + } + }, "Please do not close the app or turn off the device." : { "comment" : "Additional instructions to show to the user while a software update is downloading.", "isCommentAutoGenerated" : true, @@ -1013,6 +1115,23 @@ } } }, + "Show Changelog" : { + "comment" : "A button in the About section of Settings that shows the full changelog history.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Changelog" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher les nouveautĂ©s" + } + } + } + }, "Show Hidden Devices" : { "comment" : "A button label that toggles the visibility of hidden devices.", "isCommentAutoGenerated" : true, @@ -1476,122 +1595,71 @@ } } }, - "WLED Documentation" : { - "comment" : "A menu item that links to WLED's documentation.", - "isCommentAutoGenerated" : true, + "What's New" : { + "comment" : "Title of the changelog bottom sheet shown after an app update.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "WLED Documentation" + "value" : "What's New" } }, "fr-CA" : { "stringUnit" : { "state" : "translated", - "value" : "Documentation WLED" + "value" : "NouveautĂ©s" } } } }, - "You don't have any visible devices" : { - "comment" : "A label displayed when a user has no visible devices.", + "WLED Documentation" : { + "comment" : "A menu item that links to WLED's documentation.", "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "You don't have any visible devices" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vous n’avez aucun appareil visible" - } - } - } - }, - "Your device is up to date" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Your device is up to date" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre appareil est Ă  jour" - } - } - } - }, - "Local Network Access Required" : { - "comment" : "Title of the warning banner that appears when the local network permission has been denied.", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Local Network Access Required" - } - }, - "fr-CA" : { - "stringUnit" : { - "state" : "translated", - "value" : "AccĂšs au rĂ©seau local requis" - } - } - } - }, - "local_network_warning_body" : { - "comment" : "Body text of the warning banner explaining why local network access is needed.", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "WLED needs Local Network access to discover and control your devices. Please enable it in Settings." + "value" : "WLED Documentation" } }, "fr-CA" : { "stringUnit" : { "state" : "translated", - "value" : "WLED a besoin de l'accĂšs au rĂ©seau local pour dĂ©couvrir et contrĂŽler vos appareils. Veuillez l'activer dans les RĂ©glages." + "value" : "Documentation WLED" } } } }, - "local_network_instructions" : { - "comment" : "Step-by-step instructions for enabling local network permission in iOS Settings.", + "You don't have any visible devices" : { + "comment" : "A label displayed when a user has no visible devices.", + "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Go to Settings → WLED → Local Network" + "value" : "You don't have any visible devices" } }, "fr-CA" : { "stringUnit" : { "state" : "translated", - "value" : "Aller dans RĂ©glages → WLED → RĂ©seau local" + "value" : "Vous n’avez aucun appareil visible" } } } }, - "Open Settings" : { - "comment" : "Button label that opens the app's Settings page so the user can enable Local Network permission.", + "Your device is up to date" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Open Settings" + "value" : "Your device is up to date" } }, "fr-CA" : { "stringUnit" : { "state" : "translated", - "value" : "Ouvrir les RĂ©glages" + "value" : "Votre appareil est Ă  jour" } } } diff --git a/wled/Service/ChangelogProvider.swift b/wled/Service/ChangelogProvider.swift new file mode 100644 index 0000000..43d06e5 --- /dev/null +++ b/wled/Service/ChangelogProvider.swift @@ -0,0 +1,177 @@ +import Foundation +import OSLog + +/// Reads and assembles changelog markdown from the app bundle's `Changelog` folder. +/// +/// Changelog files are named using semantic versioning (e.g. `7.0.0.md`, `7.1.0.md`). +/// The provider filters files to show only versions newer than the user's last-seen +/// version up to the current app version, then concatenates them into a single +/// markdown string with version headers. +final class ChangelogProvider { + + // MARK: - Constants + + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "ca.cgagnier.wled-native", + category: "ChangelogProvider" + ) + private static let changelogDirectory = "Changelog" + private static let markdownExtension = "md" + private static let devFilename = "dev" + private static let defaultVersion = "0.0.0" + + // MARK: - Public API + + /// Returns assembled markdown for all changelog entries between `lastSeenVersion` + /// and `currentVersion`, or `nil` if there are no matching entries. + /// + /// - Parameters: + /// - lastSeenVersionStr: The version string the user last saw (e.g. `"7.0.0"`). + /// - currentVersionStr: The current app version string (e.g. `"7.1.0"`). + /// - Returns: A combined markdown string, or `nil` if no changelogs apply. + func getChangelog(lastSeenVersion lastSeenVersionStr: String, currentVersion currentVersionStr: String) -> String? { + guard let currentVersion = SemanticVersion(currentVersionStr) else { + Self.logger.error("Invalid current version string: \(lastSeenVersionStr)") + return nil + } + let lastSeenVersion = SemanticVersion(lastSeenVersionStr) ?? SemanticVersion(Self.defaultVersion)! + + let validChangelogs = getValidChangelogs(lastSeenVersion: lastSeenVersion, currentVersion: currentVersion) + guard !validChangelogs.isEmpty else { + return nil + } + + return buildChangelogContent(validChangelogs) + } + + // MARK: - Private Helpers + + private func getValidChangelogs(lastSeenVersion: SemanticVersion, currentVersion: SemanticVersion) -> [ChangelogFile] { + guard let changelogURL = Bundle.main.url(forResource: nil, withExtension: nil, subdirectory: Self.changelogDirectory), + let contents = try? FileManager.default.contentsOfDirectory(at: changelogURL, includingPropertiesForKeys: nil) + else { + // Try alternative: look for files in the Changelog folder reference + return getValidChangelogsFromBundle(lastSeenVersion: lastSeenVersion, currentVersion: currentVersion) + } + + let isBeta = currentVersion.preRelease != nil + var validFiles: [ChangelogFile] = [] + + // Include dev.md for beta builds + if isBeta { + let devURL = contents.first { $0.deletingPathExtension().lastPathComponent == Self.devFilename } + if devURL != nil { + validFiles.append(ChangelogFile( + fileVersion: SemanticVersion("999.0.0")!, + filename: "\(Self.devFilename).\(Self.markdownExtension)", + displayVersion: "Dev" + )) + } + } + + for fileURL in contents { + let filename = fileURL.lastPathComponent + guard fileURL.pathExtension == Self.markdownExtension else { continue } + let versionPart = fileURL.deletingPathExtension().lastPathComponent + guard versionPart != Self.devFilename, + versionPart != "README" else { continue } + + guard let fileVersion = SemanticVersion(versionPart) else { continue } + + if fileVersion > lastSeenVersion && fileVersion <= currentVersion { + validFiles.append(ChangelogFile(fileVersion: fileVersion, filename: filename)) + } + } + + return validFiles.sorted { $0.fileVersion > $1.fileVersion } + } + + /// Fallback approach: look for changelog files using `Bundle.main.paths`. + private func getValidChangelogsFromBundle(lastSeenVersion: SemanticVersion, currentVersion: SemanticVersion) -> [ChangelogFile] { + let paths = Bundle.main.paths(forResourcesOfType: Self.markdownExtension, inDirectory: nil) + let isBeta = currentVersion.preRelease != nil + var validFiles: [ChangelogFile] = [] + + for path in paths { + let url = URL(fileURLWithPath: path) + let versionPart = url.deletingPathExtension().lastPathComponent + + if versionPart == Self.devFilename { + if isBeta { + validFiles.append(ChangelogFile( + fileVersion: SemanticVersion("999.0.0")!, + filename: url.lastPathComponent, + displayVersion: "Dev" + )) + } + continue + } + + guard versionPart != "README" && versionPart != "CHANGELOG_GUIDE" else { continue } + guard let fileVersion = SemanticVersion(versionPart) else { continue } + + if fileVersion > lastSeenVersion && fileVersion <= currentVersion { + validFiles.append(ChangelogFile(fileVersion: fileVersion, filename: url.lastPathComponent)) + } + } + + return validFiles.sorted { $0.fileVersion > $1.fileVersion } + } + + private func buildChangelogContent(_ validChangelogs: [ChangelogFile]) -> String { + var parts: [String] = [] + + for changelogFile in validChangelogs { + guard let content = readChangelogFile(changelogFile.filename) else { continue } + + let versionHeader = "# " + String(localized: "Version \(changelogFile.displayVersion)") + // Add extra spacing before sub-headers for readability + let spacedContent = content + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences( + of: "(?m)^(#{1,6} )", + with: "\n$1", + options: .regularExpression + ) + + parts.append("\(versionHeader)\n\n\(spacedContent)") + } + + return parts.joined(separator: "\n\n---\n\n") + } + + private func readChangelogFile(_ filename: String) -> String? { + // Try reading from the Changelog subdirectory, fallback to main bundle + let path = Bundle.main.path( + forResource: (filename as NSString).deletingPathExtension, + ofType: Self.markdownExtension, + inDirectory: Self.changelogDirectory + ) ?? Bundle.main.path( + forResource: (filename as NSString).deletingPathExtension, + ofType: Self.markdownExtension + ) + + if let path = path { + do { + return try String(contentsOfFile: path, encoding: .utf8) + } catch { + Self.logger.error("Failed to read changelog file \(filename): \(error.localizedDescription)") + } + } + return nil + } + + // MARK: - Types + + private struct ChangelogFile { + let fileVersion: SemanticVersion + let filename: String + let displayVersion: String + + init(fileVersion: SemanticVersion, filename: String, displayVersion: String? = nil) { + self.fileVersion = fileVersion + self.filename = filename + self.displayVersion = displayVersion ?? "\(fileVersion.major).\(fileVersion.minor).\(fileVersion.patch)" + } + } +} diff --git a/wled/View/ChangelogBottomSheet.swift b/wled/View/ChangelogBottomSheet.swift new file mode 100644 index 0000000..6ad7624 --- /dev/null +++ b/wled/View/ChangelogBottomSheet.swift @@ -0,0 +1,186 @@ +import SwiftUI +import MarkdownUI + +/// A "What's New" bottom sheet that presents changelog content with a +/// vibrant, native iOS design featuring animated gradient accents and +/// smooth transitions. +struct ChangelogBottomSheet: View { + @ObservedObject var viewModel: ChangelogViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + titleSection + changelogBody + } + .padding(.horizontal, 20) + .padding(.bottom, 32) + } + .safeAreaInset(edge: .bottom) { + dismissButton + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + viewModel.dismiss() + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .font(.title2) + } + .accessibilityLabel(Text("Close")) + } + } + .navigationBarTitleDisplayMode(.inline) + } + } + + // MARK: - Title Section + + private var titleSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + // Sparkle icon with animated gradient + sparkleIcon + + Text("What's New") + .font(.system(size: 34, weight: .black, design: .rounded)) + .foregroundStyle(titleGradient) + } + } + .padding(.top, 8) + } + + @ViewBuilder + private var sparkleIcon: some View { + let icon = Image(systemName: "sparkles") + .font(.system(size: 40, weight: .bold)) + .foregroundStyle(titleGradient) + + if #available(iOS 17.0, *) { + icon.symbolEffect(.pulse, options: .repeating.speed(0.5)) + } else { + icon + } + } + + private var titleGradient: LinearGradient { + LinearGradient( + colors: [ + Color.orange, + Color.pink, + Color.purple + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + // MARK: - Changelog Body + + @ViewBuilder + private var changelogBody: some View { + if let content = viewModel.changelogContent { + Markdown(content) + .markdownBlockStyle(\.heading1) { configuration in + configuration.label + .markdownMargin(top: 16, bottom: 8) + .markdownTextStyle { + FontWeight(.heavy) + ForegroundColor(.primary) + FontSize(24) + } + } + .markdownBlockStyle(\.heading2) { configuration in + configuration.label + .markdownMargin(top: 12, bottom: 6) + .markdownTextStyle { + FontWeight(.bold) + ForegroundColor(.primary) + FontSize(20) + } + } + .markdownBlockStyle(\.heading3) { configuration in + configuration.label + .markdownMargin(top: 8, bottom: 4) + .markdownTextStyle { + FontWeight(.semibold) + ForegroundColor(.secondary) + FontSize(17) + } + } + .markdownBlockStyle(\.thematicBreak) { _ in + Divider() + .padding(.vertical, 12) + } + } + } + + // MARK: - Dismiss Button + + private var dismissButton: some View { + Button { + viewModel.dismiss() + dismiss() + } label: { + Text("Awesome") + .font(.headline.weight(.bold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.roundedRectangle(radius: 14)) + .controlSize(.large) + .padding(.horizontal, 20) + .padding(.bottom, 8) + .padding(.top, 16) + .background( + Rectangle() + .fill(.ultraThinMaterial) + .ignoresSafeArea() + ) + } +} + +#Preview("With Content") { + Text("Background") + .sheet(isPresented: .constant(true)) { + ChangelogBottomSheet( + viewModel: { + let vm = ChangelogViewModel() + vm.changelogContent = """ + # Version 42.0.0 + + ### 🧠 Telepathic Toggling + Why use your thumbs when you can use your **brainwaves**? We've added (highly experimental) support for telepathic light control. Just think about "Orange" and watch the magic happen! + > *Disclaimer: May cause sudden cravings for tacos.* + + ### 🛾 Anti-Gravity Mode + App navigation now automatically adjusts its orientation when you are in **zero-gravity environments**. Perfect for those late-night ISS lighting adjustments. + + --- + + # Version 1.2.3-BETA + + ### 👃 Scent-Sync (Beta) + Added support for the upcoming *WLED-Scent* hardware. + - **Fireplace effect**: Smells like toasted marshmallows. + - **Ocean wave**: Smells like salty sea air. + - **Rainbow**: Smells like... well, we're still working on that one. + + ### 🐛 Bug Squashing + - Fixed an issue where the app would accidentally summon a **minor storm cloud** when the brightness was set to exactly 11%. + - Improved connectivity for users currently roaming in **parallel dimensions**. + - General `under-the-hood` polish and stability for the space-time continuum. + """ + return vm + }() + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } +} diff --git a/wled/View/DeviceListView.swift b/wled/View/DeviceListView.swift index 231242b..03938ec 100644 --- a/wled/View/DeviceListView.swift +++ b/wled/View/DeviceListView.swift @@ -12,8 +12,11 @@ struct DeviceListView: View { @State private var addDeviceButtonActive: Bool = false @State private var showSettingsSheet: Bool = false + @State private var showChangelogSheet: Bool = false @State private var columnVisibility = NavigationSplitViewVisibility.doubleColumn + @StateObject private var changelogViewModel = ChangelogViewModel() + @AppStorage("lastSelectedDeviceMac") private var lastSelectedDeviceMac: String = "" private var hasHiddenDevices: Bool { @@ -46,15 +49,31 @@ struct DeviceListView: View { .sheet(isPresented: $showSettingsSheet) { Settings( showHiddenDevices: $viewModel.showHiddenDevices, - showOfflineDevices: $viewModel.showOfflineDevices + showOfflineDevices: $viewModel.showOfflineDevices, + showChangelog: { + changelogViewModel.showAllChangelogs() + showChangelogSheet = true + } ) } + .sheet(isPresented: $showChangelogSheet, onDismiss: { + changelogViewModel.dismiss() + }) { + ChangelogBottomSheet(viewModel: changelogViewModel) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } .navigationBarTitleDisplayMode(.inline) } detail: { detailView } .navigationSplitViewStyle(.balanced) .onAppear(perform: appearAction) + .onReceive(changelogViewModel.$changelogContent) { content in + if content != nil && !showChangelogSheet { + showChangelogSheet = true + } + } .onChange(of: scenePhase) { newPhase in switch newPhase { case .active: diff --git a/wled/View/Settings/Settings.swift b/wled/View/Settings/Settings.swift index 5604713..f008fe4 100644 --- a/wled/View/Settings/Settings.swift +++ b/wled/View/Settings/Settings.swift @@ -10,6 +10,7 @@ import SwiftUI struct Settings: View { @Binding var showHiddenDevices: Bool @Binding var showOfflineDevices: Bool + var showChangelog: () -> Void = {} // Environment to dismiss the sheet @Environment(\.dismiss) private var dismiss @@ -33,6 +34,13 @@ struct Settings: View { Label("WLED Documentation", systemImage: "questionmark.circle") } } + + Button { + dismiss() + showChangelog() + } label: { + Label("Show Changelog", systemImage: "sparkles") + } } header: { Text("About") } footer: { @@ -64,6 +72,9 @@ struct Settings: View { #Preview { Settings( - showHiddenDevices: .constant(true), showOfflineDevices: .constant(true) + showHiddenDevices: .constant(true), + showOfflineDevices: .constant(true), + showChangelog: {} ) } + diff --git a/wled/ViewModel/ChangelogViewModel.swift b/wled/ViewModel/ChangelogViewModel.swift new file mode 100644 index 0000000..0ccc858 --- /dev/null +++ b/wled/ViewModel/ChangelogViewModel.swift @@ -0,0 +1,88 @@ +import Foundation +import OSLog + +/// Manages changelog display state, tracking which version the user last saw +/// and determining whether to present the "What's New" sheet. +/// +/// On init, automatically checks if there are new changelogs to show. +/// On first install (no stored version), saves the current version silently. +final class ChangelogViewModel: ObservableObject { + + // MARK: - Constants + + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "ca.cgagnier.wled-native", + category: "ChangelogViewModel" + ) + private static let lastChangelogVersionKey = "lastChangelogVersionSeen" + + // MARK: - Published State + + /// The assembled markdown content to display. Non-nil means the sheet should be shown. + @Published var changelogContent: String? + + // MARK: - Dependencies + + private let changelogProvider: ChangelogProvider + private let currentVersion: String + + // MARK: - Init + + init(changelogProvider: ChangelogProvider = ChangelogProvider()) { + self.changelogProvider = changelogProvider + self.currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + + checkChangelog() + } + + // MARK: - Public API + + /// Dismisses the changelog sheet and saves the current version as "last seen". + func dismiss() { + UserDefaults.standard.set(currentVersion, forKey: Self.lastChangelogVersionKey) + changelogContent = nil + } + + /// Shows all changelogs from the beginning of time (for the Settings menu). + func showAllChangelogs() { + let content = changelogProvider.getChangelog( + lastSeenVersion: "0.0.0", + currentVersion: currentVersion + ) + if let content, !content.isEmpty { + changelogContent = content + } + } + + // MARK: - Private + + private func checkChangelog() { + let lastSeenVersion = UserDefaults.standard.string(forKey: Self.lastChangelogVersionKey) ?? "" + + if lastSeenVersion.isEmpty { + // First install — don't show changelog, just save the current version + Self.logger.info("First install detected, saving current version \(self.currentVersion) as last seen") + UserDefaults.standard.set(currentVersion, forKey: Self.lastChangelogVersionKey) + return + } + + if lastSeenVersion == currentVersion { + // Already saw the latest version's changelog + return + } + + Self.logger.info("Version changed from \(lastSeenVersion) to \(self.currentVersion), checking changelogs") + + let content = changelogProvider.getChangelog( + lastSeenVersion: lastSeenVersion, + currentVersion: currentVersion + ) + + if let content, !content.isEmpty { + changelogContent = content + } else { + // No changelogs to show, but version changed — save so we don't check again + UserDefaults.standard.set(currentVersion, forKey: Self.lastChangelogVersionKey) + } + } +}