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/App.swift b/app-ios/App/DroidKaigi2023/DroidKaigi2023/App.swift index 3f6b80b5d..38cce7535 100644 --- a/app-ios/App/DroidKaigi2023/DroidKaigi2023/App.swift +++ b/app-ios/App/DroidKaigi2023/DroidKaigi2023/App.swift @@ -4,8 +4,10 @@ import Theme @main struct MainApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + init() { FontAssets.registerAllCustomFonts() } - + var body: some Scene { WindowGroup { RootView() 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/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements b/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements index f2ef3ae02..414f3adac 100644 --- a/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements +++ b/app-ios/App/DroidKaigi2023/DroidKaigi2023/DroidKaigi2023.entitlements @@ -2,9 +2,18 @@ - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - + com.apple.developer.associated-domains + + applinks:confsched2023.page.link + applinks:droidkaigiapp.page.link + + 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 e3a68e949..2779f717f 100644 --- a/app-ios/App/DroidKaigi2023/DroidKaigi2023/Info.plist +++ b/app-ios/App/DroidKaigi2023/DroidKaigi2023/Info.plist @@ -6,5 +6,23 @@ CFBundleAllowMixedLocalizations + 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 281a2af4c..04b2fe09d 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( @@ -98,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: [ @@ -194,9 +205,12 @@ var package = Package( name: "Achievements", dependencies: [ "Assets", + "DeepLink", "Theme", "KMPContainer", + "NFC", .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ] ), .testTarget( @@ -230,12 +244,21 @@ var package = Package( "Achievements", "Assets", "Contributor", + "DeepLink", "FloorMap", "Session", "Sponsor", "Staff", "Theme", "Timetable", + .product(name: "FirebaseRemoteConfig", package: "firebase-ios-sdk"), + ] + ), + + .target( + name: "NFC", + dependencies: [ + "Model", ] ), diff --git a/app-ios/Modules/Sources/Achievements/AchievementsView.swift b/app-ios/Modules/Sources/Achievements/AchievementsView.swift index 99a589cda..09671d501 100644 --- a/app-ios/Modules/Sources/Achievements/AchievementsView.swift +++ b/app-ios/Modules/Sources/Achievements/AchievementsView.swift @@ -1,29 +1,121 @@ import Assets +import shared import SwiftUI import Theme public struct AchievementsView: View { + @ObservedObject var viewModel: AchievementsViewModel = .init() + @State private var obtainedAchievement: Achievement? + 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 { + 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.electriceel, + savedAchievements: state.achievements, + activeImage: Assets.Images.achievementElectricEelActive, + inactiveImage: Assets.Images.achievementElectricEel + ) + } + Button { + Task { + let result = await viewModel.read() + obtainedAchievement = result + } + } label: { + 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 + } + } } } - .padding(16) + .background(AssetColors.Surface.surface.swiftUIColor) + .navigationTitle("Achievements") } - .background(AssetColors.Surface.surface.swiftUIColor) - .navigationTitle("Achievements") + } + } +} + +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 + } + } +} + +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 } } } diff --git a/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift b/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift index 8b1378917..0c83e4f0a 100644 --- a/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift +++ b/app-ios/Modules/Sources/Achievements/AchievementsViewModel.swift @@ -1 +1,69 @@ +import AsyncAlgorithms +import CryptoKit +import DeepLink +import Dependencies +import Foundation +import KMPContainer +import Model +import NFC +import shared +struct AchievementsViewState: ViewModelState { + struct LoadedState: Equatable { + var description: String + var achievements: Set + } + + var loadedState: LoadingState = .initial +} + +@MainActor +class AchievementsViewModel: ObservableObject { + @Dependency(\.achievementData) var achievementData + @Published var state: AchievementsViewState = .init() + private let deepLink = DeepLink() + private let nfcReader = NFCReader() + 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.achievementData.achievementDetailDescription(), + self.achievementData.achievements() + ) { + self.state.loadedState = .loaded( + .init( + description: description, + achievements: achievements + ) + ) + } + } catch { + self.state.loadedState = .failed(error) + } + } + } + + func read() async -> Achievement? { + do { + 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 { + print(error) + } + return nil + } +} 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 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/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/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/KMPContainer/StampDataProvider.swift b/app-ios/Modules/Sources/KMPContainer/StampDataProvider.swift deleted file mode 100644 index e84133ac3..000000000 --- a/app-ios/Modules/Sources/KMPContainer/StampDataProvider.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Dependencies -import shared - -public struct StampDataProvider { - private static var stampRepository: AchievementRepository { - Container.shared.get(type: AchievementRepository.self) - } - - public let stampEnabled: () -> AsyncThrowingStream -} - -extension StampDataProvider: DependencyKey { - @MainActor - public static var liveValue: StampDataProvider = StampDataProvider( - stampEnabled: { - stampRepository.getAchievementEnabledStream().stream() - } - ) - - public static var testValue: StampDataProvider = StampDataProvider( - stampEnabled: { - .init { - true - } - } - ) -} - -public extension DependencyValues { - var stampData: StampDataProvider { - get { self[StampDataProvider.self] } - set { self[StampDataProvider.self] = newValue } - } -} 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() + } +} 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() 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)