From 60f064deb1e2974127789f3a2cdccfe5a1846a07 Mon Sep 17 00:00:00 2001 From: ryoya ito Date: Wed, 6 Sep 2023 19:30:35 +0900 Subject: [PATCH 1/9] wip --- .../xcshareddata/swiftpm/Package.resolved | 18 +++++++ .../DroidKaigi2023.entitlements | 12 +++-- app-ios/Modules/Package.swift | 2 + .../Achievements/AchievementsView.swift | 50 +++++++++++++------ .../Achievements/AchievementsViewModel.swift | 47 +++++++++++++++++ .../KMPContainer/StampDataProvider.swift | 25 +++++++++- 6 files changed, 133 insertions(+), 21 deletions(-) diff --git a/app-ios/App/DroidKaigi2023/DroidKaigi2023.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/app-ios/App/DroidKaigi2023/DroidKaigi2023.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e7e29ffe2..952f695d5 100644 --- a/app-ios/App/DroidKaigi2023/DroidKaigi2023.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/app-ios/App/DroidKaigi2023/DroidKaigi2023.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -162,6 +162,15 @@ "version" : "1.2.3" } }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "9cfed92b026c524674ed869a4ff2dcfdeedf8a2a", + "version" : "0.1.0" + } + }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", @@ -171,6 +180,15 @@ "version" : "1.0.0" } }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" + } + }, { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", diff --git a/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements b/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements index f2ef3ae02..2d9160d73 100644 --- a/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements +++ b/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements @@ -2,9 +2,13 @@ - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - + com.apple.developer.associated-domains + + applinks:confsched2023.page.link + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + diff --git a/app-ios/Modules/Package.swift b/app-ios/Modules/Package.swift index 281a2af4c..375c2e092 100644 --- a/app-ios/Modules/Package.swift +++ b/app-ios/Modules/Package.swift @@ -25,6 +25,7 @@ var package = Package( .package(url: "https://github.com/cybozu/LicenseList", from: "0.2.1"), .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.14.0"), .package(url: "https://github.com/airbnb/lottie-spm", from: "4.2.0"), + .package(url: "https://github.com/apple/swift-async-algorithms", from: "0.1.0"), ], targets: [ .target( @@ -197,6 +198,7 @@ var package = Package( "Theme", "KMPContainer", .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ] ), .testTarget( diff --git a/app-ios/Modules/Sources/Achievements/AchievementsView.swift b/app-ios/Modules/Sources/Achievements/AchievementsView.swift index 99a589cda..911f638d6 100644 --- a/app-ios/Modules/Sources/Achievements/AchievementsView.swift +++ b/app-ios/Modules/Sources/Achievements/AchievementsView.swift @@ -1,29 +1,47 @@ import Assets +import shared import SwiftUI import Theme public struct AchievementsView: View { + @ObservedObject var viewModel: AchievementsViewModel = .init() + public init() {} + public var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 24) { - Text("会場の各部屋に設置されたNFCタグにスマホをかざしてバッジを集めてみましょう。イベント最終日には、全てのバッジを集めた方にDroidKaigiのオリジナルグッズをプレゼントします。") - .font(Font.system(size: 16)) - .foregroundStyle(AssetColors.Surface.onSurfaceVariant.swiftUIColor) - LazyVGrid(columns: [.init(), .init()]) { - Assets.Images.achievementArcticFox.swiftUIImage - Assets.Images.achievementBumblebee.swiftUIImage - Assets.Images.achievementChipmunk.swiftUIImage - Assets.Images.achievementDolphin.swiftUIImage - // TODO: Find good render way - Assets.Images.achievementElectricEel.swiftUIImage + switch viewModel.state.loadedState { + case .initial, .loading: + ProgressView() + .task { + await viewModel.load() + } + case .failed: + EmptyView() + case .loaded(let state): + NavigationStack { + ScrollView { + VStack(spacing: 24) { + Text(state.description) + .font(Font.system(size: 16)) + .foregroundStyle(AssetColors.Surface.onSurfaceVariant.swiftUIColor) + LazyVGrid(columns: [.init(), .init()]) { + if state.achievements.contains(AchievementsItemId) { + Assets.Images.achievementArcticFoxActive.swiftUIImage + } else { + Assets.Images.achievementArcticFox.swiftUIImage + } + Assets.Images.achievementBumblebee.swiftUIImage + Assets.Images.achievementChipmunk.swiftUIImage + Assets.Images.achievementDolphin.swiftUIImage + // TODO: Find good render way + Assets.Images.achievementElectricEel.swiftUIImage + } } + .padding(16) } - .padding(16) + .background(AssetColors.Surface.surface.swiftUIColor) + .navigationTitle("Achievements") } - .background(AssetColors.Surface.surface.swiftUIColor) - .navigationTitle("Achievements") } } } diff --git a/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift b/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift index 8b1378917..8751d24a1 100644 --- a/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift +++ b/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift @@ -1 +1,48 @@ +import AsyncAlgorithms +import Dependencies +import Foundation +import KMPContainer +import Model +import shared +struct AchievementsViewState: ViewModelState { + struct LoadedState: Equatable { + var description: String + var achievements: Set + } + + var loadedState: LoadingState = .initial +} + +@MainActor +class AchievementsViewModel: ObservableObject { + @Dependency(\.stampData) var stampData + @Published var state: AchievementsViewState = .init() + private var loadTask: Task? + + deinit { + loadTask?.cancel() + } + + func load() async { + state.loadedState = .loading + + loadTask = Task.detached { @MainActor in + do { + for try await (description, achievements) in combineLatest( + self.stampData.stampDetailDescription(), + self.stampData.achievements() + ) { + self.state.loadedState = .loaded( + .init( + description: description, + achievements: achievements + ) + ) + } + } catch { + self.state.loadedState = .failed(error) + } + } + } +} diff --git a/app-ios/Modules/Sources/KMPContainer/StampDataProvider.swift b/app-ios/Modules/Sources/KMPContainer/StampDataProvider.swift index e84133ac3..14a587b14 100644 --- a/app-ios/Modules/Sources/KMPContainer/StampDataProvider.swift +++ b/app-ios/Modules/Sources/KMPContainer/StampDataProvider.swift @@ -7,6 +7,9 @@ public struct StampDataProvider { } public let stampEnabled: () -> AsyncThrowingStream + public let stampDetailDescription: () -> AsyncThrowingStream + public let achievements: () -> AsyncThrowingStream, Error> + public let saveAchievement: (AchievementsItemId) async throws -> Void } extension StampDataProvider: DependencyKey { @@ -14,6 +17,15 @@ extension StampDataProvider: DependencyKey { public static var liveValue: StampDataProvider = StampDataProvider( stampEnabled: { stampRepository.getAchievementEnabledStream().stream() + }, + stampDetailDescription: { + stampRepository.getStampDetailDescriptionStream().stream() + }, + achievements: { + stampRepository.getAchievementsStream().stream() + }, + saveAchievement: { @MainActor id in + try await stampRepository.saveAchievements(id: id) } ) @@ -22,7 +34,18 @@ extension StampDataProvider: DependencyKey { .init { true } - } + }, + stampDetailDescription: { + .init { + "" + } + }, + achievements: { + .init { + Set() + } + }, + saveAchievement: {_ in} ) } From b51258d582037f55bda9446185e02fda3750f525 Mon Sep 17 00:00:00 2001 From: ryoya ito Date: Wed, 6 Sep 2023 21:17:30 +0900 Subject: [PATCH 2/9] add achieve rally uis connect with kmp --- .../Achievements/AchievementsView.swift | 54 +++++++++++++++--- .../Achievements/AchievementsViewModel.swift | 10 ++-- .../AchievementDataProvider.swift | 57 +++++++++++++++++++ .../KMPContainer/StampDataProvider.swift | 57 ------------------- 4 files changed, 107 insertions(+), 71 deletions(-) create mode 100644 app-ios/Modules/Sources/KMPContainer/AchievementDataProvider.swift delete mode 100644 app-ios/Modules/Sources/KMPContainer/StampDataProvider.swift diff --git a/app-ios/Modules/Sources/Achievements/AchievementsView.swift b/app-ios/Modules/Sources/Achievements/AchievementsView.swift index 911f638d6..fbb4c648f 100644 --- a/app-ios/Modules/Sources/Achievements/AchievementsView.swift +++ b/app-ios/Modules/Sources/Achievements/AchievementsView.swift @@ -25,16 +25,37 @@ public struct AchievementsView: View { .font(Font.system(size: 16)) .foregroundStyle(AssetColors.Surface.onSurfaceVariant.swiftUIColor) LazyVGrid(columns: [.init(), .init()]) { - if state.achievements.contains(AchievementsItemId) { - Assets.Images.achievementArcticFoxActive.swiftUIImage - } else { - Assets.Images.achievementArcticFox.swiftUIImage - } - Assets.Images.achievementBumblebee.swiftUIImage - Assets.Images.achievementChipmunk.swiftUIImage - Assets.Images.achievementDolphin.swiftUIImage + AchievementImage( + target: Achievement.arcticfox, + savedAchievements: state.achievements, + activeImage: Assets.Images.achievementArcticFoxActive, + inactiveImage: Assets.Images.achievementArcticFox + ) + AchievementImage( + target: Achievement.bumblebee, + savedAchievements: state.achievements, + activeImage: Assets.Images.achievementBumbleBeeActive, + inactiveImage: Assets.Images.achievementBumblebee + ) + AchievementImage( + target: Achievement.chipmunk, + savedAchievements: state.achievements, + activeImage: Assets.Images.achievementChipmunkActive, + inactiveImage: Assets.Images.achievementChipmunk + ) + AchievementImage( + target: Achievement.dolphin, + savedAchievements: state.achievements, + activeImage: Assets.Images.achievementDolphinActive, + inactiveImage: Assets.Images.achievementDolphin + ) // TODO: Find good render way - Assets.Images.achievementElectricEel.swiftUIImage + AchievementImage( + target: Achievement.chipmunk, + savedAchievements: state.achievements, + activeImage: Assets.Images.achievementElectricEelActive, + inactiveImage: Assets.Images.achievementElectricEel + ) } } .padding(16) @@ -46,6 +67,21 @@ public struct AchievementsView: View { } } +struct AchievementImage: View { + let target: Achievement + let savedAchievements: Set + let activeImage: ImageAsset + let inactiveImage: ImageAsset + + var body: some View { + if savedAchievements.contains(target) { + activeImage.swiftUIImage + } else { + inactiveImage.swiftUIImage + } + } +} + // #Preview { // AchievementsView() // } diff --git a/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift b/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift index 8751d24a1..49939ab4b 100644 --- a/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift +++ b/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift @@ -8,7 +8,7 @@ import shared struct AchievementsViewState: ViewModelState { struct LoadedState: Equatable { var description: String - var achievements: Set + var achievements: Set } var loadedState: LoadingState = .initial @@ -16,7 +16,7 @@ struct AchievementsViewState: ViewModelState { @MainActor class AchievementsViewModel: ObservableObject { - @Dependency(\.stampData) var stampData + @Dependency(\.achievementData) var achievementData @Published var state: AchievementsViewState = .init() private var loadTask: Task? @@ -30,12 +30,12 @@ class AchievementsViewModel: ObservableObject { loadTask = Task.detached { @MainActor in do { for try await (description, achievements) in combineLatest( - self.stampData.stampDetailDescription(), - self.stampData.achievements() + self.achievementData.achievementDetailDescription(), + self.achievementData.achievements() ) { self.state.loadedState = .loaded( .init( - description: description, + description: description, achievements: achievements ) ) diff --git a/app-ios/Modules/Sources/KMPContainer/AchievementDataProvider.swift b/app-ios/Modules/Sources/KMPContainer/AchievementDataProvider.swift new file mode 100644 index 000000000..f4c0ce8a4 --- /dev/null +++ b/app-ios/Modules/Sources/KMPContainer/AchievementDataProvider.swift @@ -0,0 +1,57 @@ +import Dependencies +import shared + +public struct AchievementDataProvider { + private static var achievementRepository: AchievementRepository { + Container.shared.get(type: AchievementRepository.self) + } + + public let achievementEnabled: () -> AsyncThrowingStream + public let achievementDetailDescription: () -> AsyncThrowingStream + public let achievements: () -> AsyncThrowingStream, Error> + public let saveAchievement: (Achievement) async throws -> Void +} + +extension AchievementDataProvider: DependencyKey { + @MainActor + public static var liveValue: AchievementDataProvider = AchievementDataProvider( + achievementEnabled: { + achievementRepository.getAchievementEnabledStream().stream() + }, + achievementDetailDescription: { + achievementRepository.getAchievementDetailDescriptionStream().stream() + }, + achievements: { + achievementRepository.getAchievementsStream().stream() + }, + saveAchievement: { @MainActor achievement in + try await achievementRepository.saveAchievements(achievement: achievement) + } + ) + + public static var testValue: AchievementDataProvider = AchievementDataProvider( + achievementEnabled: { + .init { + true + } + }, + achievementDetailDescription: { + .init { + "" + } + }, + achievements: { + .init { + Set() + } + }, + saveAchievement: {_ in} + ) +} + +public extension DependencyValues { + var achievementData: AchievementDataProvider { + get { self[AchievementDataProvider.self] } + set { self[AchievementDataProvider.self] = newValue } + } +} diff --git a/app-ios/Modules/Sources/KMPContainer/StampDataProvider.swift b/app-ios/Modules/Sources/KMPContainer/StampDataProvider.swift deleted file mode 100644 index 14a587b14..000000000 --- a/app-ios/Modules/Sources/KMPContainer/StampDataProvider.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Dependencies -import shared - -public struct StampDataProvider { - private static var stampRepository: AchievementRepository { - Container.shared.get(type: AchievementRepository.self) - } - - public let stampEnabled: () -> AsyncThrowingStream - public let stampDetailDescription: () -> AsyncThrowingStream - public let achievements: () -> AsyncThrowingStream, Error> - public let saveAchievement: (AchievementsItemId) async throws -> Void -} - -extension StampDataProvider: DependencyKey { - @MainActor - public static var liveValue: StampDataProvider = StampDataProvider( - stampEnabled: { - stampRepository.getAchievementEnabledStream().stream() - }, - stampDetailDescription: { - stampRepository.getStampDetailDescriptionStream().stream() - }, - achievements: { - stampRepository.getAchievementsStream().stream() - }, - saveAchievement: { @MainActor id in - try await stampRepository.saveAchievements(id: id) - } - ) - - public static var testValue: StampDataProvider = StampDataProvider( - stampEnabled: { - .init { - true - } - }, - stampDetailDescription: { - .init { - "" - } - }, - achievements: { - .init { - Set() - } - }, - saveAchievement: {_ in} - ) -} - -public extension DependencyValues { - var stampData: StampDataProvider { - get { self[StampDataProvider.self] } - set { self[StampDataProvider.self] = newValue } - } -} From 047da10ad339613c38e2425bcb9ad89cb3471855 Mon Sep 17 00:00:00 2001 From: ryoya ito Date: Thu, 7 Sep 2023 02:45:13 +0900 Subject: [PATCH 3/9] add nfc reader & save achievements --- .../DroidKaigi2023.entitlements | 4 ++ .../DroidKaigi2023/DroidKaigi2023/Info.plist | 2 + app-ios/Modules/Package.swift | 8 +++ .../Achievements/AchievementsView.swift | 7 +++ .../Achievements/AchievementsViewModel.swift | 38 +++++++++++++ app-ios/Modules/Sources/NFC/NFCReader.swift | 57 +++++++++++++++++++ 6 files changed, 116 insertions(+) create mode 100644 app-ios/Modules/Sources/NFC/NFCReader.swift diff --git a/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements b/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements index 2d9160d73..29205d571 100644 --- a/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements +++ b/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements @@ -10,5 +10,9 @@ com.apple.security.files.user-selected.read-only + com.apple.developer.nfc.readersession.formats + + NDEF + diff --git a/app-ios/App/DroidKaigi2023/DroidKaigi2023/Info.plist b/app-ios/App/DroidKaigi2023/DroidKaigi2023/Info.plist index e3a68e949..be7a5429c 100644 --- a/app-ios/App/DroidKaigi2023/DroidKaigi2023/Info.plist +++ b/app-ios/App/DroidKaigi2023/DroidKaigi2023/Info.plist @@ -6,5 +6,7 @@ CFBundleAllowMixedLocalizations + NFCReaderUsageDescription + Achievement の読み取りに使用します。 diff --git a/app-ios/Modules/Package.swift b/app-ios/Modules/Package.swift index 375c2e092..5daa3d41a 100644 --- a/app-ios/Modules/Package.swift +++ b/app-ios/Modules/Package.swift @@ -197,6 +197,7 @@ var package = Package( "Assets", "Theme", "KMPContainer", + "NFC", .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ] @@ -241,6 +242,13 @@ var package = Package( ] ), + .target( + name: "NFC", + dependencies: [ + "Model", + ] + ), + .target( name: "RemoteConfig", dependencies: [ diff --git a/app-ios/Modules/Sources/Achievements/AchievementsView.swift b/app-ios/Modules/Sources/Achievements/AchievementsView.swift index fbb4c648f..b76b6c0ac 100644 --- a/app-ios/Modules/Sources/Achievements/AchievementsView.swift +++ b/app-ios/Modules/Sources/Achievements/AchievementsView.swift @@ -57,6 +57,13 @@ public struct AchievementsView: View { inactiveImage: Assets.Images.achievementElectricEel ) } + Button { + Task { + await viewModel.read() + } + } label: { + Text("Scan!") + } } .padding(16) } diff --git a/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift b/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift index 49939ab4b..2ca8e42cb 100644 --- a/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift +++ b/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift @@ -1,8 +1,10 @@ import AsyncAlgorithms +import CryptoKit import Dependencies import Foundation import KMPContainer import Model +import NFC import shared struct AchievementsViewState: ViewModelState { @@ -18,6 +20,7 @@ struct AchievementsViewState: ViewModelState { class AchievementsViewModel: ObservableObject { @Dependency(\.achievementData) var achievementData @Published var state: AchievementsViewState = .init() + private let nfcReader = NFCReader() private var loadTask: Task? deinit { @@ -45,4 +48,39 @@ class AchievementsViewModel: ObservableObject { } } } + + func read() async { + do { + if let urlString = try await nfcReader.read(), let url = URL(string: urlString) { + let hashedString = idToSha256(id: url.lastPathComponent) + + let target: Achievement? = switch hashedString { + case Achievement.arcticfox.sha256: + Achievement.arcticfox + case Achievement.bumblebee.sha256: + Achievement.bumblebee + case Achievement.chipmunk.sha256: + Achievement.chipmunk + case Achievement.electriceel.sha256: + Achievement.electriceel + default: + nil + } + + if let achievement = target { + try await achievementData.saveAchievement(achievement) + } + } + } catch { + print(error) + } + } + + private func idToSha256(id: String) -> String? { + guard let data = id.data(using: .utf8) else { + return nil + } + let digest = SHA256.hash(data: data) + return digest.compactMap { String(format: "%02x", $0) }.joined() + } } diff --git a/app-ios/Modules/Sources/NFC/NFCReader.swift b/app-ios/Modules/Sources/NFC/NFCReader.swift new file mode 100644 index 000000000..76871a920 --- /dev/null +++ b/app-ios/Modules/Sources/NFC/NFCReader.swift @@ -0,0 +1,57 @@ +import CoreNFC + +public enum NFCError: Error { + case unavailable +} + +public class NFCReader: NSObject { + private var activeContinuation: CheckedContinuation? + private var session: NFCReaderSession? + + public override init() {} + + public func read() async throws -> String? { + guard NFCNDEFReaderSession.readingAvailable else { + throw NFCError.unavailable + } + + return try await withCheckedThrowingContinuation { continuation in + activeContinuation = continuation + + session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: false) + + session?.alertMessage = "Hold your iPhone near the item to learn more about it." + + session?.begin() + } + } + + private func invalidateSession() { + session?.invalidate() + session = nil + activeContinuation = nil + } +} + +extension NFCReader: NFCNDEFReaderSessionDelegate { + public func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) { + if let message = messages.first, let record = message.records.first { + let result = switch String(data: record.type, encoding: .utf8) { + case "T": + record.wellKnownTypeTextPayload().0 + case "U": + record.wellKnownTypeURIPayload()?.absoluteString + default: + record.wellKnownTypeTextPayload().0 + } + + activeContinuation?.resume(returning: result) + invalidateSession() + } + } + + public func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { + activeContinuation?.resume(throwing: error) + invalidateSession() + } +} From 7ea746d4b7a75bb90b450ff50b20d8bcc7d98c8b Mon Sep 17 00:00:00 2001 From: ryoya ito Date: Thu, 7 Sep 2023 03:02:26 +0900 Subject: [PATCH 4/9] add animation for obtaining achievement --- .../Achievements/AchievementsView.swift | 112 +++++++++++------- .../Achievements/AchievementsViewModel.swift | 6 +- .../Modules/Sources/Assets/LottieView.swift | 2 + 3 files changed, 77 insertions(+), 43 deletions(-) diff --git a/app-ios/Modules/Sources/Achievements/AchievementsView.swift b/app-ios/Modules/Sources/Achievements/AchievementsView.swift index b76b6c0ac..231d72828 100644 --- a/app-ios/Modules/Sources/Achievements/AchievementsView.swift +++ b/app-ios/Modules/Sources/Achievements/AchievementsView.swift @@ -5,6 +5,7 @@ import Theme public struct AchievementsView: View { @ObservedObject var viewModel: AchievementsViewModel = .init() + @State private var obtainedAchievement: Achievement? public init() {} @@ -20,52 +21,60 @@ public struct AchievementsView: View { case .loaded(let state): NavigationStack { ScrollView { - VStack(spacing: 24) { - Text(state.description) - .font(Font.system(size: 16)) - .foregroundStyle(AssetColors.Surface.onSurfaceVariant.swiftUIColor) - LazyVGrid(columns: [.init(), .init()]) { - AchievementImage( - target: Achievement.arcticfox, - savedAchievements: state.achievements, - activeImage: Assets.Images.achievementArcticFoxActive, - inactiveImage: Assets.Images.achievementArcticFox - ) - AchievementImage( - target: Achievement.bumblebee, - savedAchievements: state.achievements, - activeImage: Assets.Images.achievementBumbleBeeActive, - inactiveImage: Assets.Images.achievementBumblebee - ) - AchievementImage( - target: Achievement.chipmunk, - savedAchievements: state.achievements, - activeImage: Assets.Images.achievementChipmunkActive, - inactiveImage: Assets.Images.achievementChipmunk - ) - AchievementImage( - target: Achievement.dolphin, - savedAchievements: state.achievements, - activeImage: Assets.Images.achievementDolphinActive, - inactiveImage: Assets.Images.achievementDolphin - ) - // TODO: Find good render way - AchievementImage( - target: Achievement.chipmunk, - savedAchievements: state.achievements, - activeImage: Assets.Images.achievementElectricEelActive, - inactiveImage: Assets.Images.achievementElectricEel - ) + ZStack { + VStack(spacing: 24) { + Text(state.description) + .font(Font.system(size: 16)) + .foregroundStyle(AssetColors.Surface.onSurfaceVariant.swiftUIColor) + LazyVGrid(columns: [.init(), .init()]) { + AchievementImage( + target: Achievement.arcticfox, + savedAchievements: state.achievements, + activeImage: Assets.Images.achievementArcticFoxActive, + inactiveImage: Assets.Images.achievementArcticFox + ) + AchievementImage( + target: Achievement.bumblebee, + savedAchievements: state.achievements, + activeImage: Assets.Images.achievementBumbleBeeActive, + inactiveImage: Assets.Images.achievementBumblebee + ) + AchievementImage( + target: Achievement.chipmunk, + savedAchievements: state.achievements, + activeImage: Assets.Images.achievementChipmunkActive, + inactiveImage: Assets.Images.achievementChipmunk + ) + AchievementImage( + target: Achievement.dolphin, + savedAchievements: state.achievements, + activeImage: Assets.Images.achievementDolphinActive, + inactiveImage: Assets.Images.achievementDolphin + ) + // TODO: Find good render way + AchievementImage( + target: Achievement.chipmunk, + savedAchievements: state.achievements, + activeImage: Assets.Images.achievementElectricEelActive, + inactiveImage: Assets.Images.achievementElectricEel + ) + } + Button { + Task { + let result = await viewModel.read() + obtainedAchievement = result + } + } label: { + Text("Scan!") + } } - Button { - Task { - await viewModel.read() + .padding(16) + if let achievement = obtainedAchievement?.toLottie() { + achievement.swiftUIAnimation(loopMode: .playOnce) { _ in + obtainedAchievement = nil } - } label: { - Text("Scan!") } } - .padding(16) } .background(AssetColors.Surface.surface.swiftUIColor) .navigationTitle("Achievements") @@ -89,6 +98,25 @@ struct AchievementImage: View { } } +private extension Achievement { + func toLottie() -> LottieAnimation? { + switch self { + case .arcticfox: + return LottieAssets.achievementAJson + case .bumblebee: + return LottieAssets.achievementBJson + case .chipmunk: + return LottieAssets.achievementCJson + case .dolphin: + return LottieAssets.achievementDJson + case .electriceel: + return LottieAssets.achievementEJson + default: + return nil + } + } +} + // #Preview { // AchievementsView() // } diff --git a/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift b/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift index 2ca8e42cb..2c408d29a 100644 --- a/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift +++ b/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift @@ -49,7 +49,7 @@ class AchievementsViewModel: ObservableObject { } } - func read() async { + func read() async -> Achievement? { do { if let urlString = try await nfcReader.read(), let url = URL(string: urlString) { let hashedString = idToSha256(id: url.lastPathComponent) @@ -61,6 +61,8 @@ class AchievementsViewModel: ObservableObject { Achievement.bumblebee case Achievement.chipmunk.sha256: Achievement.chipmunk + case Achievement.dolphin.sha256: + Achievement.dolphin case Achievement.electriceel.sha256: Achievement.electriceel default: @@ -69,11 +71,13 @@ class AchievementsViewModel: ObservableObject { if let achievement = target { try await achievementData.saveAchievement(achievement) + return achievement } } } catch { print(error) } + return nil } private func idToSha256(id: String) -> String? { diff --git a/app-ios/Modules/Sources/Assets/LottieView.swift b/app-ios/Modules/Sources/Assets/LottieView.swift index 43d7dc655..a88aeb84d 100644 --- a/app-ios/Modules/Sources/Assets/LottieView.swift +++ b/app-ios/Modules/Sources/Assets/LottieView.swift @@ -1,6 +1,8 @@ import Lottie import SwiftUI +public typealias LottieAnimation = Lottie.LottieAnimation + public struct LottieView: UIViewRepresentable { private let animation: LottieAnimation private let loopMode: LottieLoopMode From c0eb70f2e29abfe3a03380ab041f2b54406e3ac8 Mon Sep 17 00:00:00 2001 From: ryoya ito Date: Thu, 7 Sep 2023 03:18:27 +0900 Subject: [PATCH 5/9] update ui --- .../AccentColor.colorset/Contents.json | 27 +++++++++++++++++++ .../Achievements/AchievementsView.swift | 7 +++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app-ios/App/DroidKaigi2023/DroidKaigi2023/Assets.xcassets/AccentColor.colorset/Contents.json b/app-ios/App/DroidKaigi2023/DroidKaigi2023/Assets.xcassets/AccentColor.colorset/Contents.json index eb8789700..5b194f183 100644 --- a/app-ios/App/DroidKaigi2023/DroidKaigi2023/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/app-ios/App/DroidKaigi2023/DroidKaigi2023/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,6 +1,33 @@ { "colors" : [ { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x50", + "green" : "0x6C", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xB0", + "green" : "0xDC", + "red" : "0x61" + } + }, "idiom" : "universal" } ], diff --git a/app-ios/Modules/Sources/Achievements/AchievementsView.swift b/app-ios/Modules/Sources/Achievements/AchievementsView.swift index 231d72828..09671d501 100644 --- a/app-ios/Modules/Sources/Achievements/AchievementsView.swift +++ b/app-ios/Modules/Sources/Achievements/AchievementsView.swift @@ -53,7 +53,7 @@ public struct AchievementsView: View { ) // TODO: Find good render way AchievementImage( - target: Achievement.chipmunk, + target: Achievement.electriceel, savedAchievements: state.achievements, activeImage: Assets.Images.achievementElectricEelActive, inactiveImage: Assets.Images.achievementElectricEel @@ -65,11 +65,14 @@ public struct AchievementsView: View { obtainedAchievement = result } } label: { - Text("Scan!") + Text("Scan NFC Tag") } + .buttonStyle(.bordered) + .controlSize(.large) } .padding(16) if let achievement = obtainedAchievement?.toLottie() { + AssetColors.Custom.black.swiftUIColor.opacity(0.84) achievement.swiftUIAnimation(loopMode: .playOnce) { _ in obtainedAchievement = nil } From 4b52db4fd01e806641358d116ecc23c5ba42d1db Mon Sep 17 00:00:00 2001 From: ryoya ito Date: Thu, 7 Sep 2023 03:37:49 +0900 Subject: [PATCH 6/9] stamp to achievement --- app-ios/Modules/Sources/Navigation/RootViewModel.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app-ios/Modules/Sources/Navigation/RootViewModel.swift b/app-ios/Modules/Sources/Navigation/RootViewModel.swift index 978613fa1..5910b2da2 100644 --- a/app-ios/Modules/Sources/Navigation/RootViewModel.swift +++ b/app-ios/Modules/Sources/Navigation/RootViewModel.swift @@ -9,15 +9,15 @@ struct RootViewState: ViewModelState { @MainActor class RootViewModel: ObservableObject { - @Dependency(\.stampData) var stampData + @Dependency(\.achievementData) var achievementData @Published var state: RootViewState = .init() func load() async { state.isAchivementEnabled = .loading do { - for try await isStampEnabled in stampData.stampEnabled() { - state.isAchivementEnabled = .loaded(isStampEnabled) + for try await isAchievementEnabled in achievementData.achievementEnabled() { + state.isAchivementEnabled = .loaded(isAchievementEnabled) } } catch { state.isAchivementEnabled = .failed(error) From e999b3504288eb95b572830d39fc1b3a95984d5f Mon Sep 17 00:00:00 2001 From: ryoya ito Date: Thu, 7 Sep 2023 11:14:26 +0900 Subject: [PATCH 7/9] fix entitlements --- .../DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements | 1 + 1 file changed, 1 insertion(+) diff --git a/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements b/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements index 29205d571..5062856c4 100644 --- a/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements +++ b/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements @@ -13,6 +13,7 @@ com.apple.developer.nfc.readersession.formats NDEF + TAG From 1ba9e8bef045a1746fe6051e96c40fc7a0d131ef Mon Sep 17 00:00:00 2001 From: ryoya ito Date: Thu, 7 Sep 2023 11:56:11 +0900 Subject: [PATCH 8/9] remove ndef --- .../DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements | 1 - 1 file changed, 1 deletion(-) diff --git a/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements b/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements index 5062856c4..a1fbe5c7a 100644 --- a/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements +++ b/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements @@ -12,7 +12,6 @@ com.apple.developer.nfc.readersession.formats - NDEF TAG From ce34f8f0cceb33cbe5dfba27d48458db963001e8 Mon Sep 17 00:00:00 2001 From: ryoya ito Date: Sun, 10 Sep 2023 02:17:56 +0900 Subject: [PATCH 9/9] add achievement with dynamic links --- .../DroidKaigi2023/DroidKaigi2023/App.swift | 2 + .../DroidKaigi2023.entitlements | 9 +-- .../DroidKaigi2023/DroidKaigi2023/Info.plist | 16 +++++ app-ios/Modules/Package.swift | 13 ++++ .../Achievements/AchievementsViewModel.swift | 37 +++-------- .../Modules/Sources/DeepLink/DeepLink.swift | 66 +++++++++++++++++++ .../Sources/KMPContainer/Container.swift | 2 - .../Sources/Navigation/AppDelegate.swift | 52 +++++++++++++++ .../Modules/Sources/Navigation/RootView.swift | 4 +- 9 files changed, 164 insertions(+), 37 deletions(-) create mode 100644 app-ios/Modules/Sources/DeepLink/DeepLink.swift create mode 100644 app-ios/Modules/Sources/Navigation/AppDelegate.swift diff --git a/app-ios/App/DroidKaigi2023/DroidKaigi2023/App.swift b/app-ios/App/DroidKaigi2023/DroidKaigi2023/App.swift index 34d3180fb..e3a2c7bcb 100644 --- a/app-ios/App/DroidKaigi2023/DroidKaigi2023/App.swift +++ b/app-ios/App/DroidKaigi2023/DroidKaigi2023/App.swift @@ -3,6 +3,8 @@ import SwiftUI @main struct MainApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + var body: some Scene { WindowGroup { RootView() diff --git a/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements b/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements index a1fbe5c7a..414f3adac 100644 --- a/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements +++ b/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements @@ -5,14 +5,15 @@ com.apple.developer.associated-domains applinks:confsched2023.page.link + applinks:droidkaigiapp.page.link - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - com.apple.developer.nfc.readersession.formats TAG + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + diff --git a/app-ios/App/DroidKaigi2023/DroidKaigi2023/Info.plist b/app-ios/App/DroidKaigi2023/DroidKaigi2023/Info.plist index be7a5429c..2779f717f 100644 --- a/app-ios/App/DroidKaigi2023/DroidKaigi2023/Info.plist +++ b/app-ios/App/DroidKaigi2023/DroidKaigi2023/Info.plist @@ -8,5 +8,21 @@ NFCReaderUsageDescription Achievement の読み取りに使用します。 + FirebaseDynamicLinksCustomDomains + + https://confsched2023.page.link + https://droidkaigiapp.page.link + + FirebaseAppDelegateProxyEnabled + + CFBundleURLTypes + + + CFBundleURLSchemes + + io.github.droidkaigi.DroidKaigi2023 + + + diff --git a/app-ios/Modules/Package.swift b/app-ios/Modules/Package.swift index 5daa3d41a..04b2fe09d 100644 --- a/app-ios/Modules/Package.swift +++ b/app-ios/Modules/Package.swift @@ -99,6 +99,16 @@ var package = Package( ] ), + .target( + name: "DeepLink", + dependencies: [ + "KMPContainer", + "shared", + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "FirebaseDynamicLinks", package: "firebase-ios-sdk"), + ] + ), + .target( name: "Event", dependencies: [ @@ -195,6 +205,7 @@ var package = Package( name: "Achievements", dependencies: [ "Assets", + "DeepLink", "Theme", "KMPContainer", "NFC", @@ -233,12 +244,14 @@ var package = Package( "Achievements", "Assets", "Contributor", + "DeepLink", "FloorMap", "Session", "Sponsor", "Staff", "Theme", "Timetable", + .product(name: "FirebaseRemoteConfig", package: "firebase-ios-sdk"), ] ), diff --git a/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift b/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift index 2c408d29a..0c83e4f0a 100644 --- a/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift +++ b/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift @@ -1,5 +1,6 @@ import AsyncAlgorithms import CryptoKit +import DeepLink import Dependencies import Foundation import KMPContainer @@ -20,6 +21,7 @@ struct AchievementsViewState: ViewModelState { class AchievementsViewModel: ObservableObject { @Dependency(\.achievementData) var achievementData @Published var state: AchievementsViewState = .init() + private let deepLink = DeepLink() private let nfcReader = NFCReader() private var loadTask: Task? @@ -51,27 +53,12 @@ class AchievementsViewModel: ObservableObject { func read() async -> Achievement? { do { - if let urlString = try await nfcReader.read(), let url = URL(string: urlString) { - let hashedString = idToSha256(id: url.lastPathComponent) - - let target: Achievement? = switch hashedString { - case Achievement.arcticfox.sha256: - Achievement.arcticfox - case Achievement.bumblebee.sha256: - Achievement.bumblebee - case Achievement.chipmunk.sha256: - Achievement.chipmunk - case Achievement.dolphin.sha256: - Achievement.dolphin - case Achievement.electriceel.sha256: - Achievement.electriceel - default: - nil - } - - if let achievement = target { - try await achievementData.saveAchievement(achievement) - return achievement + let readed = try await nfcReader.read() + if + let urlString = readed, + let url = URL(string: urlString) { + if let dynamicLink = try await deepLink.dynamicLink(shortLink: url) { + return try await deepLink.handleDynamicLink(dynamicLink: dynamicLink) } } } catch { @@ -79,12 +66,4 @@ class AchievementsViewModel: ObservableObject { } return nil } - - private func idToSha256(id: String) -> String? { - guard let data = id.data(using: .utf8) else { - return nil - } - let digest = SHA256.hash(data: data) - return digest.compactMap { String(format: "%02x", $0) }.joined() - } } diff --git a/app-ios/Modules/Sources/DeepLink/DeepLink.swift b/app-ios/Modules/Sources/DeepLink/DeepLink.swift new file mode 100644 index 000000000..0915d35b6 --- /dev/null +++ b/app-ios/Modules/Sources/DeepLink/DeepLink.swift @@ -0,0 +1,66 @@ +import CryptoKit +import Dependencies +import FirebaseDynamicLinks +import KMPContainer +import shared + +public class DeepLink { + @Dependency(\.achievementData) var achievementData + + public init() {} + + public func dynamicLink(fromUniversalLink url: URL) async throws -> DynamicLink? { + try await DynamicLinks.dynamicLinks().dynamicLink(fromUniversalLink: url) + } + + public func dynamicLink(customURL url: URL) -> DynamicLink? { + DynamicLinks.dynamicLinks().dynamicLink(fromCustomSchemeURL: url) + } + + public func dynamicLink(shortLink: URL) async throws -> DynamicLink? { + let resolvedLink = try await resolveShortLink(url: shortLink) + return dynamicLink(customURL: resolvedLink) + } + + public func resolveShortLink(url: URL) async throws -> URL { + try await DynamicLinks.dynamicLinks().resolveShortLink(url) + } + + @discardableResult + public func handleURL(url: URL) async throws -> Achievement? { + let hashedString = self.idToSha256(id: url.lastPathComponent) + let target: Achievement? = switch hashedString { + case Achievement.arcticfox.sha256: + Achievement.arcticfox + case Achievement.bumblebee.sha256: + Achievement.bumblebee + case Achievement.chipmunk.sha256: + Achievement.chipmunk + case Achievement.dolphin.sha256: + Achievement.dolphin + case Achievement.electriceel.sha256: + Achievement.electriceel + default: + nil + } + + if let achievement = target { + try await self.achievementData.saveAchievement(achievement) + } + + return target + } + + @discardableResult + public func handleDynamicLink(dynamicLink: DynamicLink) async throws -> Achievement? { + return try await handleURL(url: dynamicLink.url!) + } + + private func idToSha256(id: String) -> String? { + guard let data = id.data(using: .utf8) else { + return nil + } + let digest = SHA256.hash(data: data) + return digest.compactMap { String(format: "%02x", $0) }.joined() + } +} diff --git a/app-ios/Modules/Sources/KMPContainer/Container.swift b/app-ios/Modules/Sources/KMPContainer/Container.swift index 6390cc7b3..e090d2d63 100644 --- a/app-ios/Modules/Sources/KMPContainer/Container.swift +++ b/app-ios/Modules/Sources/KMPContainer/Container.swift @@ -1,5 +1,4 @@ import Auth -import Firebase import RemoteConfig import shared @@ -8,7 +7,6 @@ struct Container { private let entryPoint: KmpEntryPoint private init() { - FirebaseApp.configure() entryPoint = .init() entryPoint.doInit( remoteConfigApi: RemoteConfigApiImpl(), diff --git a/app-ios/Modules/Sources/Navigation/AppDelegate.swift b/app-ios/Modules/Sources/Navigation/AppDelegate.swift new file mode 100644 index 000000000..014c53f41 --- /dev/null +++ b/app-ios/Modules/Sources/Navigation/AppDelegate.swift @@ -0,0 +1,52 @@ +import DeepLink +import FirebaseCore +import UIKit + +public class AppDelegate: UIResponder, UIApplicationDelegate { + private let deepLink = DeepLink() + + public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + FirebaseApp.configure() + return true + } + + public func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + Task { + if let url = userActivity.webpageURL { + do { + if let dynamicLink = try await deepLink.dynamicLink(shortLink: url) { + try await deepLink.handleDynamicLink(dynamicLink: dynamicLink) + } + } catch { + print(error) + } + } + } + + return true + } + + public func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + config.delegateClass = SceneDelegate.self + return config + } +} + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + private let deepLink = DeepLink() + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + Task { + if let url = userActivity.webpageURL { + do { + if let dynamicLink = try await deepLink.dynamicLink(shortLink: url) { + try await deepLink.handleDynamicLink(dynamicLink: dynamicLink) + } + } catch { + print(error) + } + } + } + } +} diff --git a/app-ios/Modules/Sources/Navigation/RootView.swift b/app-ios/Modules/Sources/Navigation/RootView.swift index ddd264159..1952a5f10 100644 --- a/app-ios/Modules/Sources/Navigation/RootView.swift +++ b/app-ios/Modules/Sources/Navigation/RootView.swift @@ -68,7 +68,7 @@ public struct RootView: View { } } } - if isAchivementEnabled { +// if isAchivementEnabled { AchievementsView() .tag(Tab.achievements) .tabItem { @@ -84,7 +84,7 @@ public struct RootView: View { } } } - } +// } AboutView( contributorViewProvider: { _ in ContributorView()