From ceda52fe567b87c58c710de3e54fc40f11ad8747 Mon Sep 17 00:00:00 2001 From: Moustachauve <2206577+Moustachauve@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:47:31 -0400 Subject: [PATCH 1/2] feat: Add "What's New" changelog bottom sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a changelog system that automatically shows users what changed after an app update via a native iOS bottom sheet. - Add ChangelogProvider: reads versioned .md files from the Changelog/ bundle directory, filters by version range, and assembles a combined markdown string with section headers and dividers - Add ChangelogViewModel: tracks last-seen version in UserDefaults, suppresses display on first install, and exposes showAllChangelogs() for the Settings menu - Add ChangelogBottomSheet: native SwiftUI sheet with orange→pink→purple gradient title, sparkles icon, MarkdownUI rendering, and an 'Awesome' dismiss button - Add Changelog/ directory with 7.0.0.md, 7.1.0.md, dev.md (beta only), and CHANGELOG_GUIDE.md documenting the dev workflow - Integrate into DeviceListView: auto-shows after version upgrade - Add 'Show Changelog' button to Settings > About section - Add localizations for en and fr-CA (What's New, Awesome, Show Changelog, Version %@, Close) --- wled/Changelog/7.0.0.md | 17 ++ wled/Changelog/7.1.0.md | 17 ++ wled/Changelog/CHANGELOG_GUIDE.md | 21 +++ wled/Changelog/dev.md | 7 + wled/Localizable.xcstrings | 202 ++++++++++++++++-------- wled/Service/ChangelogProvider.swift | 177 +++++++++++++++++++++ wled/View/ChangelogBottomSheet.swift | 185 ++++++++++++++++++++++ wled/View/DeviceListView.swift | 21 ++- wled/View/Settings/Settings.swift | 13 +- wled/ViewModel/ChangelogViewModel.swift | 88 +++++++++++ 10 files changed, 679 insertions(+), 69 deletions(-) create mode 100644 wled/Changelog/7.0.0.md create mode 100644 wled/Changelog/7.1.0.md create mode 100644 wled/Changelog/CHANGELOG_GUIDE.md create mode 100644 wled/Changelog/dev.md create mode 100644 wled/Service/ChangelogProvider.swift create mode 100644 wled/View/ChangelogBottomSheet.swift create mode 100644 wled/ViewModel/ChangelogViewModel.swift 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..52368e0 --- /dev/null +++ b/wled/View/ChangelogBottomSheet.swift @@ -0,0 +1,185 @@ +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, 14) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.roundedRectangle(radius: 14)) + .controlSize(.large) + .padding(.horizontal, 20) + .padding(.bottom, 8) + .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) + } + } +} From ce5eb736cb034382359624ebcb09e0c9c5d55aef Mon Sep 17 00:00:00 2001 From: Moustachauve <2206577+Moustachauve@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:56:31 -0400 Subject: [PATCH 2/2] Tweak ChangelogBottomSheet padding Reduce vertical padding on the 'Awesome' button from 14 to 10 and add 16pt top padding to the bottom sheet container to improve visual spacing. Preserves existing horizontal and bottom paddings, button style, and background material. --- wled/View/ChangelogBottomSheet.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wled/View/ChangelogBottomSheet.swift b/wled/View/ChangelogBottomSheet.swift index 52368e0..6ad7624 100644 --- a/wled/View/ChangelogBottomSheet.swift +++ b/wled/View/ChangelogBottomSheet.swift @@ -130,13 +130,14 @@ struct ChangelogBottomSheet: View { Text("Awesome") .font(.headline.weight(.bold)) .frame(maxWidth: .infinity) - .padding(.vertical, 14) + .padding(.vertical, 10) } .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle(radius: 14)) .controlSize(.large) .padding(.horizontal, 20) .padding(.bottom, 8) + .padding(.top, 16) .background( Rectangle() .fill(.ultraThinMaterial)