diff --git a/app-ios/Modules/Package.swift b/app-ios/Modules/Package.swift index c9e094452..5d78cfb9c 100644 --- a/app-ios/Modules/Package.swift +++ b/app-ios/Modules/Package.swift @@ -145,6 +145,8 @@ var package = Package( "Component", "KMPContainer", "Model", + "shared", + .product(name: "Dependencies", package: "swift-dependencies"), ] ), .testTarget( @@ -154,6 +156,24 @@ var package = Package( ] ), + .target( + name: "Staff", + dependencies: [ + "Assets", + "Component", + "KMPContainer", + "Model", + "shared", + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), + .testTarget( + name: "StaffTests", + dependencies: [ + "Staff" + ] + ), + .target( name: "Stamps", dependencies: [ @@ -196,6 +216,7 @@ var package = Package( "FloorMap", "Session", "Sponsor", + "Staff", "Stamps", "Theme", "Timetable", diff --git a/app-ios/Modules/Sources/About/AboutView.swift b/app-ios/Modules/Sources/About/AboutView.swift index 768616034..007792601 100644 --- a/app-ios/Modules/Sources/About/AboutView.swift +++ b/app-ios/Modules/Sources/About/AboutView.swift @@ -9,17 +9,21 @@ enum AboutRouting: Hashable { case contributors case license case sponsors + case staffs } -public struct AboutView: View { +public struct AboutView: View { private let contributorViewProvider: ViewProvider + private let staffViewProvider: ViewProvider private let sponsorViewProvider: ViewProvider public init( contributorViewProvider: @escaping ViewProvider, + staffViewProvider: @escaping ViewProvider, sponsorViewProvider: @escaping ViewProvider ) { self.contributorViewProvider = contributorViewProvider + self.staffViewProvider = staffViewProvider self.sponsorViewProvider = sponsorViewProvider } @@ -56,10 +60,12 @@ public struct AboutView: View { .clipShape(RoundedRectangle(cornerRadius: 12)) Spacer().frame(height: 32) SectionTitle(title: "Credits") - ListTile( - icon: Assets.Icons.sentimentVerySatisfied.swiftUIImage, - title: "スタッフ" - ) + NavigationLink(value: AboutRouting.staffs) { + ListTile( + icon: Assets.Icons.sentimentVerySatisfied.swiftUIImage, + title: "スタッフ" + ) + } Divider() NavigationLink(value: AboutRouting.contributors) { ListTile( @@ -123,6 +129,8 @@ public struct AboutView: View { switch routing { case .contributors: contributorViewProvider(()) + case .staffs: + staffViewProvider(()) case .sponsors: sponsorViewProvider(()) case .license: @@ -136,6 +144,7 @@ public struct AboutView: View { #Preview { AboutView( contributorViewProvider: {_ in EmptyView()}, + staffViewProvider: {_ in EmptyView()}, sponsorViewProvider: {_ in EmptyView()} ) } diff --git a/app-ios/Modules/Sources/Component/SafariLink.swift b/app-ios/Modules/Sources/Component/SafariLink.swift index ad77a65a4..1357715db 100644 --- a/app-ios/Modules/Sources/Component/SafariLink.swift +++ b/app-ios/Modules/Sources/Component/SafariLink.swift @@ -2,13 +2,13 @@ import SafariServices import SwiftUI public struct SafariLink: View where Content: View { - + @State private var isSheetPresented: Bool = false - + private let url: URL private let configuration: SFSafariViewController.Configuration private let content: () -> Content - + public init( url: URL, configuration: SFSafariViewController.Configuration? = nil, @@ -20,7 +20,7 @@ public struct SafariLink: View where Content: View { self.configuration = configuration ?? defaultConfiguration self.content = content } - + public var body: some View { Button { isSheetPresented = true diff --git a/app-ios/Modules/Sources/Component/SafariView.swift b/app-ios/Modules/Sources/Component/SafariView.swift index adefced46..b3b40842d 100644 --- a/app-ios/Modules/Sources/Component/SafariView.swift +++ b/app-ios/Modules/Sources/Component/SafariView.swift @@ -4,7 +4,7 @@ import SwiftUI public struct SafariView: UIViewControllerRepresentable { private let url: URL private let configuration: SFSafariViewController.Configuration - + public init( url: URL, configuration: SFSafariViewController.Configuration? = nil @@ -14,7 +14,7 @@ public struct SafariView: UIViewControllerRepresentable { defaultConfiguration.barCollapsingEnabled = false self.configuration = configuration ?? defaultConfiguration } - + public func makeUIViewController(context: Context) -> some UIViewController { let safariViewController = SFSafariViewController( url: url, @@ -22,9 +22,9 @@ public struct SafariView: UIViewControllerRepresentable { ) return safariViewController } - + public func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { - + } } diff --git a/app-ios/Modules/Sources/Contributor/ContributorView.swift b/app-ios/Modules/Sources/Contributor/ContributorView.swift index 3b3d94c4d..06d4beb84 100644 --- a/app-ios/Modules/Sources/Contributor/ContributorView.swift +++ b/app-ios/Modules/Sources/Contributor/ContributorView.swift @@ -1,7 +1,9 @@ import Component +import Model import SwiftUI public struct ContributorView: View { + @State var presentingURL: IdentifiableURL? @ObservedObject var viewModel: ContributorViewModel = .init() public init() {} @@ -20,10 +22,16 @@ public struct ContributorView: View { ScrollView { LazyVStack(spacing: 20) { ForEach(contributors, id: \.id) { contributor in - PersonLabel( - name: contributor.username, - iconUrlString: contributor.iconUrl - ) + Button { + if let profileUrl = contributor.profileUrl { + presentingURL = IdentifiableURL(string: profileUrl) + } + } label: { + PersonLabel( + name: contributor.username, + iconUrlString: contributor.iconUrl + ) + } } } .padding(16) @@ -31,6 +39,12 @@ public struct ContributorView: View { } } .navigationTitle("Contributor") + .sheet(item: $presentingURL) { url in + if let url = url.id { + SafariView(url: url) + .ignoresSafeArea() + } + } } } diff --git a/app-ios/Modules/Sources/KMPContainer/StaffsDataProvider.swift b/app-ios/Modules/Sources/KMPContainer/StaffsDataProvider.swift new file mode 100644 index 000000000..1e50daa26 --- /dev/null +++ b/app-ios/Modules/Sources/KMPContainer/StaffsDataProvider.swift @@ -0,0 +1,39 @@ +import Dependencies +import shared + +public struct StaffsDataProvider { + private static var staffRepository: StaffRepository { + Container.shared.get(type: StaffRepository.self) + } + + public let refresh: () async throws -> Void + public let staffs: () -> AsyncThrowingStream<[Staff], Error> +} + +extension StaffsDataProvider: DependencyKey { + @MainActor + public static var liveValue: StaffsDataProvider = StaffsDataProvider( + refresh: { @MainActor in + try await staffRepository.refresh() + }, + staffs: { + staffRepository.staffs().stream() + } + ) + + public static var testValue: StaffsDataProvider = StaffsDataProvider( + refresh: {}, + staffs: { + .init { + Staff.companion.fakes() + } + } + ) +} + + public extension DependencyValues { + var staffsData: StaffsDataProvider { + get { self[StaffsDataProvider.self] } + set { self[StaffsDataProvider.self] = newValue } + } +} diff --git a/app-ios/Modules/Sources/Model/IdentifiableURL.swift b/app-ios/Modules/Sources/Model/IdentifiableURL.swift new file mode 100644 index 000000000..484370635 --- /dev/null +++ b/app-ios/Modules/Sources/Model/IdentifiableURL.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct IdentifiableURL: Identifiable { + public var id: URL? + + public init(_ id: URL?) { + self.id = id + } + + public init(string: String) { + self.id = URL(string: string) + } +} diff --git a/app-ios/Modules/Sources/Navigation/RootView.swift b/app-ios/Modules/Sources/Navigation/RootView.swift index d493d9c13..25efc1d74 100644 --- a/app-ios/Modules/Sources/Navigation/RootView.swift +++ b/app-ios/Modules/Sources/Navigation/RootView.swift @@ -4,6 +4,7 @@ import Contributor import FloorMap import Session import Sponsor +import Staff import Stamps import SwiftUI import Theme @@ -61,6 +62,9 @@ public struct RootView: View { contributorViewProvider: { _ in ContributorView() }, + staffViewProvider: { _ in + StaffView() + }, sponsorViewProvider: { _ in SponsorView() } diff --git a/app-ios/Modules/Sources/Staff/StaffView.swift b/app-ios/Modules/Sources/Staff/StaffView.swift new file mode 100644 index 000000000..1477899d8 --- /dev/null +++ b/app-ios/Modules/Sources/Staff/StaffView.swift @@ -0,0 +1,52 @@ +import Component +import Model +import shared +import SwiftUI + +public struct StaffView: View { + @State var presentingURL: IdentifiableURL? + @ObservedObject var viewModel: StaffViewModel = .init() + + public init() {} + + public var body: some View { + Group { + switch viewModel.state.staffs { + case .initial, .loading: + ProgressView() + .task { + await viewModel.load() + } + case .failed: + EmptyView() + case .loaded(let staffs): + ScrollView { + LazyVStack(spacing: 20) { + ForEach(staffs, id: \.id) { staff in + Button { + presentingURL = IdentifiableURL(string: staff.profileUrl) + } label: { + PersonLabel( + name: staff.username, + iconUrlString: staff.iconUrl + ) + } + } + } + .padding(16) + } + } + } + .navigationTitle("Staff") + .sheet(item: $presentingURL) { url in + if let url = url.id { + SafariView(url: url) + .ignoresSafeArea() + } + } + } +} + +#Preview { + StaffView() +} diff --git a/app-ios/Modules/Sources/Staff/StaffViewModel.swift b/app-ios/Modules/Sources/Staff/StaffViewModel.swift new file mode 100644 index 000000000..cd7897068 --- /dev/null +++ b/app-ios/Modules/Sources/Staff/StaffViewModel.swift @@ -0,0 +1,26 @@ +import Dependencies +import Foundation +import KMPContainer +import Model +import shared + +struct StaffState: ViewModelState { + var staffs: LoadingState<[Staff]> = .initial +} + +@MainActor +final class StaffViewModel: ObservableObject { + @Dependency(\.staffsData) var staffsData + @Published private(set) var state: StaffState = .init() + + func load() async { + state.staffs = .loading + do { + for try await staffs in staffsData.staffs() { + state.staffs = .loaded(staffs) + } + } catch let error { + state.staffs = .failed(error) + } + } +} diff --git a/app-ios/Modules/Tests/StaffTests/StaffTests.swift b/app-ios/Modules/Tests/StaffTests/StaffTests.swift new file mode 100644 index 000000000..cfe3a2f96 --- /dev/null +++ b/app-ios/Modules/Tests/StaffTests/StaffTests.swift @@ -0,0 +1,11 @@ +@testable import Staff +import XCTest + +final class StaffTests: XCTestCase { + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct + // results. + XCTAssertEqual("Hello, World!", "Hello, World!") + } +}