diff --git a/README.md b/README.md index 30d330d3d..6e2475afa 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ In addition to the standard features of a conference app, the DroidKaigi 2024 of - **Contributors**: Discover the contributors behind the app. ...and more! -![image](https://github.com/user-attachments/assets/ffed2cb2-455b-4de8-a9d2-be9ca0842b99) +![image](https://github.com/user-attachments/assets/d1aeccc1-1e8e-475f-9c51-72fb595c6563) ## Try the app diff --git a/app-android/src/main/AndroidManifest.xml b/app-android/src/main/AndroidManifest.xml index a3e472e3e..27dd16aa5 100644 --- a/app-android/src/main/AndroidManifest.xml +++ b/app-android/src/main/AndroidManifest.xml @@ -58,6 +58,16 @@ android:name="com.google.android.gms.oss.licenses.OssLicensesActivity" android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar" /> + + + + diff --git a/app-android/src/main/java/io/github/droidkaigi/confsched/KaigiApp.kt b/app-android/src/main/java/io/github/droidkaigi/confsched/KaigiApp.kt index 36dfed7ef..c9cb21b00 100644 --- a/app-android/src/main/java/io/github/droidkaigi/confsched/KaigiApp.kt +++ b/app-android/src/main/java/io/github/droidkaigi/confsched/KaigiApp.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.font.FontFamily @@ -80,6 +81,7 @@ import io.github.droidkaigi.confsched.sessions.timetableScreenRoute import io.github.droidkaigi.confsched.settings.settingsScreenRoute import io.github.droidkaigi.confsched.settings.settingsScreens import io.github.droidkaigi.confsched.share.ShareNavigator +import io.github.droidkaigi.confsched.share.saveToDisk import io.github.droidkaigi.confsched.sponsors.sponsorsScreenRoute import io.github.droidkaigi.confsched.sponsors.sponsorsScreens import io.github.droidkaigi.confsched.staff.staffScreenRoute @@ -134,11 +136,20 @@ private fun KaigiNavHost( CompositionLocalProvider( LocalSharedTransitionScope provides this, ) { + val context = LocalContext.current NavHostWithSharedAxisX( navController = navController, startDestination = mainScreenRoute, ) { - mainScreen(windowSize, navController, externalNavController) + mainScreen( + windowSize, + navController, + externalNavController, + onClickShareProfileCard = { shareText, imageBitmap -> + val imageAbsolutePath = imageBitmap.saveToDisk(context) + externalNavController.onShareProfileCardClick(shareText, imageAbsolutePath) + }, + ) sessionScreens( onNavigationIconClick = navController::popBackStack, onLinkClick = externalNavController::navigate, @@ -185,6 +196,7 @@ private fun NavGraphBuilder.mainScreen( navController: NavHostController, @Suppress("UnusedParameter") externalNavController: ExternalNavController, + onClickShareProfileCard: (String, ImageBitmap) -> Unit, ) { mainScreen( windowSize = windowSize, @@ -197,6 +209,7 @@ private fun NavGraphBuilder.mainScreen( contentPadding = contentPadding, ) eventMapScreens( + contentPadding = contentPadding, onEventMapItemClick = externalNavController::navigate, ) favoritesScreens( @@ -248,7 +261,10 @@ private fun NavGraphBuilder.mainScreen( } }, ) - profileCardScreen(contentPadding) + profileCardScreen( + contentPadding = contentPadding, + onClickShareProfileCard = onClickShareProfileCard, + ) }, ) } @@ -346,6 +362,16 @@ private class ExternalNavController( ) } + fun onShareProfileCardClick( + text: String, + filePath: String, + ) { + shareNavigator.shareTextWithImage( + text = text, + filePath = filePath, + ) + } + @Suppress("SwallowedException") @RequiresApi(Build.VERSION_CODES.R) private fun navigateToNativeAppApi30( diff --git a/app-android/src/main/java/io/github/droidkaigi/confsched/share/SaveToDisk.kt b/app-android/src/main/java/io/github/droidkaigi/confsched/share/SaveToDisk.kt new file mode 100644 index 000000000..f7a366965 --- /dev/null +++ b/app-android/src/main/java/io/github/droidkaigi/confsched/share/SaveToDisk.kt @@ -0,0 +1,31 @@ +package io.github.droidkaigi.confsched.share + +import android.content.Context +import android.graphics.Bitmap.CompressFormat.PNG +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import kotlinx.datetime.Clock.System +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import java.io.File +import java.io.FileOutputStream + +fun ImageBitmap.saveToDisk(context: Context): String { + val timestamp = System.now() + .toLocalDateTime(TimeZone.UTC) + .toString() + .replace(":", "") + .replace(".", "") + val fileName = "shared_image_$timestamp.png" + + val cachePath = File(context.cacheDir, "images") + cachePath.mkdirs() + val file = File(cachePath, fileName) + val outputStream = FileOutputStream(file) + + this.asAndroidBitmap().compress(PNG, 100, outputStream) + outputStream.flush() + outputStream.close() + + return file.absolutePath +} diff --git a/app-android/src/main/java/io/github/droidkaigi/confsched/share/ShareNavigator.kt b/app-android/src/main/java/io/github/droidkaigi/confsched/share/ShareNavigator.kt index 107a13598..82872c6ee 100644 --- a/app-android/src/main/java/io/github/droidkaigi/confsched/share/ShareNavigator.kt +++ b/app-android/src/main/java/io/github/droidkaigi/confsched/share/ShareNavigator.kt @@ -3,7 +3,9 @@ package io.github.droidkaigi.confsched.share import android.content.ActivityNotFoundException import android.content.Context import androidx.core.app.ShareCompat +import androidx.core.content.FileProvider import co.touchlab.kermit.Logger +import java.io.File class ShareNavigator(private val context: Context) { fun share(text: String) { @@ -16,4 +18,19 @@ class ShareNavigator(private val context: Context) { Logger.e("ActivityNotFoundException Fail startActivity: $e") } } + + fun shareTextWithImage(text: String, filePath: String) { + try { + val file = File(filePath) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file) + + ShareCompat.IntentBuilder(context) + .setStream(uri) + .setText(text) + .setType("image/png") + .startChooser() + } catch (e: ActivityNotFoundException) { + Logger.e("ActivityNotFoundException Fail startActivity: $e") + } + } } diff --git a/app-android/src/main/res/xml/file_paths.xml b/app-android/src/main/res/xml/file_paths.xml new file mode 100644 index 000000000..0ba6f2ad9 --- /dev/null +++ b/app-android/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app-ios/App/App.xcodeproj/project.pbxproj b/app-ios/App/App.xcodeproj/project.pbxproj index 75c9839e4..0e6e439e4 100644 --- a/app-ios/App/App.xcodeproj/project.pbxproj +++ b/app-ios/App/App.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 830BFA7C2C74EBF40017A600 /* compose-resources in Resources */ = {isa = PBXBuildFile; fileRef = 830BFA7B2C74EBF40017A600 /* compose-resources */; }; 8C31F46B2BF6909A003F1BBA /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 8C31F46A2BF6909A003F1BBA /* GoogleService-Info.plist */; }; 8C7DACB72BCBCCB0002C298A /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8C7DACB02BCBCCB0002C298A /* Preview Assets.xcassets */; }; 8C7DACB82BCBCCB0002C298A /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7DACB22BCBCCB0002C298A /* App.swift */; }; @@ -15,6 +16,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 830BFA7B2C74EBF40017A600 /* compose-resources */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "compose-resources"; path = "../../app-ios-shared/build/kotlin-multiplatform-resources/aggregated-resources/iosSimulatorArm64/compose-resources"; sourceTree = ""; }; 8C31F46A2BF6909A003F1BBA /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 8C772B152BCBCBCA00F2BADC /* DroidKaigi2024App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DroidKaigi2024App.app; sourceTree = BUILT_PRODUCTS_DIR; }; 8C7DACB02BCBCCB0002C298A /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -42,6 +44,7 @@ 8C772B0C2BCBCBCA00F2BADC = { isa = PBXGroup; children = ( + 830BFA7B2C74EBF40017A600 /* compose-resources */, C412816C2C149FB500B458D1 /* DroidKaigi2024App-Info.plist */, 8C7DACC22BCBD111002C298A /* App */, 8C7DACC12BCBD0F1002C298A /* app-ios */, @@ -152,6 +155,7 @@ 8C7DACB92BCBCCB0002C298A /* Assets.xcassets in Resources */, 8C7DACB72BCBCCB0002C298A /* Preview Assets.xcassets in Resources */, 8C31F46B2BF6909A003F1BBA /* GoogleService-Info.plist in Resources */, + 830BFA7C2C74EBF40017A600 /* compose-resources in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -173,7 +177,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/zsh; - shellScript = "cd ${SRCROOT}/../..\n./gradlew assembleSharedXCFramework --no-configuration-cache\n"; + shellScript = "cd ${SRCROOT}/../..\n./gradlew assembleSharedXCFramework --no-configuration-cache\n./gradlew iosSimulatorArm64AggregateResources --no-configuration-cache\n\nRESOURCE_DIR=\"./app-ios-shared/build/kotlin-multiplatform-resources/aggregated-resources/iosSimulatorArm64\"\n\nmkdir ${RESOURCE_DIR}/compose-resources\ncp -R ${RESOURCE_DIR}/composeResources ${RESOURCE_DIR}/compose-resources\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/app-ios/Sources/CommonComponents/Timetable/CircularUserIcon.swift b/app-ios/Sources/CommonComponents/Timetable/CircularUserIcon.swift new file mode 100644 index 000000000..3101378f3 --- /dev/null +++ b/app-ios/Sources/CommonComponents/Timetable/CircularUserIcon.swift @@ -0,0 +1,59 @@ +import Foundation +import SwiftUI + +private actor CircularUserIconInMemoryCache { + static let shared = CircularUserIconInMemoryCache() + private init() {} + + private var cache: [String: Data] = [:] + + func data(urlString: String) -> Data? { + return cache[urlString] + } + + func set(data: Data, urlString: String) { + cache[urlString] = data + } +} + +public struct CircularUserIcon: View { + let urlString: String + @State private var iconData: Data? + + public init(urlString: String) { + self.urlString = urlString + } + + public var body: some View { + Group { + if let data = iconData, + let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + } else { + Circle().stroke(Color.gray) + } + } + .clipShape(Circle()) + .task { + if let data = await CircularUserIconInMemoryCache.shared.data(urlString: urlString) { + iconData = data + return + } + + guard let url = URL(string: urlString) else { + return + } + let urlRequest = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy) + if let (data, _) = try? await URLSession.shared.data(for: urlRequest) { + iconData = data + await CircularUserIconInMemoryCache.shared.set(data: data, urlString: urlString) + } + } + } +} + +#Preview { + CircularUserIcon(urlString: "https://avatars.githubusercontent.com/u/10727543?s=96&v=4") + .frame(width: 32, height: 32) +} diff --git a/app-ios/Sources/CommonComponents/Timetable/RoomTag.swift b/app-ios/Sources/CommonComponents/Timetable/RoomTag.swift index d21ef50f4..7a3aeb31d 100644 --- a/app-ios/Sources/CommonComponents/Timetable/RoomTag.swift +++ b/app-ios/Sources/CommonComponents/Timetable/RoomTag.swift @@ -1,74 +1,37 @@ import SwiftUI import Theme -import shared import Model public struct RoomTag: View { let roomName: MultiLangText + let roomType: RoomType public init(_ roomName: MultiLangText) { self.roomName = roomName + self.roomType = .init(enTitle: roomName.enTitle) } public var body: some View { HStack(spacing: 4) { - roomName.roomType.shape + RoomTypeShape(roomType: roomType) Text(roomName.currentLangTitle) .textStyle(.labelMedium) } - .foregroundStyle(roomName.roomType.theme.primaryColor) + .foregroundStyle(roomType.theme.primaryColor) .padding(.horizontal, 6) .overlay( RoundedRectangle(cornerRadius: 2) - .stroke(roomName.roomType.theme.primaryColor, lineWidth: 1) + .stroke(roomType.theme.primaryColor, lineWidth: 1) ) } } -enum ThemeKey { - static let iguana = "iguana" - static let hedgehog = "hedgehog" - static let giraffe = "giraffe" - static let flamingo = "flamingo" - static let jellyfish = "jellyfish" -} - - -extension MultiLangText { - var roomType: RoomType { - switch enTitle.lowercased() { - case ThemeKey.flamingo: .roomF - case ThemeKey.giraffe: .roomG - case ThemeKey.hedgehog: .roomH - case ThemeKey.iguana: .roomI - case ThemeKey.jellyfish: .roomJ - default: .roomIj - } - } -} - -extension RoomType { - public var shape: some View { - Group { - switch self { - case .roomG: Image(.icCircleFill).renderingMode(.template) - case .roomH: Image(.icDiamondFill).renderingMode(.template) - case .roomF: Image(.icSharpDiamondFill).renderingMode(.template) - case .roomI: Image(.icSquareFill).renderingMode(.template) - case .roomJ: Image(.icTriangleFill).renderingMode(.template) - case .roomIj: Image(.icSquareFill).renderingMode(.template) - } - } - .foregroundStyle(theme.primaryColor) - .frame(width: 12, height: 12) - } -} - #Preview { RoomTag( MultiLangText( - jaTitle: "Iguana", - enTitle: "Iguana" + currentLangTitle: "Iguana", + enTitle: "Iguana", + jaTitle: "Iguana" ) ) } diff --git a/app-ios/Sources/CommonComponents/Timetable/RoomTypeShape.swift b/app-ios/Sources/CommonComponents/Timetable/RoomTypeShape.swift new file mode 100644 index 000000000..3692cdc0c --- /dev/null +++ b/app-ios/Sources/CommonComponents/Timetable/RoomTypeShape.swift @@ -0,0 +1,36 @@ +import SwiftUI +import Model + +public struct RoomTypeShape: View { + let roomType: RoomType + + public init(roomType: RoomType) { + self.roomType = roomType + } + + public var body: some View { + Group { + switch roomType { + case .roomG: Image(.icCircleFill).renderingMode(.template) + case .roomH: Image(.icDiamondFill).renderingMode(.template) + case .roomF: Image(.icSharpDiamondFill).renderingMode(.template) + case .roomI: Image(.icSquareFill).renderingMode(.template) + case .roomJ: Image(.icTriangleFill).renderingMode(.template) + case .roomIj: Image(.icSquareFill).renderingMode(.template) + } + } + .foregroundStyle(roomType.theme.primaryColor) + .frame(width: 12, height: 12) + } +} + +#Preview { + Group { + RoomTypeShape(roomType: .roomF) + RoomTypeShape(roomType: .roomG) + RoomTypeShape(roomType: .roomH) + RoomTypeShape(roomType: .roomI) + RoomTypeShape(roomType: .roomJ) + RoomTypeShape(roomType: .roomIj) + } +} diff --git a/app-ios/Sources/CommonComponents/Timetable/TimetableCard.swift b/app-ios/Sources/CommonComponents/Timetable/TimetableCard.swift index 3afe811a2..a3d26d265 100644 --- a/app-ios/Sources/CommonComponents/Timetable/TimetableCard.swift +++ b/app-ios/Sources/CommonComponents/Timetable/TimetableCard.swift @@ -26,7 +26,11 @@ public struct TimetableCard: View { } label: { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 4) { - RoomTag(timetableItem.room.name) + RoomTag(.init( + currentLangTitle: timetableItem.room.name.currentLangTitle, + enTitle: timetableItem.room.name.enTitle, + jaTitle: timetableItem.room.name.jaTitle + )) ForEach(timetableItem.language.labels, id: \.self) { label in LanguageTag(label) } @@ -54,18 +58,8 @@ public struct TimetableCard: View { ForEach(timetableItem.speakers, id: \.id) { speaker in HStack(spacing: 8) { - Group { - if let url = URL(string: speaker.iconUrl) { - AsyncImage(url: url) { - $0.image?.resizable() - } - } else { - Circle().stroke(Color.gray) - } - } - .frame(width: 32, height: 32) - .clipShape(Circle()) - + CircularUserIcon(urlString: speaker.iconUrl) + .frame(width: 32, height: 32) Text(speaker.name) .textStyle(.titleSmall) .foregroundStyle(AssetColors.Surface.onSurfaceVariant.swiftUIColor) diff --git a/app-ios/Sources/ContributorFeature/ContributorListItemView.swift b/app-ios/Sources/ContributorFeature/ContributorListItemView.swift index 3adc985ef..9c77c31bc 100644 --- a/app-ios/Sources/ContributorFeature/ContributorListItemView.swift +++ b/app-ios/Sources/ContributorFeature/ContributorListItemView.swift @@ -1,6 +1,7 @@ import SwiftUI import Theme import Model +import CommonComponents struct ContributorListItemView: View { let contributor: Contributor @@ -13,11 +14,8 @@ struct ContributorListItemView: View { } } label: { HStack(alignment: .center, spacing: 12) { - AsyncImage(url: contributor.iconUrl) { - $0.image?.resizable() - } - .frame(width: 52, height: 52) - .clipShape(Circle()) + CircularUserIcon(urlString: contributor.iconUrl.absoluteString) + .frame(width: 52, height: 52) Text(contributor.userName) .textStyle(.bodyLarge) diff --git a/app-ios/Sources/ContributorFeature/ContributorView.swift b/app-ios/Sources/ContributorFeature/ContributorView.swift index 97544a93e..2188d28f5 100644 --- a/app-ios/Sources/ContributorFeature/ContributorView.swift +++ b/app-ios/Sources/ContributorFeature/ContributorView.swift @@ -20,13 +20,13 @@ public struct ContributorView: View { "KMP Presenter" case .fullKmp: - "KMP Compose view" + "KMP Compose View" } } } - @State private var viewType: ViewType = .swift - + @State private var selectedTab: ViewType = .swift + @Namespace var namespace @Bindable var store: StoreOf public init(store: StoreOf) { @@ -35,23 +35,24 @@ public struct ContributorView: View { public var body: some View { VStack(spacing: 0) { - Picker("", selection: $viewType) { - ForEach(ViewType.allCases, id: \.self) { segment in - Text(segment.title) - } - } - .pickerStyle(.segmented) - .padding(16) + tabBar - switch viewType { + switch selectedTab { case .swift: SwiftUIContributorView(store: store) case .kmpPresenter: - KmpPresenterContributorView() - + KmpPresenterContributorView { + store.send(.view(.contributorButtonTapped($0))) + } + .tag(ViewType.kmpPresenter) case .fullKmp: - KmpContributorComposeViewControllerWrapper() + KmpContributorComposeViewControllerWrapper { urlString in + guard let url = URL(string: urlString) else { + return + } + store.send(.view(.contributorButtonTapped(url))) + } } } .background(AssetColors.Surface.surface.swiftUIColor) @@ -62,6 +63,41 @@ public struct ContributorView: View { .ignoresSafeArea() }) } + + @MainActor + private var tabBar: some View { + HStack { + ForEach(ViewType.allCases, id: \.self) { tab in + Button { + selectedTab = tab + } label: { + ZStack { + Text(tab.title) + .textStyle(.titleMedium) + .foregroundStyle( + selectedTab == tab ? AssetColors.Primary.primaryFixed.swiftUIColor : AssetColors.Surface.onSurface.swiftUIColor + ) + VStack { + Spacer() + Group { + if selectedTab == tab { + AssetColors.Primary.primaryFixed.swiftUIColor + .matchedGeometryEffect(id: "underline", in: namespace, properties: .frame) + } else { + Color.clear + } + } + .frame(height: 3) + } + } + .frame(height: 52, alignment: .center) + .frame(maxWidth: .infinity) + .animation(.spring(), value: selectedTab) + } + .frame(maxWidth: .infinity) + } + } + } } #Preview { diff --git a/app-ios/Sources/ContributorFeature/KmpPresenterContributorView.swift b/app-ios/Sources/ContributorFeature/KmpPresenterContributorView.swift index de91f8455..b7f9187d3 100644 --- a/app-ios/Sources/ContributorFeature/KmpPresenterContributorView.swift +++ b/app-ios/Sources/ContributorFeature/KmpPresenterContributorView.swift @@ -8,14 +8,15 @@ import Theme struct KmpPresenterContributorView: View { private let repositories: any Repositories private let events: SkieSwiftMutableSharedFlow + private let onContributorButtonTapped: (URL) -> Void @State private var currentState: ContributorsUiState? = nil - @State private var showingUrl: IdentifiableURL? - init() { + init(onContributorButtonTapped: @escaping (URL) -> Void) { self.repositories = Container.shared.get(type: (any Repositories).self) self.events = SkieKotlinSharedFlowFactory() .createSkieKotlinSharedFlow(replay: 0, extraBufferCapacity: 0) + self.onContributorButtonTapped = onContributorButtonTapped } var body: some View { @@ -30,9 +31,10 @@ struct KmpPresenterContributorView: View { profileUrl: value.profileUrl.map { URL(string: $0)! } , iconUrl: URL(string: value.iconUrl)! ) - ContributorListItemView(contributor: contributor) { url in - showingUrl = IdentifiableURL(url) - } + ContributorListItemView( + contributor: contributor, + onContributorButtonTapped: onContributorButtonTapped + ) } } } @@ -45,10 +47,6 @@ struct KmpPresenterContributorView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(AssetColors.Surface.surface.swiftUIColor) - .sheet(item: $showingUrl, content: { url in - SafariView(url: url.id) - .ignoresSafeArea() - }) } @MainActor diff --git a/app-ios/Sources/ContributorFeature/Resources/Localizable.xcstrings b/app-ios/Sources/ContributorFeature/Resources/Localizable.xcstrings index d13424538..b2ad6f9c7 100644 --- a/app-ios/Sources/ContributorFeature/Resources/Localizable.xcstrings +++ b/app-ios/Sources/ContributorFeature/Resources/Localizable.xcstrings @@ -1,9 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "" : { - - }, "Contributor" : { "localizations" : { "en" : { diff --git a/app-ios/Sources/EventMapFeature/EventItem.swift b/app-ios/Sources/EventMapFeature/EventItem.swift index 2fd6d19ef..5a3cf7e23 100644 --- a/app-ios/Sources/EventMapFeature/EventItem.swift +++ b/app-ios/Sources/EventMapFeature/EventItem.swift @@ -11,7 +11,12 @@ struct EventItem: View { var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 12) { - RoomTag(event.roomName) + RoomTag(.init( + currentLangTitle: event.roomName.currentLangTitle, + enTitle: event.roomName.enTitle, + jaTitle: event.roomName.jaTitle + )) + Text(event.name.currentLangTitle) .foregroundStyle(AssetColors.Primary.primaryFixed.swiftUIColor) .textStyle(.titleMedium) diff --git a/app-ios/Sources/KMPClient/Views/KmpContributorComposeViewControllerWrapper.swift b/app-ios/Sources/KMPClient/Views/KmpContributorComposeViewControllerWrapper.swift index 92e05dca8..871d23637 100644 --- a/app-ios/Sources/KMPClient/Views/KmpContributorComposeViewControllerWrapper.swift +++ b/app-ios/Sources/KMPClient/Views/KmpContributorComposeViewControllerWrapper.swift @@ -1,17 +1,21 @@ import SwiftUI -@preconcurrency import shared +import shared public struct KmpContributorComposeViewControllerWrapper: UIViewControllerRepresentable { + public typealias URLString = String + public let repositories: any Repositories + private let onContributorsItemClick: (URLString) -> Void - public init() { + public init(onContributorsItemClick: @escaping (URLString) -> Void) { self.repositories = Container.shared.get(type: (any Repositories).self) + self.onContributorsItemClick = onContributorsItemClick } public func makeUIViewController(context: Context) -> UIViewController { return contributorsViewController( repositories: repositories, - onContributorsItemClick: {_ in} + onContributorsItemClick: onContributorsItemClick ) } diff --git a/app-ios/Sources/Model/Entity/MultiLangText.swift b/app-ios/Sources/Model/Entity/MultiLangText.swift new file mode 100644 index 000000000..e2a5c708b --- /dev/null +++ b/app-ios/Sources/Model/Entity/MultiLangText.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct MultiLangText { + public let currentLangTitle: String + public let enTitle: String + public let jaTitle: String + + public init(currentLangTitle: String, enTitle: String, jaTitle: String) { + self.currentLangTitle = currentLangTitle + self.enTitle = enTitle + self.jaTitle = jaTitle + } +} diff --git a/app-ios/Sources/Model/Entity/RoomTheme.swift b/app-ios/Sources/Model/Entity/RoomTheme.swift new file mode 100644 index 000000000..5923eb88a --- /dev/null +++ b/app-ios/Sources/Model/Entity/RoomTheme.swift @@ -0,0 +1,7 @@ +import SwiftUI + +public struct RoomTheme { + public let primaryColor: Color + public let containerColor: Color + public let dimColor: Color +} diff --git a/app-ios/Sources/Model/Entity/RoomType.swift b/app-ios/Sources/Model/Entity/RoomType.swift new file mode 100644 index 000000000..aa493db36 --- /dev/null +++ b/app-ios/Sources/Model/Entity/RoomType.swift @@ -0,0 +1,29 @@ +import Foundation + +public enum RoomType { + case roomF + case roomG + case roomH + case roomI + case roomJ + case roomIj + + enum ThemeKey: String { + case iguana + case hedgehog + case giraffe + case flamingo + case jellyfish + } + + public init(enTitle: String) { + self = switch enTitle.lowercased() { + case ThemeKey.flamingo.rawValue: Self.roomF + case ThemeKey.giraffe.rawValue: Self.roomG + case ThemeKey.hedgehog.rawValue: Self.roomH + case ThemeKey.iguana.rawValue: Self.roomI + case ThemeKey.jellyfish.rawValue: Self.roomJ + default: Self.roomIj + } + } +} diff --git a/app-ios/Sources/Model/Extension/RoomType+Extension.swift b/app-ios/Sources/Model/Extension/RoomType+Extension.swift index 38b3b69ac..dbe32f3c7 100644 --- a/app-ios/Sources/Model/Extension/RoomType+Extension.swift +++ b/app-ios/Sources/Model/Extension/RoomType+Extension.swift @@ -1,6 +1,5 @@ import Foundation import Theme -import shared extension RoomType { public var theme: RoomTheme { diff --git a/app-ios/Sources/Model/Extension/TimetableRoom+Extension.swift b/app-ios/Sources/Model/Extension/TimetableRoom+Extension.swift index d2b32717f..3eb0c705d 100644 --- a/app-ios/Sources/Model/Extension/TimetableRoom+Extension.swift +++ b/app-ios/Sources/Model/Extension/TimetableRoom+Extension.swift @@ -1,13 +1,16 @@ import shared -import SwiftUI import Theme -public struct RoomTheme { - public let primaryColor: Color - public let containerColor: Color - public let dimColor: Color -} - extension TimetableRoom { - public var roomTheme: RoomTheme { type.theme } + public var roomTheme: RoomTheme { + let roomType: Model.RoomType = switch type { + case .roomF: .roomF + case .roomG: .roomG + case .roomH: .roomH + case .roomI: .roomI + case .roomJ: .roomJ + case .roomIj: .roomIj + } + return roomType.theme + } } diff --git a/app-ios/Sources/SearchFeature/Resources/Localizable.xcstrings b/app-ios/Sources/SearchFeature/Resources/Localizable.xcstrings index e0856dca9..67b82baf0 100644 --- a/app-ios/Sources/SearchFeature/Resources/Localizable.xcstrings +++ b/app-ios/Sources/SearchFeature/Resources/Localizable.xcstrings @@ -2,7 +2,20 @@ "sourceLanguage" : "en", "strings" : { "「%@」と一致する検索結果がありません" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing matched your search criteria \"%@\"" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "「%@」と一致する検索結果がありません" + } + } + } }, "9/11" : { "localizations" : { @@ -11,6 +24,12 @@ "state" : "translated", "value" : "9/11" } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "9/11" + } } } }, @@ -21,6 +40,12 @@ "state" : "translated", "value" : "9/12" } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "9/12" + } } } }, @@ -31,6 +56,12 @@ "state" : "translated", "value" : "9/13" } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "9/13" + } } } }, @@ -41,6 +72,12 @@ "state" : "translated", "value" : "Category" } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "カテゴリ" + } } } }, @@ -51,6 +88,12 @@ "state" : "translated", "value" : "Session type" } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "セッション種別" + } } } }, @@ -61,6 +104,12 @@ "state" : "translated", "value" : "Supported languages" } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "対応言語" + } } } }, @@ -71,6 +120,12 @@ "state" : "translated", "value" : "Day" } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "開催日" + } } } } diff --git a/app-ios/Sources/SearchFeature/SearchView.swift b/app-ios/Sources/SearchFeature/SearchView.swift index e0c09d253..e3a24b34c 100644 --- a/app-ios/Sources/SearchFeature/SearchView.swift +++ b/app-ios/Sources/SearchFeature/SearchView.swift @@ -91,7 +91,8 @@ public struct SearchView: View { store.send(.view(.selectedDayChanged($0))) } ) - searchFilterChip( + searchCategoryFilterChip( + allCategories: store.timetable?.categories ?? [], selection: store.selectedCategory, defaultTitle: String(localized: "カテゴリ", bundle: .module), onSelect: { @@ -119,6 +120,37 @@ public struct SearchView: View { } } + // MEMO: All Category can get from timetable TimetableCategories get timetable model. + // (TimetableCategory don't have to conform to Selectable protocol.) + private func searchCategoryFilterChip( + allCategories: [TimetableCategory], + selection: TimetableCategory?, + defaultTitle: String, + onSelect: @escaping (TimetableCategory) -> Void + ) -> some View { + Menu { + ForEach(allCategories, id: \.id) { category in + Button { + onSelect(category) + } label: { + HStack { + if category == selection { + Image(.icCheck) + } + Text(category.title.currentLangTitle) + } + } + } + + } label: { + SelectionChip( + title: selection?.title.currentLangTitle ?? defaultTitle, + isMultiSelect: true, + isSelected: selection != nil + ) {} + } + } + private func searchFilterChip( selection: T?, defaultTitle: String, @@ -177,23 +209,6 @@ extension DroidKaigi2024Day { } } -#if hasFeature(RetroactiveAttribute) -extension TimetableCategory: @retroactive Selectable {} -#else -extension TimetableCategory: Selectable {} -#endif - -extension TimetableCategory { - public var caseTitle: String { - title.currentLangTitle - } - - static public var allCases: [TimetableCategory] { - // TODO: use correct - [] - } -} - #if hasFeature(RetroactiveAttribute) extension TimetableSessionType: @retroactive Selectable {} #else diff --git a/app-ios/Sources/StaffFeature/StaffLabel.swift b/app-ios/Sources/StaffFeature/StaffLabel.swift index 88d9a0814..564bd7f14 100644 --- a/app-ios/Sources/StaffFeature/StaffLabel.swift +++ b/app-ios/Sources/StaffFeature/StaffLabel.swift @@ -1,5 +1,6 @@ import SwiftUI import Theme +import CommonComponents struct StaffLabel: View { let name: String @@ -7,15 +8,12 @@ struct StaffLabel: View { var body: some View { HStack(alignment: .center, spacing: 12) { - AsyncImage(url: icon) { - $0.image?.resizable() - } - .frame(width: 52, height: 52) - .clipShape(Circle()) - .overlay( - Circle() - .stroke(AssetColors.Outline.outline.swiftUIColor, lineWidth: 1) - ) + CircularUserIcon(urlString: icon.absoluteString) + .frame(width: 52, height: 52) + .overlay( + Circle() + .stroke(AssetColors.Outline.outline.swiftUIColor, lineWidth: 1) + ) Text(name) .textStyle(.bodyLarge) @@ -27,5 +25,5 @@ struct StaffLabel: View { } #Preview { - StaffLabel(name: "hoge", icon: .init(string: "")!) + StaffLabel(name: "hoge", icon: .init(string: "https://avatars.githubusercontent.com/u/10727543?s=156&v=4")!) } diff --git a/app-ios/Sources/TimetableDetailFeature/TimetableDetailView.swift b/app-ios/Sources/TimetableDetailFeature/TimetableDetailView.swift index c3704045b..3007d20df 100644 --- a/app-ios/Sources/TimetableDetailFeature/TimetableDetailView.swift +++ b/app-ios/Sources/TimetableDetailFeature/TimetableDetailView.swift @@ -94,7 +94,12 @@ public struct TimetableDetailView: View { @MainActor var headLine: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 4) { - RoomTag(store.timetableItem.room.name) + RoomTag(.init( + currentLangTitle: store.timetableItem.room.name.currentLangTitle, + enTitle: store.timetableItem.room.name.enTitle, + jaTitle: store.timetableItem.room.name.jaTitle + )) + ForEach(store.timetableItem.language.labels, id: \.self) { label in LanguageTag(label) } @@ -109,13 +114,8 @@ public struct TimetableDetailView: View { ForEach(store.timetableItem.speakers, id: \.id) { speaker in HStack(spacing: 12) { - if let url = URL(string: speaker.iconUrl) { - AsyncImage(url: url) { - $0.image?.resizable() - } + CircularUserIcon(urlString: speaker.iconUrl) .frame(width: 52, height: 52) - .clipShape(Circle()) - } VStack(alignment: .leading, spacing: 8) { Text(speaker.name) diff --git a/app-ios/Sources/TimetableFeature/Resource/Localizable.xcstrings b/app-ios/Sources/TimetableFeature/Resource/Localizable.xcstrings index 43fd5afb2..8af4e207e 100644 --- a/app-ios/Sources/TimetableFeature/Resource/Localizable.xcstrings +++ b/app-ios/Sources/TimetableFeature/Resource/Localizable.xcstrings @@ -13,16 +13,6 @@ }, "|" : { - }, - "AddFavorite" : { - "localizations" : { - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ブックマークに追加されました" - } - } - } }, "Timetable" : { "localizations" : { diff --git a/app-ios/Sources/TimetableFeature/TimetableGridCard.swift b/app-ios/Sources/TimetableFeature/TimetableGridCard.swift index 41b10afff..61eff283a 100644 --- a/app-ios/Sources/TimetableFeature/TimetableGridCard.swift +++ b/app-ios/Sources/TimetableFeature/TimetableGridCard.swift @@ -2,6 +2,7 @@ import Foundation import SwiftUI import Theme import class shared.TimetableItem +import CommonComponents public struct TimetableGridCard: View { let timetableItem: TimetableItem @@ -21,7 +22,7 @@ public struct TimetableGridCard: View { } label: { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 4) { - timetableItem.room.type.shape + RoomTypeShape(roomType: .init(enTitle: timetableItem.room.name.enTitle)) .foregroundStyle(timetableItem.room.roomTheme.primaryColor) Text("\(timetableItem.startsTimeString) - \(timetableItem.endsTimeString)") .textStyle(.labelMedium) diff --git a/app-ios/Sources/TimetableFeature/TimetableListView.swift b/app-ios/Sources/TimetableFeature/TimetableListView.swift index 926e4346e..a479bd387 100644 --- a/app-ios/Sources/TimetableFeature/TimetableListView.swift +++ b/app-ios/Sources/TimetableFeature/TimetableListView.swift @@ -40,7 +40,6 @@ public struct TimetableView: View { } Spacer() } - .toast($store.toast) .background(AssetColors.Surface.surface.swiftUIColor) .frame(maxWidth: .infinity) .toolbar{ diff --git a/app-ios/Sources/TimetableFeature/TimetableReducer.swift b/app-ios/Sources/TimetableFeature/TimetableReducer.swift index fbc4a0c14..37dcf5aae 100644 --- a/app-ios/Sources/TimetableFeature/TimetableReducer.swift +++ b/app-ios/Sources/TimetableFeature/TimetableReducer.swift @@ -14,7 +14,6 @@ public struct TimetableReducer : Sendable{ @ObservableState public struct State: Equatable { var timetableItems: [TimetableTimeGroupItems] = [] //Should be simple objects - var toast: ToastState? public init(timetableItems: [TimetableTimeGroupItems] = []) { self.timetableItems = timetableItems @@ -26,7 +25,7 @@ public struct TimetableReducer : Sendable{ case view(View) case requestDay(DayTab) case response(Result<[TimetableItemWithFavorite], any Error>) - case favoriteResponse(Result) + case favoriteResponse(Result) public enum View : Sendable { case onAppear @@ -101,13 +100,9 @@ public struct TimetableReducer : Sendable{ return .run { send in await send(.favoriteResponse(Result { try await timetableClient.toggleBookmark(id: item.timetableItem.id) - return item.isFavorited })) } - case let .favoriteResponse(.success(isFavorited)): - if !isFavorited { - state.toast = .init(text: String(localized: "AddFavorite", bundle: .module)) - } + case .favoriteResponse(.success): return .none case let .favoriteResponse(.failure(error)): print(error.localizedDescription) diff --git a/core/data/src/iosMain/kotlin/io/github/droidkaigi/confsched/data/DataModule.kt b/core/data/src/iosMain/kotlin/io/github/droidkaigi/confsched/data/DataModule.kt index 40ecafd09..10a666474 100644 --- a/core/data/src/iosMain/kotlin/io/github/droidkaigi/confsched/data/DataModule.kt +++ b/core/data/src/iosMain/kotlin/io/github/droidkaigi/confsched/data/DataModule.kt @@ -8,9 +8,9 @@ import io.github.droidkaigi.confsched.data.contributors.DefaultContributorsApiCl import io.github.droidkaigi.confsched.data.contributors.DefaultContributorsRepository import io.github.droidkaigi.confsched.data.core.defaultJson import io.github.droidkaigi.confsched.data.core.defaultKtorConfig +import io.github.droidkaigi.confsched.data.eventmap.DefaultEventMapApiClient import io.github.droidkaigi.confsched.data.eventmap.DefaultEventMapRepository import io.github.droidkaigi.confsched.data.eventmap.EventMapApiClient -import io.github.droidkaigi.confsched.data.eventmap.FakeEventMapApiClient import io.github.droidkaigi.confsched.data.sessions.DefaultSessionsApiClient import io.github.droidkaigi.confsched.data.sessions.DefaultSessionsRepository import io.github.droidkaigi.confsched.data.sessions.SessionCacheDataStore @@ -121,7 +121,7 @@ public val dataModule: Module = module { singleOf(::DefaultContributorsApiClient) bind ContributorsApiClient::class singleOf(::DefaultSponsorsApiClient) bind SponsorsApiClient::class singleOf(::DefaultStaffApiClient) bind StaffApiClient::class - singleOf(::FakeEventMapApiClient) bind EventMapApiClient::class + singleOf(::DefaultEventMapApiClient) bind EventMapApiClient::class singleOf(::NetworkService) singleOf(::DefaultSessionsRepository) bind SessionsRepository::class diff --git a/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/EventMapRepository.kt b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/EventMapRepository.kt index a62b06b22..5ca1b38b9 100644 --- a/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/EventMapRepository.kt +++ b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/EventMapRepository.kt @@ -4,9 +4,11 @@ import androidx.compose.runtime.Composable import io.github.droidkaigi.confsched.model.compositionlocal.LocalRepositories import kotlinx.collections.immutable.PersistentList import kotlinx.coroutines.flow.Flow +import kotlin.coroutines.cancellation.CancellationException interface EventMapRepository { + @Throws(CancellationException::class) suspend fun refresh() @Composable diff --git a/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/Filters.kt b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/Filters.kt index 31bc030f2..a0179606f 100644 --- a/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/Filters.kt +++ b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/Filters.kt @@ -7,4 +7,19 @@ public data class Filters( val languages: List = emptyList(), val filterFavorite: Boolean = false, val searchWord: String = "", -) +) { + + /** + * Checks if all filtering criteria are empty. + * + * @return True if all criteria are empty; false otherwise. + */ + fun isEmpty() = days.isEmpty() && + categories.isEmpty() && + sessionTypes.isEmpty() && + languages.isEmpty() && + filterFavorite.not() && + searchWord.isEmpty() + + fun isNotEmpty() = isEmpty().not() +} diff --git a/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/TimeLine.kt b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/TimeLine.kt new file mode 100644 index 000000000..620621c81 --- /dev/null +++ b/core/model/src/commonMain/kotlin/io/github/droidkaigi/confsched/model/TimeLine.kt @@ -0,0 +1,34 @@ +package io.github.droidkaigi.confsched.model + +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +data class TimeLine( + private val currentTime: Instant, + private val currentDay: DroidKaigi2024Day, +) { + fun durationFromScheduleStart(targetDay: DroidKaigi2024Day): Duration? { + if (currentDay != targetDay) return null + val currentTimeSecondOfDay = currentTime.toLocalDateTime(TimeZone.currentSystemDefault()).time.toSecondOfDay() + val scheduleStartTimeSecondOfDay = LocalTime(hour = 10, minute = 0).toSecondOfDay() + return ((currentTimeSecondOfDay - scheduleStartTimeSecondOfDay) / 60).minutes + } + + companion object { + fun now(clock: Clock): TimeLine? { + val currentTime = clock.now() + val currentDay = DroidKaigi2024Day.ofOrNull(currentTime) + return currentDay?.let { + TimeLine( + currentTime = currentTime, + currentDay = currentDay, + ) + } + } + } +} diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/MiniRobots.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/MiniRobots.kt index 806ddaa7a..28b538fa0 100644 --- a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/MiniRobots.kt +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/MiniRobots.kt @@ -10,18 +10,25 @@ import io.github.droidkaigi.confsched.data.eventmap.FakeEventMapApiClient import io.github.droidkaigi.confsched.data.profilecard.ProfileCardDataStore import io.github.droidkaigi.confsched.data.sessions.FakeSessionsApiClient import io.github.droidkaigi.confsched.data.sessions.SessionsApiClient +import io.github.droidkaigi.confsched.data.settings.SettingsDataStore import io.github.droidkaigi.confsched.data.sponsors.FakeSponsorsApiClient import io.github.droidkaigi.confsched.data.sponsors.SponsorsApiClient import io.github.droidkaigi.confsched.data.staff.FakeStaffApiClient import io.github.droidkaigi.confsched.data.staff.StaffApiClient +import io.github.droidkaigi.confsched.model.FontFamily import io.github.droidkaigi.confsched.model.ProfileCard +import io.github.droidkaigi.confsched.model.Settings import io.github.droidkaigi.confsched.model.fake import io.github.droidkaigi.confsched.testing.coroutines.runTestWithLogging import io.github.droidkaigi.confsched.testing.robot.ProfileCardDataStoreRobot.ProfileCardInputStatus import io.github.droidkaigi.confsched.testing.robot.ProfileCardDataStoreRobot.ProfileCardInputStatus.AllNotEntered import io.github.droidkaigi.confsched.testing.robot.ProfileCardDataStoreRobot.ProfileCardInputStatus.NoInputOtherThanImage +import io.github.droidkaigi.confsched.testing.robot.SettingsDataStoreRobot.SettingsStatus +import io.github.droidkaigi.confsched.testing.robot.SettingsDataStoreRobot.SettingsStatus.UseDotGothic16FontFamily +import io.github.droidkaigi.confsched.testing.robot.SettingsDataStoreRobot.SettingsStatus.UseSystemDefaultFont import io.github.droidkaigi.confsched.testing.robot.SponsorsServerRobot.ServerStatus import io.github.droidkaigi.confsched.testing.rules.RobotTestRule +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.test.TestDispatcher import org.robolectric.RuntimeEnvironment import org.robolectric.shadows.ShadowLooper @@ -287,3 +294,38 @@ class DefaultProfileCardDataStoreRobot @Inject constructor( } } } + +interface SettingsDataStoreRobot { + enum class SettingsStatus { + UseDotGothic16FontFamily, + UseSystemDefaultFont, + } + + suspend fun setupSettings(settingsStatus: SettingsStatus) + fun get(): Flow +} + +class DefaultSettingsDataStoreRobot @Inject constructor( + private val settingsDataStore: SettingsDataStore, +) : SettingsDataStoreRobot { + override suspend fun setupSettings(settingsStatus: SettingsStatus) { + when (settingsStatus) { + UseDotGothic16FontFamily -> { + settingsDataStore.save( + Settings.Exists( + useFontFamily = FontFamily.DotGothic16Regular, + ), + ) + } + UseSystemDefaultFont -> { + settingsDataStore.save( + Settings.Exists( + useFontFamily = FontFamily.SystemDefault, + ), + ) + } + } + } + + override fun get(): Flow = settingsDataStore.get() +} diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/ProfileCardScreenRobot.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/ProfileCardScreenRobot.kt index c30c08031..1d2918a78 100644 --- a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/ProfileCardScreenRobot.kt +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/ProfileCardScreenRobot.kt @@ -30,7 +30,9 @@ class ProfileCardScreenRobot @Inject constructor( fun setupScreenContent() { robotTestRule.setContent { KaigiTheme { - ProfileCardScreen() + ProfileCardScreen( + onClickShareProfileCard = { _, _ -> }, + ) } } waitUntilIdle() diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/SearchScreenRobot.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/SearchScreenRobot.kt index 28454877b..ed2b2aa18 100644 --- a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/SearchScreenRobot.kt +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/SearchScreenRobot.kt @@ -2,6 +2,7 @@ package io.github.droidkaigi.confsched.testing.robot import androidx.compose.ui.test.assertAll import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.filter @@ -267,6 +268,12 @@ class SearchScreenRobot @Inject constructor( .assertAll(hasText(text = searchWord, ignoreCase = true)) } + fun checkTimetableListExists() { + composeTestRule + .onNode(hasTestTag(TimetableListTestTag)) + .assertExists() + } + fun checkTimetableListDisplayed() { composeTestRule .onNode(hasTestTag(TimetableListTestTag)) @@ -279,4 +286,11 @@ class SearchScreenRobot @Inject constructor( .onFirst() .assertIsDisplayed() } + + fun checkTimetableListItemsNotDisplayed() { + composeTestRule + .onAllNodesWithTag(TimetableItemCardTestTag) + .onFirst() + .assertIsNotDisplayed() + } } diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/SettingsScreenRobot.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/SettingsScreenRobot.kt index 184ad4b12..ab5e7b3fb 100644 --- a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/SettingsScreenRobot.kt +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/SettingsScreenRobot.kt @@ -1,15 +1,54 @@ package io.github.droidkaigi.confsched.testing.robot +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.performClick +import io.github.droidkaigi.confsched.compose.safeCollectAsRetainedState import io.github.droidkaigi.confsched.designsystem.theme.KaigiTheme +import io.github.droidkaigi.confsched.designsystem.theme.dotGothic16FontFamily +import io.github.droidkaigi.confsched.model.FontFamily.DotGothic16Regular +import io.github.droidkaigi.confsched.model.FontFamily.SystemDefault +import io.github.droidkaigi.confsched.model.Settings.DoesNotExists +import io.github.droidkaigi.confsched.model.Settings.Exists +import io.github.droidkaigi.confsched.model.Settings.Loading import io.github.droidkaigi.confsched.settings.SettingsScreen +import io.github.droidkaigi.confsched.settings.component.SettingsItemRowCurrentValueTextTestTag +import io.github.droidkaigi.confsched.settings.section.SettingsAccessibilityUseFontFamilyTestTag +import io.github.droidkaigi.confsched.settings.section.SettingsAccessibilityUseFontFamilyTestTagPrefix import javax.inject.Inject class SettingsScreenRobot @Inject constructor( screenRobot: DefaultScreenRobot, -) : ScreenRobot by screenRobot { + settingsDataStoreRobot: DefaultSettingsDataStoreRobot, +) : ScreenRobot by screenRobot, + SettingsDataStoreRobot by settingsDataStoreRobot { + private enum class FontFamily( + val displayName: String, + ) { + DotGothic16Regular("DotGothic"), + SystemDefault("System Default"), + } + fun setupScreenContent() { robotTestRule.setContent { - KaigiTheme { + val settings by remember { get() }.safeCollectAsRetainedState(Loading) + + val fontFamily = when (settings) { + DoesNotExists, Loading -> dotGothic16FontFamily() + is Exists -> { + when ((settings as Exists).useFontFamily) { + DotGothic16Regular -> dotGothic16FontFamily() + SystemDefault -> null + } + } + } + + KaigiTheme( + fontFamily = fontFamily, + ) { SettingsScreen( onNavigationIconClick = {}, ) @@ -17,4 +56,53 @@ class SettingsScreenRobot @Inject constructor( } waitUntilIdle() } + + fun clickUseFontItem() { + composeTestRule + .onNode(hasTestTag(SettingsAccessibilityUseFontFamilyTestTag)) + .performClick() + waitUntilIdle() + } + + fun clickSystemDefaultFontItem() { + composeTestRule + .onNode(hasTestTag(SettingsAccessibilityUseFontFamilyTestTagPrefix.plus(FontFamily.SystemDefault.displayName))) + .performClick() + waitUntilIdle() + } + + fun clickDotGothicFontItem() { + composeTestRule + .onNode(hasTestTag(SettingsAccessibilityUseFontFamilyTestTagPrefix.plus(FontFamily.DotGothic16Regular.displayName))) + .performClick() + waitUntilIdle() + } + + fun checkAllDisplayedAvailableFont() { + FontFamily.entries.forEach { fontFamily -> + composeTestRule + .onNode(hasTestTag(SettingsAccessibilityUseFontFamilyTestTagPrefix.plus(fontFamily.displayName))) + .assertIsDisplayed() + .assertExists() + .assertTextEquals(fontFamily.displayName) + } + } + + fun checkEnsureThatSystemDefaultFontIsUsed() { + composeTestRule + .onNode( + matcher = hasTestTag(SettingsItemRowCurrentValueTextTestTag), + useUnmergedTree = true, + ) + .assertTextEquals(FontFamily.SystemDefault.displayName) + } + + fun checkEnsureThatDotGothicFontIsUsed() { + composeTestRule + .onNode( + matcher = hasTestTag(SettingsItemRowCurrentValueTextTestTag), + useUnmergedTree = true, + ) + .assertTextEquals(FontFamily.DotGothic16Regular.displayName) + } } diff --git a/core/ui/src/androidMain/kotlin/io/github/droidkaigi/confsched/ui/CanShowLargeVector.android.kt b/core/ui/src/androidMain/kotlin/io/github/droidkaigi/confsched/ui/CanShowLargeVector.android.kt new file mode 100644 index 000000000..555488b3b --- /dev/null +++ b/core/ui/src/androidMain/kotlin/io/github/droidkaigi/confsched/ui/CanShowLargeVector.android.kt @@ -0,0 +1,10 @@ +package io.github.droidkaigi.confsched.ui + +import android.os.Build +import android.os.Build.VERSION_CODES +import androidx.annotation.ChecksSdkIntAtLeast + +@ChecksSdkIntAtLeast(api = VERSION_CODES.O_MR1) +actual fun canShowLargeVector(): Boolean { + return Build.VERSION.SDK_INT > 26 +} diff --git a/core/ui/src/androidMain/kotlin/io/github/droidkaigi/confsched/ui/ProvideAboutHeaderPainter.kt b/core/ui/src/androidMain/kotlin/io/github/droidkaigi/confsched/ui/ProvideAboutHeaderPainter.kt index fdbf5fab2..1912eb057 100644 --- a/core/ui/src/androidMain/kotlin/io/github/droidkaigi/confsched/ui/ProvideAboutHeaderPainter.kt +++ b/core/ui/src/androidMain/kotlin/io/github/droidkaigi/confsched/ui/ProvideAboutHeaderPainter.kt @@ -11,21 +11,27 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.painter.Painter +import conference_app_2024.core.ui.generated.resources.about_header_title import io.github.droidkaigi.confsched.core.ui.R +import org.jetbrains.compose.resources.painterResource @OptIn(ExperimentalAnimationGraphicsApi::class) @Composable -actual fun provideAboutHeaderTitlePainter(): Painter { - var animationPlayed by remember { mutableStateOf(false) } +actual fun provideAboutHeaderTitlePainter(enableAnimation: Boolean): Painter { + return if (enableAnimation) { + var animationPlayed by remember { mutableStateOf(false) } - val painter = rememberAnimatedVectorPainter( - animatedImageVector = AnimatedImageVector.animatedVectorResource(id = R.drawable.anim_header_title), - atEnd = animationPlayed, - ) + val painter = rememberAnimatedVectorPainter( + animatedImageVector = AnimatedImageVector.animatedVectorResource(id = R.drawable.anim_header_title), + atEnd = animationPlayed, + ) - LaunchedEffect(Unit) { - animationPlayed = true - } + LaunchedEffect(Unit) { + animationPlayed = true + } - return painter + painter + } else { + painterResource(UiRes.drawable.about_header_title) + } } diff --git a/core/ui/src/commonMain/kotlin/io/github/droidkaigi/confsched/ui/CanShowLargeVector.kt b/core/ui/src/commonMain/kotlin/io/github/droidkaigi/confsched/ui/CanShowLargeVector.kt new file mode 100644 index 000000000..aad62f486 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/io/github/droidkaigi/confsched/ui/CanShowLargeVector.kt @@ -0,0 +1,3 @@ +package io.github.droidkaigi.confsched.ui + +expect fun canShowLargeVector(): Boolean diff --git a/core/ui/src/commonMain/kotlin/io/github/droidkaigi/confsched/ui/PresenterDefaultsProvider.kt b/core/ui/src/commonMain/kotlin/io/github/droidkaigi/confsched/ui/PresenterDefaultsProvider.kt index c302b5b5f..c15463f2c 100644 --- a/core/ui/src/commonMain/kotlin/io/github/droidkaigi/confsched/ui/PresenterDefaultsProvider.kt +++ b/core/ui/src/commonMain/kotlin/io/github/droidkaigi/confsched/ui/PresenterDefaultsProvider.kt @@ -1,7 +1,10 @@ package io.github.droidkaigi.confsched.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density import io.github.droidkaigi.confsched.compose.ComposeEffectErrorHandler import io.github.droidkaigi.confsched.compose.LocalComposeEffectErrorHandler import io.github.droidkaigi.confsched.compose.compositionLocalProviderWithReturnValue @@ -11,7 +14,11 @@ fun providePresenterDefaults( userMessageStateHolder: UserMessageStateHolder = rememberUserMessageStateHolder(), block: @Composable (UserMessageStateHolder) -> T, ): T { - val composeResourceErrorMessages = composeResourceErrorMessages() + var composeResourceErrorMessages: List = listOf() + // For iOS + CompositionLocalProvider(LocalDensity provides Density(1F)) { + composeResourceErrorMessages = composeResourceErrorMessages() + } val handler = remember(userMessageStateHolder) { object : ComposeEffectErrorHandler { override suspend fun emit(throwable: Throwable) { diff --git a/core/ui/src/commonMain/kotlin/io/github/droidkaigi/confsched/ui/ProvideAboutHeaderPainter.kt b/core/ui/src/commonMain/kotlin/io/github/droidkaigi/confsched/ui/ProvideAboutHeaderPainter.kt index 915da1d83..de22c54e3 100644 --- a/core/ui/src/commonMain/kotlin/io/github/droidkaigi/confsched/ui/ProvideAboutHeaderPainter.kt +++ b/core/ui/src/commonMain/kotlin/io/github/droidkaigi/confsched/ui/ProvideAboutHeaderPainter.kt @@ -4,4 +4,4 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter @Composable -expect fun provideAboutHeaderTitlePainter(): Painter +expect fun provideAboutHeaderTitlePainter(enableAnimation: Boolean = true): Painter diff --git a/core/ui/src/iosMain/kotlin/io/github/droidkaigi/confsched/ui/CanShowLargeVector.ios.kt b/core/ui/src/iosMain/kotlin/io/github/droidkaigi/confsched/ui/CanShowLargeVector.ios.kt new file mode 100644 index 000000000..e5494cff9 --- /dev/null +++ b/core/ui/src/iosMain/kotlin/io/github/droidkaigi/confsched/ui/CanShowLargeVector.ios.kt @@ -0,0 +1,6 @@ +package io.github.droidkaigi.confsched.ui + +actual fun canShowLargeVector(): Boolean { + // The process is only relevant to Android, so it always returns true. + return true +} diff --git a/core/ui/src/iosMain/kotlin/io/github/droidkaigi/confsched/ui/ProvideAboutHeaderPainter.kt b/core/ui/src/iosMain/kotlin/io/github/droidkaigi/confsched/ui/ProvideAboutHeaderPainter.kt index 5ce3ee2dc..1c8439bf1 100644 --- a/core/ui/src/iosMain/kotlin/io/github/droidkaigi/confsched/ui/ProvideAboutHeaderPainter.kt +++ b/core/ui/src/iosMain/kotlin/io/github/droidkaigi/confsched/ui/ProvideAboutHeaderPainter.kt @@ -6,6 +6,6 @@ import conference_app_2024.core.ui.generated.resources.about_header_title import org.jetbrains.compose.resources.painterResource @Composable -actual fun provideAboutHeaderTitlePainter(): Painter { +actual fun provideAboutHeaderTitlePainter(enableAnimation: Boolean): Painter { return painterResource(UiRes.drawable.about_header_title) } diff --git a/feature/about/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/about/AboutScreenTest.kt b/feature/about/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/about/AboutScreenTest.kt index 0884adcd0..132621976 100644 --- a/feature/about/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/about/AboutScreenTest.kt +++ b/feature/about/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/about/AboutScreenTest.kt @@ -40,7 +40,7 @@ class AboutScreenTest( fun behaviors(): List> { return describeBehaviors("AboutScreen") { describe("when launch") { - run { + doIt { setupScreenContent() } itShould("show detail section") { diff --git a/feature/about/src/commonMain/kotlin/io/github/droidkaigi/confsched/about/section/AboutDroidKaigiDetail.kt b/feature/about/src/commonMain/kotlin/io/github/droidkaigi/confsched/about/section/AboutDroidKaigiDetail.kt index e4922eb29..c68d5813e 100644 --- a/feature/about/src/commonMain/kotlin/io/github/droidkaigi/confsched/about/section/AboutDroidKaigiDetail.kt +++ b/feature/about/src/commonMain/kotlin/io/github/droidkaigi/confsched/about/section/AboutDroidKaigiDetail.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag @@ -26,6 +27,7 @@ import io.github.droidkaigi.confsched.about.AboutRes import io.github.droidkaigi.confsched.about.component.AboutDroidKaigiDetailSummaryCard import io.github.droidkaigi.confsched.designsystem.theme.KaigiTheme import io.github.droidkaigi.confsched.ui.UiRes +import io.github.droidkaigi.confsched.ui.canShowLargeVector import io.github.droidkaigi.confsched.ui.provideAboutHeaderTitlePainter import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -58,22 +60,30 @@ fun AboutDroidKaigiDetail( Column( modifier = modifier.testTag(AboutDetailTestTag), ) { - Box { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val imageModifier = if (canShowLargeVector()) { + Modifier + .fillMaxWidth() + .offset(y = aboutHeaderOffset.dp) + } else { + // Some API Levels are not optimized to handle VectorDrawable, so OOM occurs when large VectorDrawable is displayed. + // Therefore, depending on the API Level, whether or not to display an Image in its full width should be separated. + Modifier + } Image( painter = painterResource(UiRes.drawable.about_header_year), contentDescription = null, contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxWidth() - .offset(y = aboutHeaderOffset.dp), + modifier = imageModifier, ) Image( - painter = provideAboutHeaderTitlePainter(), + painter = provideAboutHeaderTitlePainter(canShowLargeVector()), contentDescription = null, contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxWidth() - .offset(y = aboutHeaderOffset.dp), + modifier = imageModifier, ) } Text( diff --git a/feature/contributors/src/commonMain/composeResources/values-ja/strings.xml b/feature/contributors/src/commonMain/composeResources/values-ja/strings.xml index 8f3723939..dd7db785c 100644 --- a/feature/contributors/src/commonMain/composeResources/values-ja/strings.xml +++ b/feature/contributors/src/commonMain/composeResources/values-ja/strings.xml @@ -1,4 +1,4 @@ - !!Please remove this resource when you add string resource!! + コントリビューター diff --git a/feature/contributors/src/commonMain/composeResources/values/strings.xml b/feature/contributors/src/commonMain/composeResources/values/strings.xml index 8f3723939..5ac45c4ff 100644 --- a/feature/contributors/src/commonMain/composeResources/values/strings.xml +++ b/feature/contributors/src/commonMain/composeResources/values/strings.xml @@ -1,4 +1,4 @@ - !!Please remove this resource when you add string resource!! + Contributor diff --git a/feature/contributors/src/commonMain/kotlin/io/github/droidkaigi/confsched/contributors/ContributorsScreen.kt b/feature/contributors/src/commonMain/kotlin/io/github/droidkaigi/confsched/contributors/ContributorsScreen.kt index 28a0a13af..a6b583ca2 100644 --- a/feature/contributors/src/commonMain/kotlin/io/github/droidkaigi/confsched/contributors/ContributorsScreen.kt +++ b/feature/contributors/src/commonMain/kotlin/io/github/droidkaigi/confsched/contributors/ContributorsScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.testTag import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable +import conference_app_2024.feature.contributors.generated.resources.contributor_title import io.github.droidkaigi.confsched.compose.rememberEventEmitter import io.github.droidkaigi.confsched.contributors.component.ContributorsItem import io.github.droidkaigi.confsched.model.Contributor @@ -27,6 +28,7 @@ import io.github.droidkaigi.confsched.ui.UserMessageStateHolder import io.github.droidkaigi.confsched.ui.component.AnimatedLargeTopAppBar import io.github.droidkaigi.confsched.ui.handleOnClickIfNotNavigating import kotlinx.collections.immutable.PersistentList +import org.jetbrains.compose.resources.stringResource const val contributorsScreenRoute = "contributors" const val ContributorsScreenTestTag = "ContributorsScreenTestTag" @@ -106,7 +108,7 @@ fun ContributorsScreen( topBar = { if (!isTopAppBarHidden) { AnimatedLargeTopAppBar( - title = "Contributor", + title = stringResource(ContributorsRes.string.contributor_title), onBackClick = onBackClick, scrollBehavior = scrollBehavior, navIconContentDescription = "Back", diff --git a/feature/eventmap/src/commonMain/kotlin/io/github/droidkaigi/confsched/eventmap/EventMapScreen.kt b/feature/eventmap/src/commonMain/kotlin/io/github/droidkaigi/confsched/eventmap/EventMapScreen.kt index 3531ee6a8..02c96f324 100644 --- a/feature/eventmap/src/commonMain/kotlin/io/github/droidkaigi/confsched/eventmap/EventMapScreen.kt +++ b/feature/eventmap/src/commonMain/kotlin/io/github/droidkaigi/confsched/eventmap/EventMapScreen.kt @@ -2,6 +2,7 @@ package io.github.droidkaigi.confsched.eventmap import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -19,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.navigation.NavController @@ -47,10 +49,12 @@ const val EventMapLazyColumnTestTag = "EventMapLazyColumnTestTag" const val EventMapItemTestTag = "EventMapItemTestTag:" fun NavGraphBuilder.eventMapScreens( + contentPadding: PaddingValues, onEventMapItemClick: (url: String) -> Unit, ) { composable(eventMapScreenRoute) { EventMapScreen( + contentPadding = contentPadding, onEventMapItemClick = onEventMapItemClick, ) } @@ -75,6 +79,7 @@ data class EventMapUiState( fun EventMapScreen( onEventMapItemClick: (url: String) -> Unit, modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), ) { val eventEmitter = rememberEventEmitter() val uiState = eventMapScreenPresenter( @@ -88,6 +93,7 @@ fun EventMapScreen( userMessageStateHolder = uiState.userMessageStateHolder, ) EventMapScreen( + contentPadding = contentPadding, uiState = uiState, snackbarHostState = snackbarHostState, onEventMapItemClick = onEventMapItemClick, @@ -102,9 +108,11 @@ fun EventMapScreen( snackbarHostState: SnackbarHostState, onEventMapItemClick: (url: String) -> Unit, modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), ) { Logger.d { "EventMapScreen: $uiState" } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val layoutDirection = LocalLayoutDirection.current Scaffold( modifier = modifier.testTag(EventMapScreenTestTag), @@ -115,6 +123,12 @@ fun EventMapScreen( scrollBehavior = scrollBehavior, ) }, + contentWindowInsets = WindowInsets( + left = contentPadding.calculateLeftPadding(layoutDirection), + top = contentPadding.calculateTopPadding(), + right = contentPadding.calculateRightPadding(layoutDirection), + bottom = contentPadding.calculateBottomPadding(), + ), ) { padding -> EventMap( eventMapEvents = uiState.eventMap, diff --git a/feature/favorites/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/favorites/FavoritesScreenTest.kt b/feature/favorites/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/favorites/FavoritesScreenTest.kt index 007d841dd..e7cd1f6a0 100644 --- a/feature/favorites/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/favorites/FavoritesScreenTest.kt +++ b/feature/favorites/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/favorites/FavoritesScreenTest.kt @@ -39,7 +39,7 @@ class FavoritesScreenTest( fun behaviors(): List> { return describeBehaviors(name = "FavoritesScreen") { describe("when server is operational") { - run { + doIt { setupTimetableServer(ServerStatus.Operational) setupFavoriteSession() setupFavoritesScreenContent() @@ -50,7 +50,7 @@ class FavoritesScreenTest( ) } describe("click first session bookmark") { - run { + doIt { clickFirstSessionBookmark() } itShould("display empty view") { @@ -62,11 +62,11 @@ class FavoritesScreenTest( } describe("when server is down") { - run { + doIt { setupTimetableServer(ServerStatus.Error) } describe("when launch") { - run { + doIt { setupFavoritesScreenContent() } itShould("show error message") { diff --git a/feature/favorites/src/commonMain/kotlin/io/github/droidkaigi/confsched/favorites/FavoritesScreen.kt b/feature/favorites/src/commonMain/kotlin/io/github/droidkaigi/confsched/favorites/FavoritesScreen.kt index ae498406d..17b433a0e 100644 --- a/feature/favorites/src/commonMain/kotlin/io/github/droidkaigi/confsched/favorites/FavoritesScreen.kt +++ b/feature/favorites/src/commonMain/kotlin/io/github/droidkaigi/confsched/favorites/FavoritesScreen.kt @@ -1,16 +1,23 @@ package io.github.droidkaigi.confsched.favorites +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag import androidx.navigation.NavController @@ -115,6 +122,17 @@ fun FavoritesScreen( ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val fraction = if (scrollBehavior.state.overlappedFraction > 0.01f) 1f else 0f + + val filterBackgroundColor by animateColorAsState( + targetValue = lerp( + MaterialTheme.colorScheme.surface, + MaterialTheme.colorScheme.surfaceContainer, + FastOutLinearInEasing.transform(fraction), + ), + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + ) + Scaffold( modifier = modifier .testTag(FavoritesScreenTestTag), @@ -123,11 +141,15 @@ fun FavoritesScreen( AnimatedTextTopAppBar( title = stringResource(FavoritesRes.string.favorite), scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.topAppBarColors().copy( + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + ), ) }, ) { padding -> FavoriteSheet( uiState = uiState.favoritesSheetUiState, + filterBackgroundColor = filterBackgroundColor, onTimetableItemClick = onTimetableItemClick, onAllFilterChipClick = onAllFilterChipClick, onDay1FilterChipClick = onDay1FilterChipClick, diff --git a/feature/favorites/src/commonMain/kotlin/io/github/droidkaigi/confsched/favorites/FavoritesScreenPresenter.kt b/feature/favorites/src/commonMain/kotlin/io/github/droidkaigi/confsched/favorites/FavoritesScreenPresenter.kt index b1e024a84..f0fe0afab 100644 --- a/feature/favorites/src/commonMain/kotlin/io/github/droidkaigi/confsched/favorites/FavoritesScreenPresenter.kt +++ b/feature/favorites/src/commonMain/kotlin/io/github/droidkaigi/confsched/favorites/FavoritesScreenPresenter.kt @@ -99,7 +99,12 @@ private fun favoritesSheet( ): FavoritesSheetUiState { val filteredSessions by rememberUpdatedState( favoriteSessions - .filtered(Filters(days = selectedDayFilters.toList())), + .filtered( + Filters( + filterFavorite = true, + days = selectedDayFilters.toList(), + ), + ), ) return if (filteredSessions.isEmpty()) { diff --git a/feature/favorites/src/commonMain/kotlin/io/github/droidkaigi/confsched/favorites/component/FavoriteFilters.kt b/feature/favorites/src/commonMain/kotlin/io/github/droidkaigi/confsched/favorites/component/FavoriteFilters.kt index d71e30318..8fa051c94 100644 --- a/feature/favorites/src/commonMain/kotlin/io/github/droidkaigi/confsched/favorites/component/FavoriteFilters.kt +++ b/feature/favorites/src/commonMain/kotlin/io/github/droidkaigi/confsched/favorites/component/FavoriteFilters.kt @@ -1,16 +1,20 @@ package io.github.droidkaigi.confsched.favorites.component +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import conference_app_2024.feature.favorites.generated.resources.filter_all import conference_app_2024.feature.favorites.generated.resources.filter_day1 @@ -25,13 +29,17 @@ fun FavoriteFilters( allFilterSelected: Boolean, day1FilterSelected: Boolean, day2FilterSelected: Boolean, + backgroundColor: Color, onAllFilterChipClick: () -> Unit, onDay1FilterChipClick: () -> Unit, onDay2FilterChipClick: () -> Unit, modifier: Modifier = Modifier, ) { Row( - modifier = modifier.padding(start = 16.dp), + modifier = modifier + .fillMaxWidth() + .background(backgroundColor) + .padding(start = 16.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), ) { FavoriteFilterChip( @@ -82,6 +90,7 @@ fun FavoriteFiltersPreview() { allFilterSelected = false, day1FilterSelected = true, day2FilterSelected = true, + backgroundColor = MaterialTheme.colorScheme.surface, onAllFilterChipClick = {}, onDay1FilterChipClick = {}, onDay2FilterChipClick = {}, diff --git a/feature/favorites/src/commonMain/kotlin/io/github/droidkaigi/confsched/favorites/section/FavoriteSheet.kt b/feature/favorites/src/commonMain/kotlin/io/github/droidkaigi/confsched/favorites/section/FavoriteSheet.kt index 3fabc9006..087485c0e 100644 --- a/feature/favorites/src/commonMain/kotlin/io/github/droidkaigi/confsched/favorites/section/FavoriteSheet.kt +++ b/feature/favorites/src/commonMain/kotlin/io/github/droidkaigi/confsched/favorites/section/FavoriteSheet.kt @@ -66,6 +66,7 @@ sealed interface FavoritesSheetUiState { @Composable fun FavoriteSheet( uiState: FavoritesSheetUiState, + filterBackgroundColor: Color, onTimetableItemClick: (TimetableItem) -> Unit, onAllFilterChipClick: () -> Unit, onDay1FilterChipClick: () -> Unit, @@ -79,6 +80,7 @@ fun FavoriteSheet( allFilterSelected = uiState.isAllFilterSelected, day1FilterSelected = uiState.isDay1FilterSelected, day2FilterSelected = uiState.isDay2FilterSelected, + backgroundColor = filterBackgroundColor, onAllFilterChipClick = onAllFilterChipClick, onDay1FilterChipClick = onDay1FilterChipClick, onDay2FilterChipClick = onDay2FilterChipClick, @@ -152,6 +154,7 @@ fun FavoriteSheetPreview() { currentDayFilter = persistentListOf(ConferenceDay1, ConferenceDay2), timeTable = Timetable.fake(), ), + filterBackgroundColor = MaterialTheme.colorScheme.surface, onAllFilterChipClick = {}, onDay1FilterChipClick = {}, onDay2FilterChipClick = {}, @@ -172,6 +175,7 @@ fun FavoriteSheetNoFavoritesPreview() { allFilterSelected = false, currentDayFilter = persistentListOf(ConferenceDay1, ConferenceDay2), ), + filterBackgroundColor = MaterialTheme.colorScheme.surface, onAllFilterChipClick = {}, onDay1FilterChipClick = {}, onDay2FilterChipClick = {}, diff --git a/feature/profilecard/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenTest.kt b/feature/profilecard/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenTest.kt index 96f07b509..bfbebf0f8 100644 --- a/feature/profilecard/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenTest.kt +++ b/feature/profilecard/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenTest.kt @@ -40,7 +40,7 @@ class ProfileCardScreenTest( fun behaviors(): List> { return describeBehaviors("ProfileCardScreen") { describe("when profile card is does not exists") { - run { + doIt { setupSavedProfileCard(ProfileCardInputStatus.AllNotEntered) setupScreenContent() } @@ -53,7 +53,7 @@ class ProfileCardScreenTest( // FIXME Currently, the test code does not allow the user to select and input an image from the Add Image button. } describe("when profile card is exists") { - run { + doIt { setupSavedProfileCard(ProfileCardInputStatus.NoInputOtherThanImage) setupScreenContent() } @@ -64,7 +64,7 @@ class ProfileCardScreenTest( } } describe("flip prifle card") { - run { + doIt { flipProfileCard() } itShould("back side of the profile card is displayed") { @@ -74,7 +74,7 @@ class ProfileCardScreenTest( } } describe("when click edit button") { - run { + doIt { clickEditButton() } itShould("show edit screen") { @@ -83,7 +83,7 @@ class ProfileCardScreenTest( } } describe("when if a required field has not been filled in") { - run { + doIt { scrollToTestTag(ProfileCardCreateButtonTestTag) } itShould("make sure the Create button is deactivated") { @@ -96,7 +96,7 @@ class ProfileCardScreenTest( val nickname = "test" val occupation = "test" val link = "test" - run { + doIt { inputNickName(nickname) inputOccupation(occupation) inputLink(link) diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreen.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreen.kt index fa024a83b..4d9097940 100644 --- a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreen.kt +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -62,6 +63,8 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.graphics.vector.VectorPainter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -97,6 +100,8 @@ import io.github.droidkaigi.confsched.profilecard.component.PhotoPickerButton import io.github.droidkaigi.confsched.ui.SnackbarMessageEffect import io.github.droidkaigi.confsched.ui.UserMessageStateHolder import io.github.droidkaigi.confsched.ui.component.AnimatedTextTopAppBar +import io.ktor.util.decodeBase64Bytes +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import kotlin.io.encoding.Base64 @@ -113,9 +118,15 @@ const val ProfileCardCreateButtonTestTag = "ProfileCardCreateButtonTestTag" const val ProfileCardCardScreenTestTag = "ProfileCardCardScreenTestTag" const val ProfileCardEditButtonTestTag = "ProfileCardEditButtonTestTag" -fun NavGraphBuilder.profileCardScreen(contentPadding: PaddingValues) { +fun NavGraphBuilder.profileCardScreen( + contentPadding: PaddingValues, + onClickShareProfileCard: (String, ImageBitmap) -> Unit, +) { composable(profileCardScreenRoute) { - ProfileCardScreen(contentPadding) + ProfileCardScreen( + contentPadding = contentPadding, + onClickShareProfileCard = onClickShareProfileCard, + ) } } @@ -171,11 +182,13 @@ internal data class ProfileCardScreenState( @Composable fun ProfileCardScreen( + onClickShareProfileCard: (String, ImageBitmap) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), ) { ProfileCardScreen( contentPadding = contentPadding, + onClickShareProfileCard = onClickShareProfileCard, modifier = modifier, rememberEventEmitter(), ) @@ -185,6 +198,7 @@ fun ProfileCardScreen( @Composable internal fun ProfileCardScreen( contentPadding: PaddingValues, + onClickShareProfileCard: (String, ImageBitmap) -> Unit, modifier: Modifier = Modifier, eventEmitter: EventEmitter = rememberEventEmitter(), uiState: ProfileCardScreenState = profileCardScreenPresenter(eventEmitter), @@ -276,8 +290,10 @@ internal fun ProfileCardScreen( onClickEdit = { eventEmitter.tryEmit(CardScreenEvent.Edit) }, - onClickShareProfileCard = { - eventEmitter.tryEmit(CardScreenEvent.Share) + onClickShareProfileCard = { imageBitmap -> + // TODO Make it better written. + val shareText = "${uiState.cardUiState.nickname}'s profile card" + onClickShareProfileCard(shareText, imageBitmap) }, contentPadding = padding, isCreated = true, @@ -643,11 +659,14 @@ fun Modifier.selectedBorder( internal fun CardScreen( uiState: ProfileCardUiState.Card, onClickEdit: () -> Unit, - onClickShareProfileCard: () -> Unit, + onClickShareProfileCard: (ImageBitmap) -> Unit, modifier: Modifier = Modifier, isCreated: Boolean = false, contentPadding: PaddingValues = PaddingValues(16.dp), ) { + val coroutineScope = rememberCoroutineScope() + val graphicsLayer = rememberGraphicsLayer() + ProvideProfileCardTheme(uiState.cardType.toString()) { Column( modifier = modifier @@ -662,12 +681,23 @@ internal fun CardScreen( verticalArrangement = Arrangement.Center, ) { FlipCard( + modifier = Modifier + .drawWithContent { + graphicsLayer.record { + this@drawWithContent.drawContent() + } + drawLayer(graphicsLayer) + }, uiState = uiState, isCreated = isCreated, ) Spacer(Modifier.height(32.dp)) Button( - onClick = { onClickShareProfileCard() }, + onClick = { + coroutineScope.launch { + onClickShareProfileCard(graphicsLayer.toImageBitmap()) + } + }, colors = ButtonDefaults.buttonColors(containerColor = Color.White), border = if (uiState.cardType == ProfileCardType.None) BorderStroke(0.5.dp, Color.Black) else null, modifier = Modifier diff --git a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenPresenter.kt b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenPresenter.kt index 59d0b3777..1b6d9ba33 100644 --- a/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenPresenter.kt +++ b/feature/profilecard/src/commonMain/kotlin/io/github/droidkaigi/confsched/profilecard/ProfileCardScreenPresenter.kt @@ -19,6 +19,7 @@ import io.github.droidkaigi.confsched.model.localProfileCardRepository import io.github.droidkaigi.confsched.profilecard.ProfileCardUiType.Card import io.github.droidkaigi.confsched.ui.providePresenterDefaults import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource internal sealed interface ProfileCardScreenEvent @@ -45,7 +46,6 @@ internal sealed interface EditScreenEvent : ProfileCardScreenEvent { } internal sealed interface CardScreenEvent : ProfileCardScreenEvent { - data object Share : CardScreenEvent data object Edit : CardScreenEvent } @@ -120,20 +120,18 @@ internal fun profileCardScreenPresenter( when (event) { is CardScreenEvent.Edit -> { isLoading = true - userMessageStateHolder.showMessage("Edit") + launch { + userMessageStateHolder.showMessage("Edit") + } uiType = ProfileCardUiType.Edit isLoading = false } - is CardScreenEvent.Share -> { - isLoading = true - userMessageStateHolder.showMessage("Share Profile Card") - isLoading = false - } - is EditScreenEvent.Create -> { isLoading = true - userMessageStateHolder.showMessage("Create Profile Card") + launch { + userMessageStateHolder.showMessage("Create Profile Card") + } repository.save(event.profileCard) uiType = Card isLoading = false @@ -141,7 +139,9 @@ internal fun profileCardScreenPresenter( is EditScreenEvent.SelectImage -> { isLoading = true - userMessageStateHolder.showMessage("Select Image") + launch { + userMessageStateHolder.showMessage("Select Image") + } isLoading = false } diff --git a/feature/sessions/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/sessions/SearchScreenTest.kt b/feature/sessions/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/sessions/SearchScreenTest.kt index da3400177..e971c2658 100644 --- a/feature/sessions/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/sessions/SearchScreenTest.kt +++ b/feature/sessions/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/sessions/SearchScreenTest.kt @@ -46,10 +46,10 @@ class SearchScreenTest( setupTimetableServer(ServerStatus.Operational) setupSearchScreenContent() } - itShould("show non-filtered timetable items") { + itShould("no timetable items are displayed") { captureScreenWithChecks { - checkTimetableListDisplayed() - checkTimetableListItemsDisplayed() + checkTimetableListExists() + checkTimetableListItemsNotDisplayed() } } describe("input search word to TextField") { @@ -60,6 +60,8 @@ class SearchScreenTest( captureScreenWithChecks { checkDemoSearchWordDisplayed() checkTimetableListItemsHasDemoText() + checkTimetableListDisplayed() + checkTimetableListItemsDisplayed() } } } @@ -84,6 +86,8 @@ class SearchScreenTest( checkTimetableListItemByConferenceDay( checkDay = conference, ) + checkTimetableListDisplayed() + checkTimetableListItemsDisplayed() } } } @@ -108,6 +112,8 @@ class SearchScreenTest( itShould("selected category ${category.categoryName}") { captureScreenWithChecks { checkTimetableListItemByCategory(category) + checkTimetableListDisplayed() + checkTimetableListItemsDisplayed() } } } @@ -133,6 +139,8 @@ class SearchScreenTest( itShould("selected language ${language.name}") { captureScreenWithChecks { checkTimetableListItemByLanguage(language) + checkTimetableListDisplayed() + checkTimetableListItemsDisplayed() } } } diff --git a/feature/sessions/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/sessions/TimetableItemDetailScreenTest.kt b/feature/sessions/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/sessions/TimetableItemDetailScreenTest.kt index 108e28434..24ed5c31c 100644 --- a/feature/sessions/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/sessions/TimetableItemDetailScreenTest.kt +++ b/feature/sessions/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/sessions/TimetableItemDetailScreenTest.kt @@ -47,11 +47,11 @@ class TimetableItemDetailScreenTest(private val testCase: DescribedBehavior