diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2023-03-24 13:13:00 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2023-03-27 16:35:56 +0200 |
| commit | b5b791def7f83af5fc0fd7c395850dcd00b16008 (patch) | |
| tree | d2747a4c95f92f126076621b11b34e972cc691f6 | |
| parent | e86a962ce095e145154f6431477e589e4a3013ac (diff) | |
| download | mullvadvpn-b5b791def7f83af5fc0fd7c395850dcd00b16008.tar.xz mullvadvpn-b5b791def7f83af5fc0fd7c395850dcd00b16008.zip | |
Add changelog
| -rw-r--r-- | gui/changes.txt | 1 | ||||
| -rw-r--r-- | ios/Assets/changes.txt | 5 | ||||
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 28 | ||||
| -rw-r--r-- | ios/MullvadVPN/Classes/ChangeLog.swift | 64 | ||||
| -rw-r--r-- | ios/MullvadVPN/Coordinators/App/ApplicationCoordinator.swift | 28 | ||||
| -rw-r--r-- | ios/MullvadVPN/Coordinators/App/ApplicationRouter.swift | 4 | ||||
| -rw-r--r-- | ios/MullvadVPN/Coordinators/App/ChangeLogCoordinator.swift | 46 | ||||
| -rw-r--r-- | ios/MullvadVPN/Extensions/Bundle+ProductVersion.swift | 5 | ||||
| -rw-r--r-- | ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift | 273 | ||||
| -rw-r--r-- | ios/MullvadVPN/View controllers/ChangeLog/ChangeLogContentView.swift | 136 | ||||
| -rw-r--r-- | ios/MullvadVPN/View controllers/ChangeLog/ChangeLogViewController.swift | 49 |
11 files changed, 508 insertions, 131 deletions
diff --git a/gui/changes.txt b/gui/changes.txt index 75b0418348..f9d1c0f3c8 100644 --- a/gui/changes.txt +++ b/gui/changes.txt @@ -1,5 +1,4 @@ CHANGE THIS BEFORE A RELEASE Each line is treated as a separate change item shown in the GUI the first time it runs after install. Start each line with a capital letter and end each line with a period. -[macOS, Windows, linux] To make an entry platform specific, start the line with an angle bracket enclosed comma separated list of platforms to show the entry on. Only point out the major changes. diff --git a/ios/Assets/changes.txt b/ios/Assets/changes.txt new file mode 100644 index 0000000000..75b0418348 --- /dev/null +++ b/ios/Assets/changes.txt @@ -0,0 +1,5 @@ +CHANGE THIS BEFORE A RELEASE +Each line is treated as a separate change item shown in the GUI the first time it runs after install. +Start each line with a capital letter and end each line with a period. +[macOS, Windows, linux] To make an entry platform specific, start the line with an angle bracket enclosed comma separated list of platforms to show the entry on. +Only point out the major changes. diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index b202225aae..72be02bc76 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -113,6 +113,8 @@ 584EBDBD2747C98F00A0C9FD /* NSAttributedString+Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */; }; 584F99202902CBDD001F858D /* libRelaySelector.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5898D29829017DAC00EB5EBA /* libRelaySelector.a */; }; 5857F24324C8662600CF6F47 /* SelectLocationHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5857F24224C8662600CF6F47 /* SelectLocationHeaderView.swift */; }; + 5859A55329CD9B1300F66591 /* ChangeLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5859A55229CD9B1300F66591 /* ChangeLog.swift */; }; + 5859A55529CD9DD900F66591 /* changes.txt in Resources */ = {isa = PBXBuildFile; fileRef = 5859A55429CD9DD800F66591 /* changes.txt */; }; 585B4B8726D9098900555C4C /* TunnelStatusNotificationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A94AE326CFD945001CB97C /* TunnelStatusNotificationProvider.swift */; }; 585CA70F25F8C44600B47C62 /* UIMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CA70E25F8C44600B47C62 /* UIMetrics.swift */; }; 585E820327F3285E00939F0E /* SendStoreReceiptOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585E820227F3285E00939F0E /* SendStoreReceiptOperation.swift */; }; @@ -151,7 +153,10 @@ 5878A279290954790096FC88 /* TunnelViewControllerInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878A278290954790096FC88 /* TunnelViewControllerInteractor.swift */; }; 5878A27B2909649A0096FC88 /* CustomOverlayRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878A27A2909649A0096FC88 /* CustomOverlayRenderer.swift */; }; 5878A27D2909657C0096FC88 /* RevokedDeviceInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878A27C2909657C0096FC88 /* RevokedDeviceInteractor.swift */; }; + 5878F4FC29CDA2E4003D4BE2 /* ChangeLogViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878F4FB29CDA2E4003D4BE2 /* ChangeLogViewController.swift */; }; 5878F50029CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878F4FF29CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift */; }; + 5878F50229CDB989003D4BE2 /* ChangeLogCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878F50129CDB989003D4BE2 /* ChangeLogCoordinator.swift */; }; + 5878F50429CDC547003D4BE2 /* ChangeLogContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5878F50329CDC547003D4BE2 /* ChangeLogContentView.swift */; }; 587988C728A2A01F00E3DF54 /* AccountDataThrottling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587988C628A2A01F00E3DF54 /* AccountDataThrottling.swift */; }; 587A01FC23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587A01FB23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift */; }; 587AD7C623421D7000E93A53 /* TunnelSettingsV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587AD7C523421D7000E93A53 /* TunnelSettingsV1.swift */; }; @@ -731,6 +736,8 @@ 584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Markdown.swift"; sourceTree = "<group>"; }; 58561C98239A5D1500BD6B5E /* IPv4Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPv4Endpoint.swift; sourceTree = "<group>"; }; 5857F24224C8662600CF6F47 /* SelectLocationHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationHeaderView.swift; sourceTree = "<group>"; }; + 5859A55229CD9B1300F66591 /* ChangeLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeLog.swift; sourceTree = "<group>"; }; + 5859A55429CD9DD800F66591 /* changes.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = changes.txt; sourceTree = "<group>"; }; 585CA70E25F8C44600B47C62 /* UIMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIMetrics.swift; sourceTree = "<group>"; }; 585DA87626B024A600B8C587 /* CachedRelays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedRelays.swift; sourceTree = "<group>"; }; 585DA89226B0323E00B8C587 /* TunnelProviderMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelProviderMessage.swift; sourceTree = "<group>"; }; @@ -771,7 +778,10 @@ 5878A278290954790096FC88 /* TunnelViewControllerInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelViewControllerInteractor.swift; sourceTree = "<group>"; }; 5878A27A2909649A0096FC88 /* CustomOverlayRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomOverlayRenderer.swift; sourceTree = "<group>"; }; 5878A27C2909657C0096FC88 /* RevokedDeviceInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevokedDeviceInteractor.swift; sourceTree = "<group>"; }; + 5878F4FB29CDA2E4003D4BE2 /* ChangeLogViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeLogViewController.swift; sourceTree = "<group>"; }; 5878F4FF29CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+AutoLayoutBuilder.swift"; sourceTree = "<group>"; }; + 5878F50129CDB989003D4BE2 /* ChangeLogCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeLogCoordinator.swift; sourceTree = "<group>"; }; + 5878F50329CDC547003D4BE2 /* ChangeLogContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeLogContentView.swift; sourceTree = "<group>"; }; 587988C628A2A01F00E3DF54 /* AccountDataThrottling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDataThrottling.swift; sourceTree = "<group>"; }; 587A01FB23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorTunnelProviderHost.swift; sourceTree = "<group>"; }; 587AD7C523421D7000E93A53 /* TunnelSettingsV1.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TunnelSettingsV1.swift; sourceTree = "<group>"; }; @@ -1241,6 +1251,7 @@ 583FE01D29C197C1006E85F9 /* DeviceList */, 583FE01C29C19793006E85F9 /* RevokedDevice */, 583FE01B29C19786006E85F9 /* OutOfTime */, + 5878F4FA29CDA2D4003D4BE2 /* ChangeLog */, 583FE01A29C19777006E85F9 /* Preferences */, 583FE01929C19760006E85F9 /* ProblemReport */, 583FE01829C19709006E85F9 /* Settings */, @@ -1460,6 +1471,7 @@ 5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */, 5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */, 5872D6E7286304DE00DB5F4E /* TermsOfService.swift */, + 5859A55229CD9B1300F66591 /* ChangeLog.swift */, 587988C628A2A01F00E3DF54 /* AccountDataThrottling.swift */, 58138E60294871C600684F0C /* DeviceDataThrottling.swift */, 589A454B28DDF5E100565204 /* Swizzle.swift */, @@ -1557,6 +1569,15 @@ path = Operations; sourceTree = "<group>"; }; + 5878F4FA29CDA2D4003D4BE2 /* ChangeLog */ = { + isa = PBXGroup; + children = ( + 5878F4FB29CDA2E4003D4BE2 /* ChangeLogViewController.swift */, + 5878F50329CDC547003D4BE2 /* ChangeLogContentView.swift */, + ); + path = ChangeLog; + sourceTree = "<group>"; + }; 587B75422669034500DEF7E9 /* Notification Providers */ = { isa = PBXGroup; children = ( @@ -1579,6 +1600,7 @@ 58C3F4FA296C3AD500D72515 /* SettingsCoordinator.swift */, 5847D58C29B7740F008C3808 /* RevokedCoordinator.swift */, 583FE00D29C0D586006E85F9 /* OutOfTimeCoordinator.swift */, + 5878F50129CDB989003D4BE2 /* ChangeLogCoordinator.swift */, ); path = App; sourceTree = "<group>"; @@ -1879,6 +1901,7 @@ 58F3C0A824A50C0E003E76BE /* Assets */ = { isa = PBXGroup; children = ( + 5859A55429CD9DD800F66591 /* changes.txt */, 587DCCEE287D84A500CE821E /* countries.geo.json */, ); path = Assets; @@ -2375,6 +2398,7 @@ buildActionMask = 2147483647; files = ( 58727283265D173C00F315B2 /* LaunchScreen.storyboard in Resources */, + 5859A55529CD9DD900F66591 /* changes.txt in Resources */, 587DCCEF287D84A500CE821E /* countries.geo.json in Resources */, 58CE5E6B224146210008646E /* Assets.xcassets in Resources */, 5883A09E266A5AF7003EFFCB /* Localizable.strings in Resources */, @@ -2597,6 +2621,7 @@ 58F185AA298A3E3E00075977 /* TunnelCoordinator.swift in Sources */, 58F8AC0E25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift in Sources */, 5878A27129091CF20096FC88 /* AccountInteractor.swift in Sources */, + 5878F4FC29CDA2E4003D4BE2 /* ChangeLogViewController.swift in Sources */, 068CE5742927B7A400A068BB /* Migration.swift in Sources */, 58CCA010224249A1004F3011 /* TunnelViewController.swift in Sources */, 58B26E22294351EA00D5980C /* InAppNotificationProvider.swift in Sources */, @@ -2692,6 +2717,7 @@ 58607A4D2947287800BC467D /* AccountExpiryInAppNotificationProvider.swift in Sources */, 06410E07292D108E00AFC18C /* SettingsStore.swift in Sources */, 586A950D290125F0007BAF2B /* PresentAlertOperation.swift in Sources */, + 5878F50229CDB989003D4BE2 /* ChangeLogCoordinator.swift in Sources */, 58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */, 58B26E262943522400D5980C /* NotificationProvider.swift in Sources */, 58FEEB46260A028D00A621A8 /* GeoJSON.swift in Sources */, @@ -2714,6 +2740,7 @@ 58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */, 587D9676288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift in Sources */, 58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */, + 5878F50429CDC547003D4BE2 /* ChangeLogContentView.swift in Sources */, 58B9EB152489139B00095626 /* RESTError+Display.swift in Sources */, 587B753F2668E5A700DEF7E9 /* NotificationContainerView.swift in Sources */, 58421034282E4B1500F24E46 /* TunnelSettingsV2+REST.swift in Sources */, @@ -2739,6 +2766,7 @@ 58A8EE5E2976DB00009C0F8D /* StorePaymentManagerError+Display.swift in Sources */, 580F8B8328197881002E0998 /* TunnelSettingsV2.swift in Sources */, 58A8EE5A2976BFBB009C0F8D /* SKError+Localized.swift in Sources */, + 5859A55329CD9B1300F66591 /* ChangeLog.swift in Sources */, 58BBB39729717E0C00C8DB7C /* ApplicationCoordinator.swift in Sources */, 5803B4B22940A48700C23744 /* TunnelStore.swift in Sources */, 586A950F29012BEE007BAF2B /* AddressCacheTracker.swift in Sources */, diff --git a/ios/MullvadVPN/Classes/ChangeLog.swift b/ios/MullvadVPN/Classes/ChangeLog.swift new file mode 100644 index 0000000000..418bf277a8 --- /dev/null +++ b/ios/MullvadVPN/Classes/ChangeLog.swift @@ -0,0 +1,64 @@ +// +// ChangeLog.swift +// MullvadVPN +// +// Created by pronebird on 24/03/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +enum ChangeLog { + private static let userDefaultsKey = "lastSeenChangeLogVersion" + + /** + Returns `true` if changelog for current application version was already seen by user, otherwise + `false`. + */ + static var isSeen: Bool { + let version = UserDefaults.standard.string(forKey: Self.userDefaultsKey) + + return version == Bundle.main.shortVersion + } + + /** + Marks changelog for current application version as seen in user defaults. + */ + static func markAsSeen() { + UserDefaults.standard.set(Bundle.main.shortVersion, forKey: Self.userDefaultsKey) + } + + /** + Marks changelog as unseen. Removes an entry from user defaults. + */ + static func markAsUnseen() { + UserDefaults.standard.removeObject(forKey: Self.userDefaultsKey) + } + + /** + Reads changelog file from bundle and returns its contents as a string. + */ + static func readFromFile() throws -> String { + return try String(contentsOfFile: try getPathToChangesFile()) + .split(whereSeparator: { $0.isNewline }) + .compactMap { line in + let trimmedString = line.trimmingCharacters(in: .whitespaces) + + guard !trimmedString.isEmpty else { return nil } + + return "• \(trimmedString)" + } + .joined(separator: "\n") + } + + /** + Returns path to changelog file in bundle. + */ + static func getPathToChangesFile() throws -> String { + if let filePath = Bundle.main.path(forResource: "changes", ofType: "txt") { + return filePath + } else { + throw CocoaError(.fileNoSuchFile) + } + } +} diff --git a/ios/MullvadVPN/Coordinators/App/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/App/ApplicationCoordinator.swift index c0b18a2aaf..ba03323d0d 100644 --- a/ios/MullvadVPN/Coordinators/App/ApplicationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/App/ApplicationCoordinator.swift @@ -138,6 +138,9 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo case .login: presentLogin(animated: animated, completion: completion) + case .changelog: + presentChangeLog(animated: animated, completion: completion) + case .tos: presentTOS(animated: animated, completion: completion) @@ -267,6 +270,10 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo return .tos } + guard ChangeLog.isSeen else { + return .changelog + } + switch tunnelManager.deviceState { case .revoked: return .revoked @@ -318,11 +325,9 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo Begins horizontal flow presenting a navigation controller suitable for current user interface idiom. - On iPad this takes care of presenting a secondary navigation context using modal presentation - after calling the given `block`. + On iPad this takes care of presenting a secondary navigation context using modal presentation. - On iPhone this function simply passes the primary navigation container to the `block` and - nothing else. + On iPhone this does nothing. */ private func beginHorizontalFlow(animated: Bool, completion: @escaping () -> Void) { if isPad, secondaryNavigationContainer.presentingViewController == nil { @@ -395,6 +400,21 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo } } + private func presentChangeLog(animated: Bool, completion: @escaping (Coordinator) -> Void) { + let coordinator = ChangeLogCoordinator(navigationController: horizontalFlowController) + + coordinator.didFinish = { [weak self] coordinator in + self?.continueFlow(animated: true) + } + + addChild(coordinator) + coordinator.start(animated: animated) + + beginHorizontalFlow(animated: animated) { + completion(coordinator) + } + } + private func presentMain(animated: Bool, completion: @escaping (Coordinator) -> Void) { precondition(!isPad) diff --git a/ios/MullvadVPN/Coordinators/App/ApplicationRouter.swift b/ios/MullvadVPN/Coordinators/App/ApplicationRouter.swift index 8b52b21044..a6277dba58 100644 --- a/ios/MullvadVPN/Coordinators/App/ApplicationRouter.swift +++ b/ios/MullvadVPN/Coordinators/App/ApplicationRouter.swift @@ -75,7 +75,7 @@ enum AppRoute: Equatable, Hashable { /** Routes that are part of primary horizontal navigation group. */ - case tos, login, main, revoked, outOfTime + case tos, changelog, login, main, revoked, outOfTime /** Returns `true` when only one route of a kind can be displayed. @@ -105,7 +105,7 @@ enum AppRoute: Equatable, Hashable { */ var routeGroup: AppRouteGroup { switch self { - case .tos, .login, .main, .revoked, .outOfTime: + case .tos, .changelog, .login, .main, .revoked, .outOfTime: return .primary case .selectLocation: return .selectLocation diff --git a/ios/MullvadVPN/Coordinators/App/ChangeLogCoordinator.swift b/ios/MullvadVPN/Coordinators/App/ChangeLogCoordinator.swift new file mode 100644 index 0000000000..0dca0a41a4 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/App/ChangeLogCoordinator.swift @@ -0,0 +1,46 @@ +// +// ChangeLogCoordinator.swift +// MullvadVPN +// +// Created by pronebird on 24/03/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import MullvadLogging +import UIKit + +final class ChangeLogCoordinator: Coordinator { + private let logger = Logger(label: "ChangeLogCoordinator") + + let navigationController: RootContainerViewController + + var didFinish: ((ChangeLogCoordinator) -> Void)? + + init(navigationController: RootContainerViewController) { + self.navigationController = navigationController + } + + func start(animated: Bool) { + let controller = ChangeLogViewController() + + controller.setApplicationVersion(Bundle.main.shortVersion) + + do { + let string = try ChangeLog.readFromFile() + + controller.setChangeLogText(string) + } catch { + logger.error(error: error, message: "Cannot read changelog from bundle.") + } + + controller.onFinish = { [weak self] in + guard let self = self else { return } + + ChangeLog.markAsSeen() + + self.didFinish?(self) + } + + navigationController.pushViewController(controller, animated: animated) + } +} diff --git a/ios/MullvadVPN/Extensions/Bundle+ProductVersion.swift b/ios/MullvadVPN/Extensions/Bundle+ProductVersion.swift index a3631c1872..410811842f 100644 --- a/ios/MullvadVPN/Extensions/Bundle+ProductVersion.swift +++ b/ios/MullvadVPN/Extensions/Bundle+ProductVersion.swift @@ -31,4 +31,9 @@ extension Bundle { } #endif } + + /// Returns short version XXXX.YY (i.e 2020.5). + var shortVersion: String { + return object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "???" + } } diff --git a/ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift b/ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift index 7e207b0c68..c8f6970ed4 100644 --- a/ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift +++ b/ios/MullvadVPN/Extensions/UIView+AutoLayoutBuilder.swift @@ -10,161 +10,87 @@ import UIKit extension UIView { /** - Pin edges to edges of other view edges. + Pin all edges to edges of other view. */ - func pinEdgesTo( - _ other: UIView, - insets: NSDirectionalEdgeInsets = .zero, - excludingEdges: NSDirectionalRectEdge = [] - ) -> [NSLayoutConstraint] { - var constraints = [NSLayoutConstraint]() + func pinEdgesTo(_ other: UIView) -> [NSLayoutConstraint] { + return pinEdges(.all(), to: other) + } - if !excludingEdges.contains(.top) { - constraints.append( - topAnchor.constraint(equalTo: other.topAnchor, constant: insets.top) - ) - } + /** + Pin edges to edges of other view. + */ + func pinEdges(_ edges: PinnableEdges, to other: UIView) -> [NSLayoutConstraint] { + return edges.inner.map { edge -> NSLayoutConstraint in + switch edge { + case let .top(inset): + return topAnchor.constraint(equalTo: other.topAnchor, constant: inset) - if !excludingEdges.contains(.bottom) { - constraints.append( - bottomAnchor.constraint(equalTo: other.bottomAnchor, constant: insets.bottom) - ) - } + case let .bottom(inset): + return bottomAnchor.constraint(equalTo: other.bottomAnchor, constant: inset) - if !excludingEdges.contains(.leading) { - constraints.append( - leadingAnchor.constraint(equalTo: other.leadingAnchor, constant: insets.leading) - ) - } + case let .leading(inset): + return leadingAnchor.constraint(equalTo: other.leadingAnchor, constant: inset) - if !excludingEdges.contains(.trailing) { - constraints.append( - trailingAnchor.constraint(equalTo: other.trailingAnchor, constant: insets.trailing) - ) + case let .trailing(inset): + return trailingAnchor.constraint(equalTo: other.trailingAnchor, constant: inset) + } } - - return constraints } /** Pin edges to superview edges. */ - func pinEdgesToSuperview( - insets: NSDirectionalEdgeInsets = .zero, - excludingEdges: NSDirectionalRectEdge = [] - ) -> [NSLayoutConstraint] { + func pinEdgesToSuperview(_ edges: PinnableEdges = .all()) -> [NSLayoutConstraint] { guard let superview = superview else { return [] } - return pinEdgesTo(superview, insets: insets, excludingEdges: excludingEdges) + return pinEdges(edges, to: superview) } /** Pin edges to superview margins. */ - func pinEdgesToSuperviewMargins( - insets: NSDirectionalEdgeInsets = .zero, - excludingEdges: NSDirectionalRectEdge = [] - ) -> [NSLayoutConstraint] { + func pinEdgesToSuperviewMargins(_ edges: PinnableEdges = .all()) -> [NSLayoutConstraint] { guard let superview = superview else { return [] } - return pinEdgesToMargins(superview, insets: insets, excludingEdges: excludingEdges) + return pinEdges(edges, toMarginsOf: superview) + } + + /** + Pin all edges to other view layout margins. + */ + func pinEdgesToMarginsOf(_ other: UIView) -> [NSLayoutConstraint] { + return pinEdges(.all(), toMarginsOf: other) } /** Pin edges to other view layout margins. */ - func pinEdgesToMargins( - _ other: UIView, - insets: NSDirectionalEdgeInsets = .zero, - excludingEdges: NSDirectionalRectEdge = [] - ) -> [NSLayoutConstraint] { - return pinEdgesTo(other.layoutMarginsGuide, insets: insets, excludingEdges: excludingEdges) + func pinEdges(_ edges: PinnableEdges, toMarginsOf other: UIView) -> [NSLayoutConstraint] { + return pinEdges(edges, to: other.layoutMarginsGuide) } /** Pin edges to layout guide. */ - func pinEdgesTo( - _ layoutGuide: UILayoutGuide, - insets: NSDirectionalEdgeInsets = .zero, - excludingEdges: NSDirectionalRectEdge = [] - ) -> [NSLayoutConstraint] { - var constraints = [NSLayoutConstraint]() + func pinEdges(_ edges: PinnableEdges, to layoutGuide: UILayoutGuide) -> [NSLayoutConstraint] { + return edges.inner.map { edge -> NSLayoutConstraint in + switch edge { + case let .top(inset): + return topAnchor.constraint(equalTo: layoutGuide.topAnchor, constant: inset) - if !excludingEdges.contains(.top) { - constraints.append( - topAnchor.constraint(equalTo: layoutGuide.topAnchor, constant: insets.top) - ) - } + case let .bottom(inset): + return bottomAnchor.constraint(equalTo: layoutGuide.bottomAnchor, constant: inset) - if !excludingEdges.contains(.bottom) { - constraints.append( - bottomAnchor.constraint( - equalTo: layoutGuide.bottomAnchor, - constant: insets.bottom - ) - ) - } + case let .leading(inset): + return leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor, constant: inset) - if !excludingEdges.contains(.leading) { - constraints.append( - leadingAnchor.constraint( - equalTo: layoutGuide.leadingAnchor, - constant: insets.leading - ) - ) - } - - if !excludingEdges.contains(.trailing) { - constraints.append( - trailingAnchor.constraint( + case let .trailing(inset): + return trailingAnchor.constraint( equalTo: layoutGuide.trailingAnchor, - constant: insets.trailing + constant: inset ) - ) + } } - - return constraints - } - - /** - Pin horizontal edges to other view edges. - */ - func pinHorizontalEdgesTo( - _ other: UIView, - leadingInset: CGFloat = .zero, - trailingInset: CGFloat = .zero - ) -> [NSLayoutConstraint] { - return pinEdgesTo( - other, - insets: NSDirectionalEdgeInsets( - top: 0, - leading: leadingInset, - bottom: 0, - trailing: trailingInset - ), - excludingEdges: [.bottom, .top] - ) - } - - /** - Pin horizontal edges to other view layout margins. - */ - func pinHorizontalEdgesToMargins( - _ other: UIView, - leadingInset: CGFloat = .zero, - trailingInset: CGFloat = .zero - ) -> [NSLayoutConstraint] { - return pinEdgesToMargins( - other, - insets: NSDirectionalEdgeInsets( - top: 0, - leading: leadingInset, - bottom: 0, - trailing: trailingInset - ), - excludingEdges: [.bottom, .top] - ) } } @@ -182,13 +108,12 @@ extension UIView { view.addSubview(subview) NSLayoutConstraint.activate { - subview.pinEdgesToSuperview( - insets: NSDirectionalEdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 24), - excludingEdges: .bottom - ) + // Pin top, leading and trailing edges to superview. + subview.pinEdgesToSuperview(.init([.top(8), .leading(16), .trailing(8)])) + + // Pin bottom to safe area layout guide. subview.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) } - ``` */ @resultBuilder enum AutoLayoutBuilder { @@ -229,3 +154,103 @@ extension NSLayoutConstraint { activate(builder()) } } + +extension UIView { + /** + Add subviews using AutoLayout and configure constraints. + */ + func addConstrainedSubviews( + _ subviews: [UIView], + @AutoLayoutBuilder builder: () -> [NSLayoutConstraint] + ) { + for subview in subviews { + subview.configureForAutoLayout() + addSubview(subview) + } + + NSLayoutConstraint.activate(builder()) + } + + /** + Add subviews using AutoLayout without configuring constraints. + */ + func addConstrainedSubviews(_ subviews: [UIView]) { + addConstrainedSubviews(subviews) {} + } + + /** + Configure view for AutoLayout by disabling automatic autoresizing mask translation into + constraints. + */ + func configureForAutoLayout() { + translatesAutoresizingMaskIntoConstraints = false + } +} + +/** + Struct describing a relationship between AutoLayout anchors. + */ +struct PinnableEdges { + /** + Enum describing each inidividual edge with associated inset value. + */ + enum Edge: Hashable { + case top(CGFloat) + case bottom(CGFloat) + case leading(CGFloat) + case trailing(CGFloat) + + var rectEdge: NSDirectionalRectEdge { + switch self { + case .top: + return .top + case .bottom: + return .bottom + case .leading: + return .leading + case .trailing: + return .trailing + } + } + + func hash(into hasher: inout Hasher) { + hasher.combine(rectEdge.rawValue) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.rectEdge == rhs.rectEdge + } + } + + /** + Inner set of `Edge` objects. + */ + var inner: Set<Edge> + + /** + Designated initializer. + */ + init(_ edges: Set<Edge>) { + inner = edges + } + + /** + Returns new `PinnableEdges` with the given edge(s) excluded. + */ + func excluding(_ excludeEdges: NSDirectionalRectEdge) -> Self { + return Self(inner.filter { !excludeEdges.contains($0.rectEdge) }) + } + + /** + Returns new `PinnableEdges` initialized with four edges and corresponding insets from + `NSDirectionalEdgeInsets`. + */ + static func all(_ directionalEdgeInsets: NSDirectionalEdgeInsets = .zero) -> Self { + return Self([ + .top(directionalEdgeInsets.top), + .bottom(directionalEdgeInsets.bottom), + .leading(directionalEdgeInsets.leading), + .trailing(directionalEdgeInsets.trailing), + ]) + } +} diff --git a/ios/MullvadVPN/View controllers/ChangeLog/ChangeLogContentView.swift b/ios/MullvadVPN/View controllers/ChangeLog/ChangeLogContentView.swift new file mode 100644 index 0000000000..edfe3413d4 --- /dev/null +++ b/ios/MullvadVPN/View controllers/ChangeLog/ChangeLogContentView.swift @@ -0,0 +1,136 @@ +// +// ChangeLogContentView.swift +// MullvadVPN +// +// Created by pronebird on 24/03/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +final class ChangeLogContentView: UIView { + private let titleLabel: UILabel = { + let titleLabel = UILabel() + titleLabel.font = .systemFont(ofSize: 24, weight: .bold) + titleLabel.numberOfLines = 0 + titleLabel.textColor = .white + titleLabel.allowsDefaultTighteningForTruncation = true + titleLabel.lineBreakMode = .byWordWrapping + if #available(iOS 14.0, *) { + // See: https://stackoverflow.com/q/46200027/351305 + titleLabel.lineBreakStrategy = [] + } + return titleLabel + }() + + private let subheadLabel: UILabel = { + let subheadLabel = UILabel() + subheadLabel.font = .systemFont(ofSize: 18, weight: .bold) + subheadLabel.numberOfLines = 0 + subheadLabel.textColor = .white + subheadLabel.allowsDefaultTighteningForTruncation = true + subheadLabel.lineBreakMode = .byWordWrapping + if #available(iOS 14.0, *) { + // See: https://stackoverflow.com/q/46200027/351305 + subheadLabel.lineBreakStrategy = [] + } + subheadLabel.text = NSLocalizedString( + "CHANGES_IN_THIS_VERSION", + tableName: "ChangeLog", + value: "Changes in this version:", + comment: "" + ) + return subheadLabel + }() + + private let textView: UITextView = { + let textView = UITextView() + textView.backgroundColor = .clear + textView.isEditable = false + textView.isSelectable = false + textView.textContainerInset = UIMetrics.contentLayoutMargins + return textView + }() + + private let okButton: AppButton = { + let button = AppButton(style: .default) + button.accessibilityIdentifier = "OkButton" + button.setTitle(NSLocalizedString( + "OK_BUTTON", + tableName: "ChangeLog", + value: "Got it", + comment: "" + ), for: .normal) + return button + }() + + private let footerContainer: UIView = { + let container = UIView() + container.layoutMargins = UIMetrics.contentLayoutMargins + container.backgroundColor = .secondaryColor + return container + }() + + var didTapButton: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundColor = .primaryColor + layoutMargins = UIMetrics.contentLayoutMargins + + okButton.addTarget(self, action: #selector(handleButtonTap), for: .touchUpInside) + + addSubviews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setApplicationVersion(_ string: String) { + titleLabel.text = string + } + + func setChangeLogText(_ string: String) { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineHeightMultiple = 1.5 + paragraphStyle.lineBreakMode = .byWordWrapping + + textView.attributedText = NSAttributedString( + string: string, + attributes: [ + .paragraphStyle: paragraphStyle, + .font: UIFont.systemFont(ofSize: 18), + .foregroundColor: UIColor.white, + ] + ) + } + + private func addSubviews() { + footerContainer.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + + footerContainer.addConstrainedSubviews([okButton]) { + okButton.pinEdgesToSuperviewMargins() + } + + addConstrainedSubviews([titleLabel, subheadLabel, textView, footerContainer]) { + titleLabel.pinEdgesToSuperviewMargins(.all().excluding(.bottom)) + subheadLabel.pinEdgesToSuperviewMargins(.init([.leading(0), .trailing(0)])) + subheadLabel.topAnchor.constraint( + equalToSystemSpacingBelow: titleLabel.bottomAnchor, + multiplier: 1 + ) + + textView.topAnchor.constraint(equalTo: subheadLabel.bottomAnchor) + textView.pinEdgesToSuperview(.init([.leading(0), .trailing(0)])) + + footerContainer.pinEdgesToSuperview(.all().excluding(.top)) + footerContainer.topAnchor.constraint(equalTo: textView.bottomAnchor) + } + } + + @objc private func handleButtonTap() { + didTapButton?() + } +} diff --git a/ios/MullvadVPN/View controllers/ChangeLog/ChangeLogViewController.swift b/ios/MullvadVPN/View controllers/ChangeLog/ChangeLogViewController.swift new file mode 100644 index 0000000000..2a1279e6fd --- /dev/null +++ b/ios/MullvadVPN/View controllers/ChangeLog/ChangeLogViewController.swift @@ -0,0 +1,49 @@ +// +// ChangeLogViewController.swift +// MullvadVPN +// +// Created by pronebird on 24/03/2023. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class ChangeLogViewController: UIViewController, RootContainment { + // MARK: - RootContainment + + var preferredHeaderBarPresentation: HeaderBarPresentation { + return HeaderBarPresentation(style: .default, showsDivider: false) + } + + var prefersHeaderBarHidden: Bool { + return false + } + + // MARK: - Public + + var onFinish: (() -> Void)? + + func setApplicationVersion(_ string: String) { + contentView.setApplicationVersion(string) + } + + func setChangeLogText(_ string: String) { + contentView.setChangeLogText(string) + } + + // MARK: - View lifecycle + + private let contentView = ChangeLogContentView() + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + override func loadView() { + view = contentView + + contentView.didTapButton = { [weak self] in + self?.onFinish?() + } + } +} |
