From 34b1f3681463d0ba5845aec2cd3ffa2f33dcac8a Mon Sep 17 00:00:00 2001 From: Tobias Preuss Date: Thu, 4 Apr 2024 23:15:18 +0200 Subject: [PATCH 1/2] Migrate "About" screen to Compose UI. + Replace XML-based screen with Compose UI setup. + Introduce AboutViewModel and AboutParameter to encapsulate screen logic. Add unit tests. + AndroidView is used for text links to keep the on press background color effect working. See: commons/Composables.kt. + Add @MultiDevicePreview annotation. + Vertical scroll bar is missing. --- .../congress/about/AboutComposables.kt | 290 ++++++++++++++++++ .../fahrplan/congress/about/AboutDialog.kt | 211 ++----------- .../fahrplan/congress/about/AboutParameter.kt | 27 ++ .../congress/about/AboutParameterFactory.kt | 84 +++++ .../fahrplan/congress/about/AboutViewEvent.kt | 7 + .../fahrplan/congress/about/AboutViewModel.kt | 41 +++ .../congress/about/AboutViewModelFactory.kt | 24 ++ .../congress/commons/BuildConfigProvider.kt | 17 + .../congress/commons/BuildConfigProvision.kt | 15 + .../fahrplan/congress/commons/Composables.kt | 141 +++++++++ .../congress/commons/ComposePreviews.kt | 34 ++ .../congress/commons/ExternalNavigation.kt | 7 + .../congress/commons/ExternalNavigator.kt | 10 + .../fahrplan/congress/commons/TextResource.kt | 41 +++ .../fahrplan/congress/extensions/Contexts.kt | 12 + .../congress/extensions/DpExtensions.kt | 12 + .../congress/extensions/TextViewExtensions.kt | 22 -- .../congress/schedule/MainActivity.kt | 11 +- .../congress/schedule/MainViewModel.kt | 6 +- app/src/main/res/layout/about_dialog.xml | 146 +-------- app/src/main/res/layout/horizontal_line.xml | 6 - app/src/main/res/values-land/dimens.xml | 2 - app/src/main/res/values/dimens.xml | 2 +- app/src/main/res/values/styles_congress.xml | 56 ---- .../about/AboutParameterFactoryTest.kt | 197 ++++++++++++ .../congress/about/AboutViewModelTest.kt | 56 ++++ .../congress/commons/TextResourceTest.kt | 42 +++ .../extensions/TextViewExtensionsTest.kt | 56 ---- .../congress/schedule/MainViewModelTest.kt | 2 +- 29 files changed, 1099 insertions(+), 478 deletions(-) create mode 100644 app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutComposables.kt create mode 100644 app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutParameter.kt create mode 100644 app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutParameterFactory.kt create mode 100644 app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutViewEvent.kt create mode 100644 app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutViewModel.kt create mode 100644 app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutViewModelFactory.kt create mode 100644 app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/BuildConfigProvider.kt create mode 100644 app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/BuildConfigProvision.kt create mode 100644 app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/ComposePreviews.kt create mode 100644 app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/ExternalNavigation.kt create mode 100644 app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/ExternalNavigator.kt create mode 100644 app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/TextResource.kt create mode 100644 app/src/main/java/nerd/tuxmobil/fahrplan/congress/extensions/DpExtensions.kt delete mode 100644 app/src/main/res/layout/horizontal_line.xml create mode 100644 app/src/test/java/nerd/tuxmobil/fahrplan/congress/about/AboutParameterFactoryTest.kt create mode 100644 app/src/test/java/nerd/tuxmobil/fahrplan/congress/about/AboutViewModelTest.kt create mode 100644 app/src/test/java/nerd/tuxmobil/fahrplan/congress/commons/TextResourceTest.kt delete mode 100644 app/src/test/java/nerd/tuxmobil/fahrplan/congress/extensions/TextViewExtensionsTest.kt diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutComposables.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutComposables.kt new file mode 100644 index 0000000000..4086e3a0a8 --- /dev/null +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutComposables.kt @@ -0,0 +1,290 @@ +package nerd.tuxmobil.fahrplan.congress.about + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Divider +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import nerd.tuxmobil.fahrplan.congress.BuildConfig +import nerd.tuxmobil.fahrplan.congress.R +import nerd.tuxmobil.fahrplan.congress.about.AboutViewEvent.OnPostalAddressClick +import nerd.tuxmobil.fahrplan.congress.commons.ClickableText +import nerd.tuxmobil.fahrplan.congress.commons.EventFahrplanTheme +import nerd.tuxmobil.fahrplan.congress.commons.MultiDevicePreview +import nerd.tuxmobil.fahrplan.congress.commons.TextResource +import nerd.tuxmobil.fahrplan.congress.commons.TextResource.Empty +import nerd.tuxmobil.fahrplan.congress.commons.TextResource.Html +import nerd.tuxmobil.fahrplan.congress.commons.TextResource.PostalAddress +import nerd.tuxmobil.fahrplan.congress.extensions.toTextUnit + +@Composable +internal fun AboutScreen( + parameter: AboutParameter, + onViewEvent: (AboutViewEvent) -> Unit, +) { + EventFahrplanTheme { + Scaffold { contentPadding -> + Box( + Modifier + .background(colorResource(R.color.about_window_background)) + .padding(contentPadding) + .fillMaxSize() // Prevent background flickering on load + .verticalScroll(rememberScrollState()) + ) { + Column(Modifier.padding( + start = dimensionResource(R.dimen.about_padding_horizontal), + top = dimensionResource(R.dimen.about_padding_top), + end = dimensionResource(R.dimen.about_padding_horizontal), + bottom = dimensionResource(R.dimen.about_padding_bottom) + )) { + EventInfo(parameter, onViewEvent) + UsageNote(parameter) + AppDisclaimer(parameter) + LogoCopyright(parameter) + ProjectLinks(parameter) + Libraries(parameter) + DataPrivacyStatement(parameter) + CopyrightNotes(parameter) + BuildInfo(parameter) + } + } + } + } +} + +@Composable +private fun EventInfo(parameter: AboutParameter, onViewEvent: (AboutViewEvent) -> Unit) { + Column( + Modifier.fillMaxWidth(), // Prevent horizontal flickering on load + horizontalAlignment = CenterHorizontally + ) { + val horizontalTextAlign = TextAlign.Center + Image( + modifier = Modifier + .padding(vertical = 16.dp), + painter = painterResource(R.drawable.dialog_logo), + contentDescription = stringResource(R.string.about_logo_content_description) + ) + if (parameter.title.isNotEmpty()) { + Text( + modifier = Modifier.padding(bottom = 4.dp), + color = colorResource(R.color.about_title), + fontSize = dimensionResource(R.dimen.about_title).toTextUnit(), + fontWeight = FontWeight.Bold, + text = parameter.title, + textAlign = horizontalTextAlign, + ) + } + if (parameter.subtitle.isNotEmpty()) { + Text( + modifier = Modifier.padding(bottom = 12.dp), + text = parameter.subtitle, + fontSize = dimensionResource(R.dimen.about_subtitle).toTextUnit(), + fontStyle = FontStyle.Italic, + fontFamily = FontFamily.Serif, + color = colorResource(R.color.about_subtitle), + textAlign = horizontalTextAlign, + ) + } + AboutClickableText( + textResource = parameter.eventLocation, + textAlign = horizontalTextAlign, + onClick = { onViewEvent(OnPostalAddressClick(it)) }, + ) + AboutClickableText( + textResource = parameter.eventUrl, + textAlign = horizontalTextAlign, + ) + AboutText( + text = parameter.scheduleVersion, + textAlign = horizontalTextAlign, + ) + AboutText( + text = parameter.appVersion, + textAlign = horizontalTextAlign, + ) + if (parameter.eventLocation != Empty || + parameter.eventUrl != Empty || + parameter.scheduleVersion.isNotEmpty() || + parameter.appVersion.isNotEmpty() + ) { + SectionDivider() + } + } +} + +@Composable +private fun UsageNote(parameter: AboutParameter) { + if (parameter.usageNote.isNotEmpty()) { + AboutText(text = parameter.usageNote) + SectionDivider() + } +} + +@Composable +private fun AppDisclaimer(parameter: AboutParameter) { + if (parameter.appDisclaimer.isNotEmpty()) { + AboutText(text = parameter.appDisclaimer) + SectionDivider() + } +} + +@Composable +private fun LogoCopyright(parameter: AboutParameter) { + if (parameter.logoCopyright != Empty) { + AboutClickableText(textResource = parameter.logoCopyright) + SectionDivider() + } +} + +@Composable +private fun ProjectLinks(parameter: AboutParameter) { + AboutClickableText( + textResource = parameter.translationPlatform, + ) + AboutClickableText( + textResource = parameter.sourceCode, + ) + AboutClickableText( + textResource = parameter.issues, + ) + AboutClickableText( + textResource = parameter.fDroid, + ) + AboutClickableText( + textResource = parameter.googlePlay, + ) + if (parameter.translationPlatform != Empty || + parameter.sourceCode != Empty || + parameter.issues != Empty || + parameter.fDroid != Empty || + parameter.googlePlay != Empty + ) { + SectionDivider() + } +} + +@Composable +private fun Libraries(parameter: AboutParameter) { + if (parameter.libraries.isNotEmpty()) { + AboutText(text = parameter.libraries) + SectionDivider() + } +} + +@Composable +private fun DataPrivacyStatement(parameter: AboutParameter) { + if (parameter.dataPrivacyStatement != Empty) { + AboutClickableText( + textResource = parameter.dataPrivacyStatement, + ) + SectionDivider() + } +} + +@Composable +private fun CopyrightNotes(parameter: AboutParameter) { + if (parameter.copyrightNotes.isNotEmpty()) { + AboutText(text = parameter.copyrightNotes) + SectionDivider() + } +} + +@Composable +private fun BuildInfo(parameter: AboutParameter) { + AboutText(text = parameter.buildTime) + AboutText(text = parameter.buildVersion) + AboutText(text = parameter.buildHash) +} + +@Composable +private fun AboutClickableText( + textResource: TextResource, + textAlign: TextAlign = TextAlign.Start, + onClick: (String) -> Unit = {}, +) { + ClickableText( + textResource = textResource, + fontSize = dimensionResource(R.dimen.about_text).toTextUnit(), // To match font size of AboutText + textAlign = textAlign, + textColor = R.color.about_text, + textLinkColor = R.color.about_text_link, + onClick = onClick, + ) +} + +@Composable +private fun AboutText( + text: String, + modifier: Modifier = Modifier, + textAlign: TextAlign = TextAlign.Start, +) { + if (text.isNotEmpty()) { + Text( + modifier = modifier + .padding(horizontal = 16.dp, vertical = 4.dp), + text = text, + fontSize = dimensionResource(R.dimen.about_text).toTextUnit(), + textAlign = textAlign, + color = colorResource(R.color.about_text), + ) + } +} + +@Composable +private fun SectionDivider() { + Divider( + modifier = Modifier.padding(vertical = 12.dp), + color = colorResource(R.color.about_horizontal_line), + thickness = dimensionResource(R.dimen.about_horizontal_line_height) + ) +} + +@MultiDevicePreview +@Composable +private fun AboutScreenPreview() { + AboutScreen( + AboutParameter( + title = "37th Chaos Communication Congress", + subtitle = "Unlocked", + eventLocation = PostalAddress("CCH, Congressplatz 1, 20355 Hamburg"), + eventUrl = Html.of("https://events.ccc.de/congress/2023/"), + scheduleVersion = "Fahrplan BAD NETWORK/FIREWALL", + appVersion = "App Version 1.63.2 Kaus Australis; lounges 909; lightning Manwë; thms Tales of Monkey Island; wiki 2023-12-28 12:11", + usageNote = stringResource(R.string.usage), + appDisclaimer = stringResource(R.string.app_disclaimer), + logoCopyright = Html.of(stringResource(R.string.copyright_logo)), + translationPlatform = Html.of(BuildConfig.TRANSLATION_PLATFORM_URL, stringResource(R.string.about_translation_platform)), + sourceCode = Html.of(BuildConfig.SOURCE_CODE_URL, stringResource(R.string.about_source_code)), + issues = Html.of(BuildConfig.ISSUES_URL, stringResource(R.string.about_issues_or_feature_requests)), + fDroid = Html.of(BuildConfig.F_DROID_URL, stringResource(R.string.about_f_droid_listing)), + googlePlay = Html.of(BuildConfig.GOOGLE_PLAY_URL, stringResource(R.string.about_google_play_listing)), + libraries = stringResource(R.string.about_libraries_statement), + dataPrivacyStatement = Html.of(BuildConfig.DATA_PRIVACY_STATEMENT_DE_URL, stringResource(R.string.about_data_privacy_statement_german)), + copyrightNotes = stringResource(R.string.copyright_notes), + buildTime = stringResource(R.string.build_info_time), + buildVersion = stringResource(R.string.build_info_version_code), + buildHash = stringResource(R.string.build_info_hash), + ), + onViewEvent = {}, + ) +} diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutDialog.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutDialog.kt index 00130d37a4..7989e59fd4 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutDialog.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutDialog.kt @@ -1,210 +1,53 @@ package nerd.tuxmobil.fahrplan.congress.about import android.content.Context -import android.content.Intent -import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.TextView -import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.core.net.toUri -import androidx.core.view.isVisible +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed import androidx.fragment.app.DialogFragment -import nerd.tuxmobil.fahrplan.congress.BuildConfig +import androidx.fragment.app.viewModels import nerd.tuxmobil.fahrplan.congress.R -import nerd.tuxmobil.fahrplan.congress.extensions.requireViewByIdCompat -import nerd.tuxmobil.fahrplan.congress.extensions.setLinkText -import nerd.tuxmobil.fahrplan.congress.extensions.startActivity -import nerd.tuxmobil.fahrplan.congress.extensions.toSpanned -import nerd.tuxmobil.fahrplan.congress.extensions.withArguments -import nerd.tuxmobil.fahrplan.congress.utils.LinkMovementMethodCompat +import nerd.tuxmobil.fahrplan.congress.commons.ExternalNavigation +import nerd.tuxmobil.fahrplan.congress.commons.ExternalNavigator +import nerd.tuxmobil.fahrplan.congress.commons.ResourceResolver +import nerd.tuxmobil.fahrplan.congress.commons.ResourceResolving class AboutDialog : DialogFragment() { companion object { const val FRAGMENT_TAG = "AboutDialog" - private const val BUNDLE_KEY_SCHEDULE_VERSION = "${BuildConfig.APPLICATION_ID}.BUNDLE_KEY_SCHEDULE_VERSION" - private const val BUNDLE_KEY_SUBTITLE = "${BuildConfig.APPLICATION_ID}.BUNDLE_KEY_SUBTITLE" - private const val BUNDLE_KEY_TITLE = "${BuildConfig.APPLICATION_ID}.BUNDLE_KEY_TITLE" + } - fun newInstance(scheduleVersion: String, subtitle: String, title: String) = - AboutDialog().withArguments( - BUNDLE_KEY_SCHEDULE_VERSION to scheduleVersion, - BUNDLE_KEY_SUBTITLE to subtitle, - BUNDLE_KEY_TITLE to title - ) + private lateinit var resourceResolving: ResourceResolving + private lateinit var externalNavigation: ExternalNavigation + private val viewModel: AboutViewModel by viewModels { + AboutViewModelFactory(resourceResolving, externalNavigation) } - private var scheduleVersionText = "" - private var subtitleText = "" - private var titleText = "" + override fun onAttach(context: Context) { + super.onAttach(context) + resourceResolving = ResourceResolver(context) + externalNavigation = ExternalNavigator(context) + } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View = inflater.inflate(R.layout.about_dialog, container, false) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - with(requireArguments()) { - scheduleVersionText = getString(BUNDLE_KEY_SCHEDULE_VERSION, "") - subtitleText = getString(BUNDLE_KEY_SUBTITLE, "") - titleText = getString(BUNDLE_KEY_TITLE, "") - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - // Schedule version - val scheduleVersion = view.requireViewByIdCompat(R.id.about_session_version_view) - if (scheduleVersionText.isEmpty()) { - scheduleVersion.isVisible = false - } else { - scheduleVersion.isVisible = true - scheduleVersion.text = "${getString(R.string.fahrplan)} $scheduleVersionText" - } - - // Session title - val title = view.requireViewByIdCompat(R.id.about_session_title_view) - if (titleText.isEmpty()) { - titleText = getString(R.string.app_name) - } - title.text = titleText - - // Session subtitle - val subtitle = view.requireViewByIdCompat(R.id.about_session_subtitle_view) - if (subtitleText.isEmpty()) { - val hardcodedSubtitleText = getString(R.string.app_hardcoded_subtitle) - if (hardcodedSubtitleText.isEmpty()) { - subtitle.isVisible = false - } else { - subtitle.isVisible = true - subtitleText = hardcodedSubtitleText + ): View = inflater.inflate(R.layout.about_dialog, container, false).apply { + findViewById(R.id.about_view).apply { + setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed) + setContent { + AboutScreen( + parameter = viewModel.aboutParameter.collectAsState().value, + onViewEvent = viewModel::onViewEvent, + ) } - } - subtitle.text = subtitleText - - // App version - val appVersion = view.requireViewByIdCompat(R.id.about_app_version_view) - appVersion.text = getString(R.string.appVersion, BuildConfig.VERSION_NAME) - - // App disclaimer - val appDisclaimer = view.requireViewByIdCompat(R.id.about_app_disclaimer_view) - appDisclaimer.isVisible = BuildConfig.SHOW_APP_DISCLAIMER - val linkTextColor = ContextCompat.getColor(view.context, R.color.text_link_on_dark) - val movementMethod = LinkMovementMethodCompat.getInstance() - val appDisclaimerLine = view.requireViewByIdCompat(R.id.about_app_disclaimer_line_view) - appDisclaimerLine.isVisible = BuildConfig.SHOW_APP_DISCLAIMER - - // Logo copyright note - val logoCopyright = view.requireViewByIdCompat(R.id.about_copyright_logo_view) - logoCopyright.text = getString(R.string.copyright_logo).toSpanned() - logoCopyright.setLinkTextColor(linkTextColor) - logoCopyright.movementMethod = movementMethod - - // Event location - val locationView = view.requireViewByIdCompat(R.id.about_conference_location_view) - val locationText = BuildConfig.EVENT_POSTAL_ADDRESS - if (locationText.isEmpty()) { - locationView.isVisible = false - } else { - locationView.isVisible = true - locationView.setLinkText(locationText, null, movementMethod, linkTextColor) - locationView.setOnClickListener { openMap(view.context, locationText) } - } - - // Event website URL - val conferenceUrl = view.requireViewByIdCompat(R.id.about_conference_url_view) - val websiteUrl = BuildConfig.EVENT_WEBSITE_URL - conferenceUrl.setLinkText(websiteUrl, null, movementMethod, linkTextColor) - - // Translation platform link - val translationPlatform = view.requireViewByIdCompat(R.id.about_translation_platform_view) - val translationPlatformUrl = BuildConfig.TRANSLATION_PLATFORM_URL - val translationPlatformTitle = getString(R.string.about_translation_platform) - translationPlatform.setLinkText(translationPlatformUrl, translationPlatformTitle, movementMethod, linkTextColor) - - // Source code link - val sourceCode = view.requireViewByIdCompat(R.id.about_source_code_view) - val sourceCodeUrl = BuildConfig.SOURCE_CODE_URL - val sourceCodeTitle = getString(R.string.about_source_code) - sourceCode.setLinkText(sourceCodeUrl, sourceCodeTitle, movementMethod, linkTextColor) - - // Issues link - val issues = view.requireViewByIdCompat(R.id.about_issues_view) - val issuesUrl = BuildConfig.ISSUES_URL - val issuesTitle = getString(R.string.about_issues_or_feature_requests) - issues.setLinkText(issuesUrl, issuesTitle, movementMethod, linkTextColor) - - // F-Droid store link - val fdroidStore = view.requireViewByIdCompat(R.id.about_f_droid_view) - val fdroidUrl = BuildConfig.F_DROID_URL - if (fdroidUrl.isEmpty()) { - fdroidStore.isVisible = false - } else { - fdroidStore.isVisible = true - val fdroidListingTitle = getString(R.string.about_f_droid_listing) - fdroidStore.setLinkText(fdroidUrl, fdroidListingTitle, movementMethod, linkTextColor) - } - - // Google Play store link - val googlePlayStore = view.requireViewByIdCompat(R.id.about_google_play_view) - val googlePlayUrl = BuildConfig.GOOGLE_PLAY_URL - val googlePlayListingTitle = getString(R.string.about_google_play_listing) - googlePlayStore.setLinkText( - googlePlayUrl, - googlePlayListingTitle, - movementMethod, - linkTextColor - ) - - // Libraries statement - val librariesStatement = view.requireViewByIdCompat(R.id.about_libraries_view) - val libraryNames = getString(R.string.about_libraries_names) - val librariesStatementText = getString(R.string.about_libraries_statement, libraryNames) - librariesStatement.text = librariesStatementText - - // Privacy statement - val dataPrivacyStatement = view.requireViewByIdCompat(R.id.about_data_privacy_statement_view) - val dataPrivacyStatementGermanUrl = BuildConfig.DATA_PRIVACY_STATEMENT_DE_URL - val dataPrivacyStatementGermanTitle = - getString(R.string.about_data_privacy_statement_german) - dataPrivacyStatement.setLinkText( - dataPrivacyStatementGermanUrl, - dataPrivacyStatementGermanTitle, - movementMethod, - linkTextColor - ) - - // Build time - val buildTimeTextView = view.requireViewByIdCompat(R.id.build_time) - val buildTimeValue = getString(R.string.build_time) - val buildTimeText = getString(R.string.build_info_time, buildTimeValue) - buildTimeTextView.text = buildTimeText - - // Build version - val versionCodeTextView = view.requireViewByIdCompat(R.id.build_version_code) - val versionCodeText = - getString(R.string.build_info_version_code, "" + BuildConfig.VERSION_CODE) - versionCodeTextView.text = versionCodeText - - // Build hash - val buildHashTextView = view.requireViewByIdCompat(R.id.build_hash) - val buildHashValue = getString(R.string.git_sha) - val buildHashText = getString(R.string.build_info_hash, buildHashValue) - buildHashTextView.text = buildHashText - } - - private fun openMap(context: Context, @Suppress("SameParameterValue") locationText: String) { - val encodedLocationText = Uri.encode(locationText) - val uri = "geo:0,0?q=$encodedLocationText".toUri() - context.startActivity(Intent(Intent.ACTION_VIEW).apply { data = uri }) { - Toast.makeText(context, R.string.share_error_activity_not_found, Toast.LENGTH_SHORT).show() + isClickable = true } } diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutParameter.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutParameter.kt new file mode 100644 index 0000000000..4ace80e55d --- /dev/null +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutParameter.kt @@ -0,0 +1,27 @@ +package nerd.tuxmobil.fahrplan.congress.about + +import nerd.tuxmobil.fahrplan.congress.commons.TextResource +import nerd.tuxmobil.fahrplan.congress.commons.TextResource.Empty + +data class AboutParameter( + val title: String = "", + val subtitle: String = "", + val eventLocation: TextResource = Empty, + val eventUrl: TextResource = Empty, + val scheduleVersion: String = "", + val appVersion: String = "", + val usageNote: String = "", + val appDisclaimer: String = "", + val logoCopyright: TextResource = Empty, + val translationPlatform: TextResource = Empty, + val sourceCode: TextResource = Empty, + val issues: TextResource = Empty, + val fDroid: TextResource = Empty, + val googlePlay: TextResource = Empty, + val libraries: String = "", + val dataPrivacyStatement: TextResource = Empty, + val copyrightNotes: String = "", + val buildTime: String = "", + val buildVersion: String = "", + val buildHash: String = "", +) diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutParameterFactory.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutParameterFactory.kt new file mode 100644 index 0000000000..d1b1aa4179 --- /dev/null +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutParameterFactory.kt @@ -0,0 +1,84 @@ +package nerd.tuxmobil.fahrplan.congress.about + +import androidx.annotation.StringRes +import nerd.tuxmobil.fahrplan.congress.R +import nerd.tuxmobil.fahrplan.congress.commons.BuildConfigProvision +import nerd.tuxmobil.fahrplan.congress.commons.ResourceResolving +import nerd.tuxmobil.fahrplan.congress.commons.TextResource +import nerd.tuxmobil.fahrplan.congress.commons.TextResource.Empty +import nerd.tuxmobil.fahrplan.congress.commons.TextResource.Html +import nerd.tuxmobil.fahrplan.congress.commons.TextResource.PostalAddress +import nerd.tuxmobil.fahrplan.congress.models.Meta + +class AboutParameterFactory( + private val buildConfig: BuildConfigProvision, + private val resourceResolving: ResourceResolving, +) { + + fun createAboutParameter(meta: Meta): AboutParameter { + val scheduleVersion = meta.version + val scheduleVersionText = if (scheduleVersion.isEmpty()) "" + else "${resourceResolving.getString(R.string.fahrplan)} $scheduleVersion" + + val title = meta.title + val titleText = title.ifEmpty { resourceResolving.getString(R.string.app_name) } + + val subtitle = meta.subtitle + val subtitleText = subtitle.ifEmpty { + resourceResolving.getString(R.string.app_hardcoded_subtitle).ifEmpty { "" } + } + + return AboutParameter( + title = titleText, + subtitle = subtitleText, + eventLocation = PostalAddress(buildConfig.eventPostalAddress), + eventUrl = textResourceOf(url = buildConfig.eventWebsiteUrl), + scheduleVersion = scheduleVersionText, + appVersion = if (buildConfig.versionName.isEmpty()) "" else resourceResolving.getString( + R.string.appVersion, + buildConfig.versionName + ), + + usageNote = resourceResolving.getString(R.string.usage), + appDisclaimer = if (buildConfig.showAppDisclaimer) resourceResolving.getString(R.string.app_disclaimer) else "", + logoCopyright = Html(resourceResolving.getString(R.string.copyright_logo)), + + translationPlatform = textResourceOf(R.string.about_translation_platform, buildConfig.translationPlatformUrl), + + sourceCode = textResourceOf(R.string.about_source_code, buildConfig.sourceCodeUrl), + issues = textResourceOf(R.string.about_issues_or_feature_requests, buildConfig.issuesUrl), + fDroid = textResourceOf(R.string.about_f_droid_listing, buildConfig.fDroidUrl), + googlePlay = textResourceOf(R.string.about_google_play_listing, buildConfig.googlePlayUrl), + + libraries = resourceResolving.getString( + R.string.about_libraries_statement, + resourceResolving.getString(R.string.about_libraries_names) + ), + dataPrivacyStatement = textResourceOf(R.string.about_data_privacy_statement_german, buildConfig.dataPrivacyStatementDeUrl), + copyrightNotes = resourceResolving.getString(R.string.copyright_notes), + + buildTime = resourceResolving.getString( + R.string.build_info_time, + resourceResolving.getString(R.string.build_time) + ), + buildVersion = resourceResolving.getString( + R.string.build_info_version_code, + "${buildConfig.versionCode}" + ), + buildHash = resourceResolving.getString( + R.string.build_info_hash, + resourceResolving.getString(R.string.git_sha) + ), + ) + } + + private fun textResourceOf(@StringRes text: Int? = null, url: String? = null): TextResource { + return if (url.isNullOrEmpty()) { + Empty + } else { + val title = if (text == null) null else resourceResolving.getString(text) + Html.of(url = url, text = title) + } + } + +} diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutViewEvent.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutViewEvent.kt new file mode 100644 index 0000000000..181d9d708a --- /dev/null +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutViewEvent.kt @@ -0,0 +1,7 @@ +package nerd.tuxmobil.fahrplan.congress.about + +sealed interface AboutViewEvent { + + data class OnPostalAddressClick(val textualAddress: String): AboutViewEvent + +} diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutViewModel.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutViewModel.kt new file mode 100644 index 0000000000..3d6da5e996 --- /dev/null +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutViewModel.kt @@ -0,0 +1,41 @@ +package nerd.tuxmobil.fahrplan.congress.about + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import nerd.tuxmobil.fahrplan.congress.about.AboutViewEvent.OnPostalAddressClick +import nerd.tuxmobil.fahrplan.congress.commons.ExternalNavigation +import nerd.tuxmobil.fahrplan.congress.repositories.AppRepository +import nerd.tuxmobil.fahrplan.congress.repositories.ExecutionContext + +class AboutViewModel( + private val repository: AppRepository = AppRepository, + private val executionContext: ExecutionContext, + private val externalNavigation: ExternalNavigation, + private val aboutParameterFactory: AboutParameterFactory, +) : ViewModel() { + + private val mutableAboutParameter = MutableStateFlow(AboutParameter()) + val aboutParameter = mutableAboutParameter.asStateFlow() + + init { + launch { + val meta = repository.readMeta() + mutableAboutParameter.value = aboutParameterFactory.createAboutParameter(meta) + } + } + + fun onViewEvent(viewEvent: AboutViewEvent) { + when (viewEvent) { + is OnPostalAddressClick -> externalNavigation.openMap(viewEvent.textualAddress) + } + } + + private fun launch(block: suspend CoroutineScope.() -> Unit) { + viewModelScope.launch(executionContext.database, block = block) + } + +} diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutViewModelFactory.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutViewModelFactory.kt new file mode 100644 index 0000000000..6f1e2e7a10 --- /dev/null +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutViewModelFactory.kt @@ -0,0 +1,24 @@ +package nerd.tuxmobil.fahrplan.congress.about + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider.Factory +import nerd.tuxmobil.fahrplan.congress.commons.BuildConfigProvider +import nerd.tuxmobil.fahrplan.congress.commons.ExternalNavigation +import nerd.tuxmobil.fahrplan.congress.commons.ResourceResolving +import nerd.tuxmobil.fahrplan.congress.repositories.AppExecutionContext + +internal class AboutViewModelFactory( + private val resourceResolving: ResourceResolving, + private val externalNavigation: ExternalNavigation, +) : Factory { + + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return AboutViewModel( + executionContext = AppExecutionContext, + externalNavigation = externalNavigation, + aboutParameterFactory = AboutParameterFactory(BuildConfigProvider(), resourceResolving) + ) as T + } + +} diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/BuildConfigProvider.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/BuildConfigProvider.kt new file mode 100644 index 0000000000..12e2a52779 --- /dev/null +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/BuildConfigProvider.kt @@ -0,0 +1,17 @@ +package nerd.tuxmobil.fahrplan.congress.commons + +import nerd.tuxmobil.fahrplan.congress.BuildConfig + +class BuildConfigProvider : BuildConfigProvision { + override val versionName: String = BuildConfig.VERSION_NAME + override val versionCode: Int = BuildConfig.VERSION_CODE + override val eventPostalAddress: String = BuildConfig.EVENT_POSTAL_ADDRESS + override val eventWebsiteUrl: String = BuildConfig.EVENT_WEBSITE_URL + override val showAppDisclaimer: Boolean = BuildConfig.SHOW_APP_DISCLAIMER + override val translationPlatformUrl: String = BuildConfig.TRANSLATION_PLATFORM_URL + override val sourceCodeUrl: String = BuildConfig.SOURCE_CODE_URL + override val issuesUrl: String = BuildConfig.ISSUES_URL + override val fDroidUrl: String = BuildConfig.F_DROID_URL + override val googlePlayUrl: String = BuildConfig.GOOGLE_PLAY_URL + override val dataPrivacyStatementDeUrl: String = BuildConfig.DATA_PRIVACY_STATEMENT_DE_URL +} \ No newline at end of file diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/BuildConfigProvision.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/BuildConfigProvision.kt new file mode 100644 index 0000000000..897a679444 --- /dev/null +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/BuildConfigProvision.kt @@ -0,0 +1,15 @@ +package nerd.tuxmobil.fahrplan.congress.commons + +interface BuildConfigProvision { + val versionName: String + val versionCode: Int + val eventPostalAddress: String + val eventWebsiteUrl: String + val showAppDisclaimer: Boolean + val translationPlatformUrl: String + val sourceCodeUrl: String + val issuesUrl: String + val fDroidUrl: String + val googlePlayUrl: String + val dataPrivacyStatementDeUrl: String +} \ No newline at end of file diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/Composables.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/Composables.kt index 5331129338..6e78e3a04f 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/Composables.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/Composables.kt @@ -1,17 +1,40 @@ package nerd.tuxmobil.fahrplan.congress.commons +import android.view.Gravity.CENTER +import android.view.Gravity.START +import android.widget.TextView +import androidx.annotation.ColorRes import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration.Companion.Underline +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import nerd.tuxmobil.fahrplan.congress.R +import nerd.tuxmobil.fahrplan.congress.commons.TextResource.Empty +import nerd.tuxmobil.fahrplan.congress.commons.TextResource.Html +import nerd.tuxmobil.fahrplan.congress.commons.TextResource.PostalAddress +import nerd.tuxmobil.fahrplan.congress.extensions.toSpanned +import nerd.tuxmobil.fahrplan.congress.utils.LinkMovementMethodCompat @Composable fun Loading() { @@ -36,3 +59,121 @@ fun NoData(text: String) { ) } } + +@Composable +fun ClickableText( + textResource: TextResource, + fontSize: TextUnit, + textAlign: TextAlign, + @ColorRes textColor: Int, + @ColorRes textLinkColor: Int, + onClick: (String) -> Unit = {}, +) { + var vertical = 0.dp + if (textResource is PostalAddress && textResource.text.isNotEmpty()) { + vertical = 4.dp + } + if (textResource is Html && textResource.html.isNotEmpty()) { + vertical = 4.dp + } + Box(Modifier.padding(horizontal = 16.dp, vertical = vertical)) { + when (textResource) { + Empty -> Unit + is PostalAddress -> { + GenericClickableText( + text = textResource.text, + plainUrl = textResource.text, + fontSize = fontSize, + textAlign = textAlign, + textLinkColor = colorResource(textLinkColor), + onClick = { onClick(textResource.text) }, + ) + } + + is Html -> { + AndroidView( + factory = { context -> + TextView(context).apply { + movementMethod = LinkMovementMethodCompat.getInstance() + setTextColor(ContextCompat.getColor(context, textColor)) + setLinkTextColor(ContextCompat.getColor(context, textLinkColor)) + gravity = if (textAlign == TextAlign.Center) CENTER else START + textSize = fontSize.value + text = textResource.html.toSpanned() + } + }, + ) + } + } + } +} + +@Composable +private fun GenericClickableText( + text: String, + plainUrl: String, + fontSize: TextUnit, + textAlign: TextAlign, + textLinkColor: Color, + onClick: (String) -> Unit, +) { + if (text.isNotEmpty()) { + val tag = "URL" + val annotatedString = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = textLinkColor, + textDecoration = Underline, + fontSize = fontSize, + ) + ) { + append(text) + } + addStringAnnotation( + tag = tag, + annotation = plainUrl, + start = 0, + end = text.length, + ) + } + + ClickableText( + text = annotatedString, + style = TextStyle( + textAlign = textAlign, + ), + onClick = { + annotatedString + .getStringAnnotations(tag = tag, start = it, end = it) + .firstOrNull() + ?.let { range -> onClick(range.item) } + } + ) + } +} + +@Preview +@Composable +private fun ClickableTextPostalAddressPreview() { + ClickableText( + textResource = PostalAddress("Congressplatz 1, 20355 Hamburg"), + fontSize = 18.sp, + textAlign = TextAlign.Center, + textColor = -1, + textLinkColor = android.R.color.holo_blue_light, + onClick = {}, + ) +} + +@Preview +@Composable +private fun ClickableTextHtmlPreview() { + ClickableText( + textResource = Html("""Design by eventfahrplan.eu"""), + fontSize = 18.sp, + textAlign = TextAlign.Center, + textColor = R.color.colorPrimary, + textLinkColor = android.R.color.holo_purple, + onClick = {}, + ) +} diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/ComposePreviews.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/ComposePreviews.kt new file mode 100644 index 0000000000..d5819040d1 --- /dev/null +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/ComposePreviews.kt @@ -0,0 +1,34 @@ +package nerd.tuxmobil.fahrplan.congress.commons + +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.ui.tooling.preview.Preview + +@Preview( + name = "1 Android xs", + device = "id:Nexus 5", + showSystemUi = true, +) +@Preview( + name = "2 Pixel 4 XL - light", + device = "id:pixel_4_xl", + uiMode = UI_MODE_NIGHT_NO, + showSystemUi = true, +) +@Preview( + name = "3 Pixel 4 XL - dark", + device = "id:pixel_4_xl", + uiMode = UI_MODE_NIGHT_YES, + showSystemUi = true, +) +@Preview( + name = "4 Galaxy Tab A7 lite - portrait", + device = "spec:width=800dp,height=1334dp,dpi=179", + showSystemUi = true, +) +@Preview( + name = "5 Galaxy Tab A7 lite - landscape", + device = "spec:width=1334dp,height=800dp,dpi=179", + showSystemUi = true, +) +annotation class MultiDevicePreview diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/ExternalNavigation.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/ExternalNavigation.kt new file mode 100644 index 0000000000..f89b57ca5f --- /dev/null +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/ExternalNavigation.kt @@ -0,0 +1,7 @@ +package nerd.tuxmobil.fahrplan.congress.commons + +fun interface ExternalNavigation { + + fun openMap(locationText: String) + +} diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/ExternalNavigator.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/ExternalNavigator.kt new file mode 100644 index 0000000000..9c9c2e30a0 --- /dev/null +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/ExternalNavigator.kt @@ -0,0 +1,10 @@ +package nerd.tuxmobil.fahrplan.congress.commons + +import android.content.Context +import nerd.tuxmobil.fahrplan.congress.extensions.openMap + +class ExternalNavigator(val context: Context) : ExternalNavigation { + + override fun openMap(locationText: String) = context.openMap(locationText) + +} diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/TextResource.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/TextResource.kt new file mode 100644 index 0000000000..a4adbbb824 --- /dev/null +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/commons/TextResource.kt @@ -0,0 +1,41 @@ +package nerd.tuxmobil.fahrplan.congress.commons + +sealed interface TextResource { + + /** + * No text at all. + */ + data object Empty : TextResource + + /** + * Examples: + * - example.com + * - Some text without a link. + */ + data class Html( + val html: String, + ) : TextResource { + + companion object { + fun of(url: String, text: String? = null): Html { + require(url.isNotEmpty()) + return if ("http" in url) { + val title = text ?: url + Html("""$title""") + } else { + require(text == null) + Html(url) + } + } + } + + } + + /** + * Example: Congressplatz 1, 20355 Hamburg + */ + data class PostalAddress( + val text: String, + ) : TextResource + +} diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/extensions/Contexts.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/extensions/Contexts.kt index 934f77ac4d..891fd20f19 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/extensions/Contexts.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/extensions/Contexts.kt @@ -7,9 +7,13 @@ import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.res.Configuration +import android.net.Uri import android.view.LayoutInflater +import android.widget.Toast import androidx.core.app.NotificationManagerCompat import androidx.core.content.getSystemService +import androidx.core.net.toUri +import nerd.tuxmobil.fahrplan.congress.R fun Context.getAlarmManager() = getSystemService()!! @@ -26,3 +30,11 @@ fun Context.startActivity(intent: Intent, onActivityNotFound: () -> Unit) { } fun Context.isLandscape() = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + +fun Context.openMap(locationText: String) { + val encodedLocationText = Uri.encode(locationText) + val uri = "geo:0,0?q=$encodedLocationText".toUri() + startActivity(Intent(Intent.ACTION_VIEW).apply { data = uri }) { + Toast.makeText(this, R.string.share_error_activity_not_found, Toast.LENGTH_SHORT).show() + } +} diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/extensions/DpExtensions.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/extensions/DpExtensions.kt new file mode 100644 index 0000000000..89e694ada8 --- /dev/null +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/extensions/DpExtensions.kt @@ -0,0 +1,12 @@ +package nerd.tuxmobil.fahrplan.congress.extensions + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit + +@Composable +fun Dp.toTextUnit(): TextUnit { + val density = LocalDensity.current + return with(density) { this@toTextUnit.toSp() } +} diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/extensions/TextViewExtensions.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/extensions/TextViewExtensions.kt index b6342e6509..2655cff107 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/extensions/TextViewExtensions.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/extensions/TextViewExtensions.kt @@ -2,12 +2,7 @@ package nerd.tuxmobil.fahrplan.congress.extensions -import android.text.method.MovementMethod -import android.text.style.URLSpan import android.widget.TextView -import androidx.annotation.ColorInt -import androidx.core.text.set -import androidx.core.text.toSpannable import androidx.core.view.isVisible /** @@ -24,20 +19,3 @@ var TextView.textOrHide: CharSequence isVisible = true } } - -/** - * Sets the given [plainLinkUrl] and the optional [urlTitle] as a clickable link to this [TextView]. - */ -@JvmOverloads -fun TextView.setLinkText( - plainLinkUrl: String, - urlTitle: String? = null, - movementMethod: MovementMethod, - @ColorInt linkTextColor: Int, -) { - val title = urlTitle ?: plainLinkUrl - val linkText = title.toSpannable().apply { set(0, title.length, URLSpan(plainLinkUrl)) } - setText(linkText, TextView.BufferType.SPANNABLE) - setMovementMethod(movementMethod) - setLinkTextColor(linkTextColor) -} diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/MainActivity.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/MainActivity.kt index dce8c73b46..b176e770d8 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/MainActivity.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/MainActivity.kt @@ -43,7 +43,6 @@ import nerd.tuxmobil.fahrplan.congress.engagements.initUserEngagement import nerd.tuxmobil.fahrplan.congress.extensions.withExtras import nerd.tuxmobil.fahrplan.congress.favorites.StarredListActivity import nerd.tuxmobil.fahrplan.congress.favorites.StarredListFragment -import nerd.tuxmobil.fahrplan.congress.models.Meta import nerd.tuxmobil.fahrplan.congress.net.CertificateErrorFragment import nerd.tuxmobil.fahrplan.congress.net.ErrorMessage import nerd.tuxmobil.fahrplan.congress.net.HttpStatus @@ -167,8 +166,8 @@ class MainActivity : BaseActivity(), viewModel.scheduleChangesParameter.observe(this) { (scheduleVersion, changeStatistic) -> showChangesDialog(scheduleVersion, changeStatistic) } - viewModel.showAbout.observe(this) { meta -> - showAboutDialog(meta) + viewModel.showAbout.observe(this) { + showAboutDialog() } viewModel.openSessionDetails.observe(this) { openSessionDetails() @@ -246,12 +245,10 @@ class MainActivity : BaseActivity(), } } - private fun showAboutDialog(meta: Meta) { + private fun showAboutDialog() { val transaction = supportFragmentManager.beginTransaction() transaction.addToBackStack(null) - AboutDialog - .newInstance(meta.version, meta.subtitle, meta.title) - .show(transaction, AboutDialog.FRAGMENT_TAG) + AboutDialog().show(transaction, AboutDialog.FRAGMENT_TAG) } override fun onOptionsItemSelected(item: MenuItem): Boolean { diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/MainViewModel.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/MainViewModel.kt index ef63ab6d90..9a5530cf33 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/MainViewModel.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/schedule/MainViewModel.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import nerd.tuxmobil.fahrplan.congress.changes.ChangeStatistic -import nerd.tuxmobil.fahrplan.congress.models.Meta import nerd.tuxmobil.fahrplan.congress.net.ParseResult import nerd.tuxmobil.fahrplan.congress.notifications.NotificationHelper import nerd.tuxmobil.fahrplan.congress.repositories.AppRepository @@ -47,7 +46,7 @@ internal class MainViewModel( private val mutableScheduleChangesParameter = Channel() val scheduleChangesParameter = mutableScheduleChangesParameter.receiveAsFlow() - private val mutableShowAbout = Channel() + private val mutableShowAbout = Channel() val showAbout = mutableShowAbout.receiveAsFlow() private val mutableOpenSessionDetails = Channel() @@ -147,8 +146,7 @@ internal class MainViewModel( fun showAboutDialog() { launch { - val meta = repository.readMeta() - mutableShowAbout.sendOneTimeEvent(meta) + mutableShowAbout.sendOneTimeEvent(Unit) } } diff --git a/app/src/main/res/layout/about_dialog.xml b/app/src/main/res/layout/about_dialog.xml index 7ae1424cfb..7eaba4c4b5 100644 --- a/app/src/main/res/layout/about_dialog.xml +++ b/app/src/main/res/layout/about_dialog.xml @@ -1,142 +1,10 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="0dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> diff --git a/app/src/main/res/layout/horizontal_line.xml b/app/src/main/res/layout/horizontal_line.xml deleted file mode 100644 index abf88091d9..0000000000 --- a/app/src/main/res/layout/horizontal_line.xml +++ /dev/null @@ -1,6 +0,0 @@ - - diff --git a/app/src/main/res/values-land/dimens.xml b/app/src/main/res/values-land/dimens.xml index f0033ef644..f559dfbc4d 100644 --- a/app/src/main/res/values-land/dimens.xml +++ b/app/src/main/res/values-land/dimens.xml @@ -25,7 +25,5 @@ 32dp - 16dp - 32dp diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index a0519b0ed1..8b53db94a3 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -47,7 +47,7 @@ 1dp 16dp 16dp - 16dp + @dimen/about_padding_horizontal 20sp 16sp 14sp diff --git a/app/src/main/res/values/styles_congress.xml b/app/src/main/res/values/styles_congress.xml index 31c7b73170..b739cc8236 100644 --- a/app/src/main/res/values/styles_congress.xml +++ b/app/src/main/res/values/styles_congress.xml @@ -199,60 +199,4 @@ @dimen/session_details_section_margin_top - - - - - - - - - - - - - - - - diff --git a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/about/AboutParameterFactoryTest.kt b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/about/AboutParameterFactoryTest.kt new file mode 100644 index 0000000000..05294cb8ab --- /dev/null +++ b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/about/AboutParameterFactoryTest.kt @@ -0,0 +1,197 @@ +package nerd.tuxmobil.fahrplan.congress.about + +import com.google.common.truth.Truth.assertThat +import nerd.tuxmobil.fahrplan.congress.R +import nerd.tuxmobil.fahrplan.congress.commons.BuildConfigProvision +import nerd.tuxmobil.fahrplan.congress.commons.ResourceResolving +import nerd.tuxmobil.fahrplan.congress.commons.TextResource.Empty +import nerd.tuxmobil.fahrplan.congress.commons.TextResource.Html +import nerd.tuxmobil.fahrplan.congress.commons.TextResource.PostalAddress +import nerd.tuxmobil.fahrplan.congress.models.Meta +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.fail + +class AboutParameterFactoryTest { + + @Test + fun `createAboutParameter returns AboutParameter with all properties filled`() { + val factory = AboutParameterFactory(CompleteBuildConfigProvider, CompleteResourceResolver) + val meta = Meta( + version = "2024-01-21 21:26", + title = "37th Chaos Communication Congress", + subtitle = "Unlocked", + ) + val expected = AboutParameter( + title = "37th Chaos Communication Congress", + subtitle = "Unlocked", + eventLocation = PostalAddress("Congressplatz 1, 20355 Hamburg"), + eventUrl = Html.of("https://events.ccc.de/congress/2023/"), + scheduleVersion = "Fahrplan 2024-01-21 21:26", + appVersion = "App Version 1.63.2", + usageNote = "Usage note", + appDisclaimer = "App disclaimer", + logoCopyright = Html("""Logo by eventfahrplan.eu"""), + translationPlatform = Html.of(text = "Translation platform", url = "https://crowdin.com/project/eventfahrplan"), + sourceCode = Html.of(text = "Source code", url = "https://github.com/EventFahrplan/EventFahrplan"), + issues = Html.of(text = "Issues or feature requests", url = "https://github.com/EventFahrplan/EventFahrplan/issues"), + fDroid = Html.of(text = "F-Droid listing", url = "https://f-droid.org/packages/info.metadude.android.congress.schedule"), + googlePlay = Html.of(text = "Google Play listing", url = "https://play.google.com/store/apps/details?id=info.metadude.android.congress.schedule"), + libraries = "This application uses the following libraries: Jetpack Compose", + dataPrivacyStatement = Html.of(text = "Data privacy statement (German)", url = "https://github.com/EventFahrplan/EventFahrplan/blob/master/DATA-PRIVACY-DE.md"), + copyrightNotes = "Copyright", + buildTime = "Build time: 2015-12-27T13:42Z", + buildVersion = "Version code: 100", + buildHash = "Version hash: e1f2g3h-dirty", + ) + assertThat(factory.createAboutParameter(meta)).isEqualTo(expected) + } + + @Test + fun `createAboutParameter returns AboutParameter with some properties filled with hardcoded values`() { + val factory = AboutParameterFactory(CompleteBuildConfigProvider, CompleteResourceResolver) + val meta = Meta( + version = "", + title = "", + subtitle = "", + ) + val expected = AboutParameter( + title = "37C3 Schedule", + subtitle = "December 27–30 2023, Congress Center Hamburg", + eventLocation = PostalAddress("Congressplatz 1, 20355 Hamburg"), + eventUrl = Html.of("https://events.ccc.de/congress/2023/"), + scheduleVersion = "", + appVersion = "App Version 1.63.2", + usageNote = "Usage note", + appDisclaimer = "App disclaimer", + logoCopyright = Html("""Logo by eventfahrplan.eu"""), + translationPlatform = Html.of(text = "Translation platform", url = "https://crowdin.com/project/eventfahrplan"), + sourceCode = Html.of(text = "Source code", url = "https://github.com/EventFahrplan/EventFahrplan"), + issues = Html.of(text = "Issues or feature requests", url = "https://github.com/EventFahrplan/EventFahrplan/issues"), + fDroid = Html.of(text = "F-Droid listing", url = "https://f-droid.org/packages/info.metadude.android.congress.schedule"), + googlePlay = Html.of(text = "Google Play listing", url = "https://play.google.com/store/apps/details?id=info.metadude.android.congress.schedule"), + libraries = "This application uses the following libraries: Jetpack Compose", + dataPrivacyStatement = Html.of(text = "Data privacy statement (German)", url = "https://github.com/EventFahrplan/EventFahrplan/blob/master/DATA-PRIVACY-DE.md"), + copyrightNotes = "Copyright", + buildTime = "Build time: 2015-12-27T13:42Z", + buildVersion = "Version code: 100", + buildHash = "Version hash: e1f2g3h-dirty", + ) + assertThat(factory.createAboutParameter(meta)).isEqualTo(expected) + } + + @Test + fun `createAboutParameter returns AboutParameter with some empty properties`() { + val factory = AboutParameterFactory(IncompleteBuildConfigProvider, SomeEmptyResourceResolver) + val meta = Meta( + version = "", + title = "", + subtitle = "", + ) + val expected = AboutParameter( + title = "37C3 Schedule", + subtitle = "", + eventLocation = PostalAddress("Congressplatz 1, 20355 Hamburg"), + eventUrl = Html.of("https://events.ccc.de/congress/2023/"), + scheduleVersion = "", + appVersion = "", + usageNote = "Usage note", + appDisclaimer = "", + logoCopyright = Html.of("Logo by eventfahrplan.eu"), + translationPlatform = Html.of(text = "Translation platform", url = "https://crowdin.com/project/eventfahrplan"), + sourceCode = Html.of(text = "Source code", url = "https://github.com/EventFahrplan/EventFahrplan"), + issues = Html.of(text = "Issues or feature requests", url = "https://github.com/EventFahrplan/EventFahrplan/issues"), + fDroid = Empty, + googlePlay = Html.of(text = "Google Play listing", url = "https://play.google.com/store/apps/details?id=info.metadude.android.congress.schedule"), + libraries = "This application uses the following libraries: Jetpack Compose", + dataPrivacyStatement = Html.of(text = "Data privacy statement (German)", url = "https://github.com/EventFahrplan/EventFahrplan/blob/master/DATA-PRIVACY-DE.md"), + copyrightNotes = "Copyright", + buildTime = "Build time: 2015-12-27T13:42Z", + buildVersion = "Version code: 200", + buildHash = "Version hash: e1f2g3h-dirty", + ) + assertThat(factory.createAboutParameter(meta)).isEqualTo(expected) + } + +} + +private object CompleteResourceResolver : ResourceResolving { + override fun getString(id: Int, vararg formatArgs: Any) = when (id) { + R.string.app_name -> "37C3 Schedule" + R.string.app_hardcoded_subtitle -> "December 27–30 2023, Congress Center Hamburg" + R.string.fahrplan -> "Fahrplan" + R.string.appVersion -> "App Version ${formatArgs.first()}" + R.string.usage -> "Usage note" + R.string.app_disclaimer -> "App disclaimer" + R.string.copyright_logo -> """Logo by eventfahrplan.eu""" + R.string.about_translation_platform -> "Translation platform" + R.string.about_source_code -> "Source code" + R.string.about_issues_or_feature_requests -> "Issues or feature requests" + R.string.about_f_droid_listing -> "F-Droid listing" + R.string.about_google_play_listing -> "Google Play listing" + R.string.about_libraries_statement -> "This application uses the following libraries: ${formatArgs.first()}" + R.string.about_libraries_names -> "Jetpack Compose" + R.string.about_data_privacy_statement_german -> "Data privacy statement (German)" + R.string.copyright_notes -> "Copyright" + R.string.build_time -> "2015-12-27T13:42Z" + R.string.build_info_time -> "Build time: ${formatArgs.first()}" + R.string.build_info_version_code -> "Version code: ${formatArgs.first()}" + R.string.git_sha -> "e1f2g3h-dirty" + R.string.build_info_hash -> "Version hash: ${formatArgs.first()}" + else -> fail("Unknown string id : $id") + } +} + +private object SomeEmptyResourceResolver : ResourceResolving { + override fun getString(id: Int, vararg formatArgs: Any) = when (id) { + R.string.app_name -> "37C3 Schedule" + R.string.app_hardcoded_subtitle -> "" + R.string.fahrplan -> "Fahrplan" + R.string.appVersion -> "App Version ${formatArgs.first()}" + R.string.usage -> "Usage note" + R.string.app_disclaimer -> "App disclaimer" + R.string.copyright_logo -> "Logo by eventfahrplan.eu" + R.string.about_translation_platform -> "Translation platform" + R.string.about_source_code -> "Source code" + R.string.about_issues_or_feature_requests -> "Issues or feature requests" + R.string.about_f_droid_listing -> "F-Droid listing" + R.string.about_google_play_listing -> "Google Play listing" + R.string.about_libraries_statement -> "This application uses the following libraries: ${formatArgs.first()}" + R.string.about_libraries_names -> "Jetpack Compose" + R.string.about_data_privacy_statement_german -> "Data privacy statement (German)" + R.string.copyright_notes -> "Copyright" + R.string.build_time -> "2015-12-27T13:42Z" + R.string.build_info_time -> "Build time: ${formatArgs.first()}" + R.string.build_info_version_code -> "Version code: ${formatArgs.first()}" + R.string.git_sha -> "e1f2g3h-dirty" + R.string.build_info_hash -> "Version hash: ${formatArgs.first()}" + else -> fail("Unknown string id : $id") + } +} + +private object CompleteBuildConfigProvider : BuildConfigProvision { + override val versionName: String = "1.63.2" + override val versionCode: Int = 100 + override val eventPostalAddress: String = "Congressplatz 1, 20355 Hamburg" + override val eventWebsiteUrl: String = "https://events.ccc.de/congress/2023/" + override val showAppDisclaimer: Boolean = true + override val translationPlatformUrl: String = "https://crowdin.com/project/eventfahrplan" + override val sourceCodeUrl: String = "https://github.com/EventFahrplan/EventFahrplan" + override val issuesUrl: String = "https://github.com/EventFahrplan/EventFahrplan/issues" + override val fDroidUrl: String = "https://f-droid.org/packages/info.metadude.android.congress.schedule" + override val googlePlayUrl: String = "https://play.google.com/store/apps/details?id=info.metadude.android.congress.schedule" + override val dataPrivacyStatementDeUrl: String = "https://github.com/EventFahrplan/EventFahrplan/blob/master/DATA-PRIVACY-DE.md" +} + +private object IncompleteBuildConfigProvider : BuildConfigProvision { + override val versionName: String = "" + override val versionCode: Int = 200 + override val eventPostalAddress: String = "Congressplatz 1, 20355 Hamburg" + override val eventWebsiteUrl: String = "https://events.ccc.de/congress/2023/" + override val showAppDisclaimer: Boolean = false + override val translationPlatformUrl: String = "https://crowdin.com/project/eventfahrplan" + override val sourceCodeUrl: String = "https://github.com/EventFahrplan/EventFahrplan" + override val issuesUrl: String = "https://github.com/EventFahrplan/EventFahrplan/issues" + override val fDroidUrl: String = "" + override val googlePlayUrl: String = "https://play.google.com/store/apps/details?id=info.metadude.android.congress.schedule" + override val dataPrivacyStatementDeUrl: String = "https://github.com/EventFahrplan/EventFahrplan/blob/master/DATA-PRIVACY-DE.md" +} diff --git a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/about/AboutViewModelTest.kt b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/about/AboutViewModelTest.kt new file mode 100644 index 0000000000..111aaeeffd --- /dev/null +++ b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/about/AboutViewModelTest.kt @@ -0,0 +1,56 @@ +package nerd.tuxmobil.fahrplan.congress.about + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import info.metadude.android.eventfahrplan.commons.testing.MainDispatcherTestExtension +import info.metadude.android.eventfahrplan.commons.testing.verifyInvokedOnce +import kotlinx.coroutines.test.runTest +import nerd.tuxmobil.fahrplan.congress.TestExecutionContext +import nerd.tuxmobil.fahrplan.congress.about.AboutViewEvent.OnPostalAddressClick +import nerd.tuxmobil.fahrplan.congress.commons.ExternalNavigation +import nerd.tuxmobil.fahrplan.congress.models.Meta +import nerd.tuxmobil.fahrplan.congress.repositories.AppRepository +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +@ExtendWith(MainDispatcherTestExtension::class) +class AboutViewModelTest { + + @Test + fun `aboutParameter property emits AboutParameter at init`() = runTest { + val aboutParameterFactory = mock { + on { createAboutParameter(any()) } doReturn AboutParameter() + } + val viewModel = createViewModel(aboutParameterFactory = aboutParameterFactory) + viewModel.aboutParameter.test { + assertThat(awaitItem()).isEqualTo(AboutParameter()) + } + } + + @Test + fun `onViewEvent(OnPostalAddressClick) invokes openMap`() = runTest { + val externalNavigation = mock() + val viewModel = createViewModel(externalNavigation = externalNavigation) + viewModel.onViewEvent(OnPostalAddressClick("Street 1, City")) + verifyInvokedOnce(externalNavigation).openMap("Street 1, City") + } + + private fun createViewModel( + repository: AppRepository = createRepository(), + externalNavigation: ExternalNavigation = mock(), + aboutParameterFactory: AboutParameterFactory = mock(), + ) = AboutViewModel( + repository = repository, + executionContext = TestExecutionContext, + externalNavigation = externalNavigation, + aboutParameterFactory = aboutParameterFactory, + ) + + private fun createRepository() = mock { + on { readMeta() } doReturn Meta() + } + +} diff --git a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/commons/TextResourceTest.kt b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/commons/TextResourceTest.kt new file mode 100644 index 0000000000..e19d6673df --- /dev/null +++ b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/commons/TextResourceTest.kt @@ -0,0 +1,42 @@ +package nerd.tuxmobil.fahrplan.congress.commons + +import com.google.common.truth.Truth.assertThat +import nerd.tuxmobil.fahrplan.congress.commons.TextResource.Html +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class TextResourceTest { + + @Test + fun `of returns HTML formatted link with title`() { + assertThat(Html.of(url = "https://example.com", text = "Example website")) + .isEqualTo(Html("""Example website""")) + } + + @Test + fun `of returns HTML formatted link`() { + assertThat(Html.of(url = "https://example.com", text = "https://example.com")) + .isEqualTo(Html("""https://example.com""")) + } + + @Test + fun `of returns plain text`() { + assertThat(Html.of(url = "Visit example.com", text = null)) + .isEqualTo(Html("Visit example.com")) + } + + @Test + fun `of throws exception if url is empty`() { + assertThrows { + Html.of(url = "", text = null) + } + } + + @Test + fun `of throws exception if url and text are passed as plain text`() { + assertThrows { + Html.of(url = "Visit example.com", text = "Visit example.com") + } + } + +} diff --git a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/extensions/TextViewExtensionsTest.kt b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/extensions/TextViewExtensionsTest.kt deleted file mode 100644 index c23b84fb34..0000000000 --- a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/extensions/TextViewExtensionsTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -package nerd.tuxmobil.fahrplan.congress.extensions - -import android.text.Spannable -import android.text.method.MovementMethod -import android.text.style.URLSpan -import android.widget.TextView -import androidx.core.text.set -import androidx.core.text.toSpannable -import com.google.common.truth.Truth.assertThat -import org.junit.jupiter.api.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify - -class TextViewExtensionsTest { - - @Test - fun `setLinkText applies formatted link text with title and MovementMethod and LinkTextColor`() { - val textView = mock() - val movementMethod = mock() - val plainLinkUrl = "https://example.com" - val urlTitle = "Example website" - textView.setLinkText( - plainLinkUrl = plainLinkUrl, - urlTitle = urlTitle, - movementMethod = movementMethod, - linkTextColor = 23 - ) - val linkTextSpannableCaptor = argumentCaptor() - verify(textView).setText(linkTextSpannableCaptor.capture(), any()) - verify(textView).movementMethod = movementMethod - verify(textView).setLinkTextColor(23) - val linkText = urlTitle.toSpannable().apply { set(0, urlTitle.length, URLSpan(plainLinkUrl)) } - assertThat(linkTextSpannableCaptor.lastValue.toString()).isEqualTo(linkText.toString()) - } - - @Test - fun `setLinkText applies formatted link text without title and MovementMethod and LinkTextColor`() { - val textView = mock() - val movementMethod = mock() - val plainLinkUrl = "https://example.com" - textView.setLinkText( - plainLinkUrl = plainLinkUrl, - movementMethod = movementMethod, - linkTextColor = 23 - ) - val linkTextSpannableCaptor = argumentCaptor() - verify(textView).setText(linkTextSpannableCaptor.capture(), any()) - verify(textView).movementMethod = movementMethod - verify(textView).setLinkTextColor(23) - val linkText = plainLinkUrl.toSpannable().apply { set(0, plainLinkUrl.length, URLSpan(plainLinkUrl)) } - assertThat(linkTextSpannableCaptor.lastValue.toString()).isEqualTo(linkText.toString()) - } - -} diff --git a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/MainViewModelTest.kt b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/MainViewModelTest.kt index 0404769de6..b2d8fd56b1 100644 --- a/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/MainViewModelTest.kt +++ b/app/src/test/java/nerd/tuxmobil/fahrplan/congress/schedule/MainViewModelTest.kt @@ -299,7 +299,7 @@ class MainViewModelTest { val viewModel = createViewModel(repository) viewModel.showAboutDialog() viewModel.showAbout.test { - assertThat(awaitItem()).isEqualTo(Meta(version = "")) + assertThat(awaitItem()).isEqualTo(Unit) } } From 09f03cb1ad125f4cf6c9d2ae54ffd6e83d881ae7 Mon Sep 17 00:00:00 2001 From: Tobias Preuss Date: Sun, 21 Apr 2024 23:50:28 +0200 Subject: [PATCH 2/2] Customize the width of the "About" screen dialog. --- .../fahrplan/congress/about/AboutDialog.kt | 18 ++++++++++++++++++ app/src/main/res/values-land/dimens.xml | 1 + app/src/main/res/values-sw720dp/dimens.xml | 3 +++ app/src/main/res/values/dimens.xml | 1 + 4 files changed, 23 insertions(+) diff --git a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutDialog.kt b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutDialog.kt index 7989e59fd4..f3b5632626 100644 --- a/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutDialog.kt +++ b/app/src/main/java/nerd/tuxmobil/fahrplan/congress/about/AboutDialog.kt @@ -1,10 +1,12 @@ package nerd.tuxmobil.fahrplan.congress.about import android.content.Context +import android.content.res.Resources import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.Window import androidx.compose.runtime.collectAsState import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed @@ -51,4 +53,20 @@ class AboutDialog : DialogFragment() { } } + override fun onStart() { + super.onStart() + val width = resources.getInteger(R.integer.about_percentage_width) + dialog?.window?.setPercentageWidth(width) + } + +} + +/** + * Sets the width of the window to a percentage of the current screen width. + * To be invoked when the hosting activity is created. + */ +private fun Window.setPercentageWidth(percentage: Int) { + val metrics = Resources.getSystem().displayMetrics + val width = (metrics.widthPixels * (percentage / 100f)).toInt() + setLayout(width, ViewGroup.LayoutParams.WRAP_CONTENT) } diff --git a/app/src/main/res/values-land/dimens.xml b/app/src/main/res/values-land/dimens.xml index f559dfbc4d..fdd5286ea1 100644 --- a/app/src/main/res/values-land/dimens.xml +++ b/app/src/main/res/values-land/dimens.xml @@ -24,6 +24,7 @@ 12 + 70 32dp diff --git a/app/src/main/res/values-sw720dp/dimens.xml b/app/src/main/res/values-sw720dp/dimens.xml index cd6fad456e..abdd8cb0ea 100644 --- a/app/src/main/res/values-sw720dp/dimens.xml +++ b/app/src/main/res/values-sw720dp/dimens.xml @@ -27,4 +27,7 @@ 18sp 14sp + + 55 + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 8b53db94a3..4ee3160629 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -45,6 +45,7 @@ 1dp + 98 16dp 16dp @dimen/about_padding_horizontal