diff --git a/.editorconfig b/.editorconfig index dc5c6e5b..87f1cd89 100644 --- a/.editorconfig +++ b/.editorconfig @@ -27,3 +27,7 @@ ktlint_standard_max-line-length = disabled # For generated code [**/build/generated/**/*] ktlint = disabled + +# For buildkonfig generated code +[**/build/buildkonfig/**/*] +ktlint = disabled diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fd4d6b40..24f742b2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,7 +8,6 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.plugin.serialization) alias(libs.plugins.koin.compiler) - alias(libs.plugins.secrets.gradle.plugin) alias(libs.plugins.appVersioning) alias(libs.plugins.aboutlibraries) alias(libs.plugins.aboutlibraries.android) @@ -16,17 +15,6 @@ plugins { alias(androidx.plugins.room3) } -secrets { - defaultPropertiesFileName = "local.defaults.properties" - ignoreList += - listOf( - "SENTRY_DSN", - "SIGNING_STORE_PASSWORD", - "SIGNING_KEY_ALIAS", - "SIGNING_KEY_PASSWORD", - ) -} - // https://stackoverflow.com/a/69268957/16484891, CC BY-SA 4.0 val localProperties = Properties().apply { diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt index 3ab00bf1..ce961121 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt @@ -51,7 +51,6 @@ import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.preferences.OcrQueuePrefere import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.staging.OcrQueueStagingViewModel import xyz.sevive.arcaeaoffline.ui.screens.overview.OverviewViewModel import xyz.sevive.arcaeaoffline.ui.screens.settings.SettingsViewModel -import xyz.sevive.arcaeaoffline.ui.screens.settings.about.SettingsAboutViewModel import xyz.sevive.arcaeaoffline.ui.screens.settings.unstablealert.SettingsUnstableAlertScreenViewModel import xyz.sevive.arcaeaoffline.ui.screens.utilities.UtilitiesChartRecommendScreenViewModel @@ -135,7 +134,6 @@ val appModule = single { create(::createDiagnosticsCollector) } - viewModel() viewModel() viewModel() viewModel() diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Settings.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Settings.kt index 59540f01..26642654 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Settings.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Settings.kt @@ -9,7 +9,6 @@ enum class SettingsSubScreen( val route: String, @StringRes val title: Int, ) { - General("$SETTINGS_NAV_ROUTE_ROOT/general", R.string.settings_general_title), About("$SETTINGS_NAV_ROUTE_ROOT/about", R.string.settings_about_title), License("$SETTINGS_NAV_ROUTE_ROOT/license", R.string.settings_license_title), Aboutlibraries("$SETTINGS_NAV_ROUTE_ROOT/aboutlibraries", R.string.settings_aboutlibraries_title), diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsContent.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsContent.kt new file mode 100644 index 00000000..4511362f --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsContent.kt @@ -0,0 +1,193 @@ +package xyz.sevive.arcaeaoffline.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher +import org.koin.compose.viewmodel.koinViewModel +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.api.ArcaeaResourcesApiClient +import xyz.sevive.arcaeaoffline.data.IS_UNSTABLE_VERSION +import xyz.sevive.arcaeaoffline.ui.components.OperationTextItem +import xyz.sevive.arcaeaoffline.ui.components.SettingsGroupHeader +import xyz.sevive.arcaeaoffline.ui.components.SwitchItem +import xyz.sevive.arcaeaoffline.ui.components.TextItem +import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogConfirmButton +import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogDismissTextButton +import xyz.sevive.arcaeaoffline.ui.navigation.LocalListDetailNavigationContext +import xyz.sevive.arcaeaoffline.ui.navigation.SettingsSubScreen +import xyz.sevive.arcaeaoffline.ui.screens.NavEntryNavigateButton +import xyz.sevive.arcaeaoffline.ui.screens.settings.unstablealert.UnstableBuildAlertCard +import xyz.sevive.arcaeaoffline.ui.theme.spacing + +@Composable +private fun ResourcesApiBaseUrlEditDialog( + initialValue: String, + onConfirm: (String) -> Unit, + onReset: () -> Unit, + onDismiss: () -> Unit, +) { + var value by rememberSaveable { mutableStateOf(initialValue) } + + val trimmed = value.trim() + val isValid = trimmed.startsWith("http://") || trimmed.startsWith("https://") + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_general_pref_resources_api_base_url_dialog_title)) }, + text = { + TextField( + value = value, + onValueChange = { value = it }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + isError = value.isNotBlank() && !isValid, + supportingText = { + if (value.isNotBlank() && !isValid) { + Text(stringResource(R.string.settings_general_pref_resources_api_base_url_invalid)) + } else if (trimmed.startsWith("http://")) { + // Android 9+ silently blocks cleartext: an http URL is "valid" here, but + // every probe and download would fail with a generic network error. + Text(stringResource(R.string.settings_general_pref_resources_api_base_url_http_hint)) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + }, + dismissButton = { + DialogDismissTextButton( + onClick = { + onReset() + onDismiss() + }, + customIcon = { Icon(Icons.AutoMirrored.Filled.Undo, contentDescription = null) }, + customLabel = { Text(stringResource(R.string.general_reset)) }, + ) + }, + confirmButton = { + DialogConfirmButton( + onClick = { + onConfirm(trimmed) + onDismiss() + }, + enabled = isValid, + customIcon = { Icon(Icons.Default.Edit, contentDescription = null) }, + customLabel = { Text(stringResource(R.string.general_save)) }, + ) + }, + ) +} + +@Composable +internal fun SettingsContent( + onNavigateToEmergencyModeActivity: () -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), +) { + val navContext = LocalListDetailNavigationContext.current + val viewModel: SettingsViewModel = koinViewModel() + val uiState by viewModel.appPreferencesUiState.collectAsStateWithLifecycle() + val diagnosticsExportState by viewModel.diagnosticsExportState.collectAsStateWithLifecycle() + val diagnosticsDirPicker = + rememberDirectoryPickerLauncher { dir -> + dir?.let(viewModel::exportDiagnostics) + } + + var showBaseUrlEditDialog by rememberSaveable { mutableStateOf(false) } + if (showBaseUrlEditDialog) { + ResourcesApiBaseUrlEditDialog( + initialValue = uiState.resourcesApiBaseUrl, + onConfirm = viewModel::setResourcesApiBaseUrl, + onReset = { viewModel.setResourcesApiBaseUrl(ArcaeaResourcesApiClient.DEFAULT_BASE_URL) }, + onDismiss = { showBaseUrlEditDialog = false }, + ) + } + + Column( + modifier + .verticalScroll(rememberScrollState()) + .padding(contentPadding), + ) { + if (IS_UNSTABLE_VERSION) { + UnstableBuildAlertCard( + onClick = { navContext.navigateToDetail(SettingsSubScreen.UnstableAlert.route) }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = MaterialTheme.spacing.pagePadding), + showDetails = true, + ) + } + + SettingsGroupHeader(stringResource(R.string.settings_general_title)) + TextItem( + onClick = { showBaseUrlEditDialog = true }, + leadingIcon = Icons.Default.CloudDownload, + title = stringResource(R.string.settings_general_pref_resources_api_base_url), + content = uiState.resourcesApiBaseUrl, + ) + + SettingsGroupHeader(stringResource(R.string.settings_debug_title)) + SwitchItem( + value = uiState.autoSendCrashReports, + onValueChange = viewModel::setAutoSendCrashReports, + icon = Icons.Default.BugReport, + title = stringResource(R.string.settings_app_pref_enable_sentry), + ) + OperationTextItem( + title = stringResource(R.string.diagnostics_export), + content = stringResource(R.string.diagnostics_export_content), + state = diagnosticsExportState, + leadingIcon = Icons.Default.BugReport, + onClick = { diagnosticsDirPicker.launch() }, + ) + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) { + TextItem( + title = stringResource(R.string.emergency_mode_title), + leadingIcon = ImageVector.vectorResource(R.drawable.ic_activity_emergency_mode), + leadingIconTint = LocalContentColor.current, + trailingIcon = Icons.AutoMirrored.Default.ArrowForward, + onClick = onNavigateToEmergencyModeActivity, + ) + } + + HorizontalDivider() + + NavEntryNavigateButton( + titleResId = SettingsSubScreen.About.title, + icon = Icons.Outlined.Info, + ) { + navContext.navigateToDetail(SettingsSubScreen.About.route) + } + } +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsNavEntry.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsNavEntry.kt index 6adb262a..81d7066f 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsNavEntry.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsNavEntry.kt @@ -2,34 +2,14 @@ package xyz.sevive.arcaeaoffline.ui.screens.settings import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.Apps -import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.data.IS_UNSTABLE_VERSION -import xyz.sevive.arcaeaoffline.ui.components.TextItem -import xyz.sevive.arcaeaoffline.ui.navigation.LocalListDetailNavigationContext import xyz.sevive.arcaeaoffline.ui.navigation.MainScreen -import xyz.sevive.arcaeaoffline.ui.navigation.SettingsSubScreen -import xyz.sevive.arcaeaoffline.ui.screens.NavEntryNavigateButton -import xyz.sevive.arcaeaoffline.ui.screens.settings.unstablealert.UnstableBuildAlertCard -import xyz.sevive.arcaeaoffline.ui.theme.spacing @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -37,65 +17,19 @@ internal fun SettingsNavEntry( onNavigateToEmergencyModeActivity: () -> Unit, modifier: Modifier = Modifier, ) { - val navContext = LocalListDetailNavigationContext.current - Scaffold( modifier, topBar = { TopAppBar(title = { Text(stringResource(MainScreen.Settings.title)) }) }, - ) { - LazyColumn( - Modifier - .fillMaxSize() - .consumeWindowInsets(it), - contentPadding = it, - ) { - if (IS_UNSTABLE_VERSION) { - item { - UnstableBuildAlertCard( - onClick = { navContext.navigateToDetail(SettingsSubScreen.UnstableAlert.route) }, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = MaterialTheme.spacing.pagePadding), - showDetails = true, - ) - } - } - - item { - NavEntryNavigateButton( - titleResId = SettingsSubScreen.General.title, - icon = Icons.Default.Apps, - ) { - navContext.navigateToDetail(SettingsSubScreen.General.route) - } - } - - item { - NavEntryNavigateButton( - titleResId = SettingsSubScreen.About.title, - icon = Icons.Outlined.Info, - ) { - navContext.navigateToDetail(SettingsSubScreen.About.route) - } - } - - item { - CompositionLocalProvider( - LocalContentColor provides MaterialTheme.colorScheme.error, - ) { - TextItem( - title = stringResource(R.string.emergency_mode_title), - leadingIcon = ImageVector.vectorResource(R.drawable.ic_activity_emergency_mode), - leadingIconTint = LocalContentColor.current, - trailingIcon = Icons.AutoMirrored.Default.ArrowForward, - ) { - onNavigateToEmergencyModeActivity() - } - } - } - } + ) { padding -> + SettingsContent( + onNavigateToEmergencyModeActivity = onNavigateToEmergencyModeActivity, + modifier = + Modifier + .fillMaxSize() + .consumeWindowInsets(padding), + contentPadding = padding, + ) } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsScreen.kt index 12079fd1..8d9afcdc 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsScreen.kt @@ -2,24 +2,19 @@ package xyz.sevive.arcaeaoffline.ui.screens.settings import android.content.Intent import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.koin.compose.viewmodel.koinViewModel import xyz.sevive.arcaeaoffline.EmergencyModeActivity import xyz.sevive.arcaeaoffline.ui.AdaptiveEntryScreen import xyz.sevive.arcaeaoffline.ui.navigation.LocalListDetailNavigationContext import xyz.sevive.arcaeaoffline.ui.navigation.SettingsSubScreen import xyz.sevive.arcaeaoffline.ui.screens.settings.about.SettingsAboutScreen import xyz.sevive.arcaeaoffline.ui.screens.settings.aboutlibraries.SettingsAboutlibrariesScreen -import xyz.sevive.arcaeaoffline.ui.screens.settings.general.SettingsGeneralScreen import xyz.sevive.arcaeaoffline.ui.screens.settings.license.SettingsLicenseScreen import xyz.sevive.arcaeaoffline.ui.screens.settings.unstablealert.SettingsUnstableAlertScreen @Composable -fun SettingsScreen(vm: SettingsViewModel = koinViewModel()) { +fun SettingsScreen() { val context = LocalContext.current - val generalUiState by vm.appPreferencesUiState.collectAsStateWithLifecycle() AdaptiveEntryScreen( listPane = { @@ -32,14 +27,6 @@ fun SettingsScreen(vm: SettingsViewModel = koinViewModel()) { detailPane = { route -> val navContext = LocalListDetailNavigationContext.current when (route) { - SettingsSubScreen.General.route -> { - SettingsGeneralScreen( - uiState = generalUiState, - onSetAutoSendCrashReports = { vm.setAutoSendCrashReports(it) }, - onSetResourcesApiBaseUrl = { vm.setResourcesApiBaseUrl(it) }, - ) - } - SettingsSubScreen.About.route -> { SettingsAboutScreen( onNavigateToLicenseScreen = { diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsViewModel.kt index c5a2c94d..9209dcf9 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/SettingsViewModel.kt @@ -1,18 +1,33 @@ package xyz.sevive.arcaeaoffline.ui.screens.settings +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.name +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.api.ArcaeaResourcesApiClient import xyz.sevive.arcaeaoffline.datastore.AppPreferencesRepository +import xyz.sevive.arcaeaoffline.helpers.diagnostics.DiagnosticsCollector +import xyz.sevive.arcaeaoffline.ui.components.OperationState class SettingsViewModel( + context: Context, private val appPreferencesRepository: AppPreferencesRepository, + private val diagnosticsCollector: DiagnosticsCollector, ) : ViewModel() { + private val appContext = context.applicationContext + data class AppPreferencesUiState( val autoSendCrashReports: Boolean = false, val resourcesApiBaseUrl: String = ArcaeaResourcesApiClient.DEFAULT_BASE_URL, @@ -38,4 +53,32 @@ class SettingsViewModel( appPreferencesRepository.setResourcesApiBaseUrl(value) } } + + private val _diagnosticsExportState = MutableStateFlow(null) + val diagnosticsExportState: StateFlow = _diagnosticsExportState.asStateFlow() + + fun exportDiagnostics(outputDir: PlatformFile) { + // atomic check & set progress state + val previous = _diagnosticsExportState.getAndUpdate { OperationState.InProgress } + if (previous is OperationState.InProgress) return + + viewModelScope.launch { + try { + _diagnosticsExportState.value = + try { + val target = diagnosticsCollector.exportTo(outputDir) + OperationState.Success( + appContext.getString(R.string.diagnostics_generated, target.name), + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + OperationState.Failed(e.message) + } + } finally { + // reset progress if canceled mid-operation + _diagnosticsExportState.compareAndSet(OperationState.InProgress, null) + } + } + } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/about/SettingsAboutCommunities.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/about/SettingsAboutCommunities.kt index 67db33ad..91da5741 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/about/SettingsAboutCommunities.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/about/SettingsAboutCommunities.kt @@ -1,74 +1,165 @@ package xyz.sevive.arcaeaoffline.ui.screens.settings.about +import android.content.ClipData +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.Code +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Feedback +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.vectorResource -import xyz.sevive.arcaeaoffline.BuildConfig +import androidx.compose.ui.platform.toClipEntry +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.shared.BuildKonfig +import xyz.sevive.arcaeaoffline.ui.components.BasicAlertDialogSurface +import xyz.sevive.arcaeaoffline.ui.theme.spacing +import kotlin.time.Duration.Companion.seconds + +private data class CommunityEntry( + val title: String, + val link: String, + val icon: @Composable () -> Painter, +) { + val isVisible = link != BuildKonfig.COMMUNITY_VALUE_PLACEHOLDER +} + +private val COMMUNITY_ENTRIES = + listOf( + CommunityEntry("GitHub", BuildKonfig.COMMUNITY_REPO_URL, { painterResource(R.drawable.simple_icons_github) }), + CommunityEntry("Issues", BuildKonfig.COMMUNITY_ISSUES_URL, { rememberVectorPainter(Icons.Filled.Feedback) }), + CommunityEntry("Discord", BuildKonfig.COMMUNITY_DISCORD_URL, { painterResource(R.drawable.simple_icons_discord) }), + CommunityEntry("QQ", BuildKonfig.COMMUNITY_QQ_URL, { painterResource(R.drawable.simple_icons_tencentqq) }), + ).filter { it.isVisible } @Composable -internal fun SettingsAboutCommunities(modifier: Modifier = Modifier) { - val showRepo = - remember { BuildConfig.COMMUNITY_REPO_URL != BuildConfig.COMMUNITY_VALUE_PLACEHOLDER } - val showIssues = - remember { BuildConfig.COMMUNITY_ISSUES_URL != BuildConfig.COMMUNITY_VALUE_PLACEHOLDER } - val showDiscord = - remember { BuildConfig.COMMUNITY_DISCORD_URL != BuildConfig.COMMUNITY_VALUE_PLACEHOLDER } - val showQq = - remember { BuildConfig.COMMUNITY_QQ_URL != BuildConfig.COMMUNITY_VALUE_PLACEHOLDER } +private fun CopyLinkIconButton(entry: CommunityEntry) { + val clipboard = LocalClipboard.current + val coroutineScope = rememberCoroutineScope() + + val (title, link) = entry + + var isCopied by remember { mutableStateOf(false) } + var copyCount by remember { mutableIntStateOf(0) } + LaunchedEffect(copyCount) { + if (copyCount > 0) { + isCopied = true + delay(1.seconds) + isCopied = false + } + } + + IconButton( + onClick = { + coroutineScope.launch { + val clipData = ClipData.newPlainText("Community link ($title)", link) + clipboard.setClipEntry(clipData.toClipEntry()) + copyCount++ + } + }, + ) { + AnimatedContent(isCopied) { + if (it) { + Icon(Icons.Default.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + } else { + Icon(Icons.Default.ContentCopy, contentDescription = null) + } + } + } +} +@Composable +internal fun SettingsAboutCommunities(modifier: Modifier = Modifier) { val uriHandler = LocalUriHandler.current - Column(modifier) { - Row { - CompositionLocalProvider( - LocalContentColor provides MaterialTheme.colorScheme.primary, + val accentColor = MaterialTheme.colorScheme.secondary + + var selectedEntryTitle by rememberSaveable { mutableStateOf(null) } + + selectedEntryTitle?.let { selectedTitle -> + val entry = COMMUNITY_ENTRIES.firstOrNull { it.title == selectedTitle } ?: return@let + val (title, link, icon) = entry + + BasicAlertDialogSurface( + onDismissRequest = { selectedEntryTitle = null }, + ) { paddingValues -> + Column( + Modifier.padding(paddingValues), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), ) { - if (showRepo) { - IconButton(onClick = { uriHandler.openUri(BuildConfig.COMMUNITY_REPO_URL) }) { - Icon(Icons.Default.Code, contentDescription = "Source code") + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider( + LocalContentColor provides accentColor, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon(), contentDescription = null) + Text(title, fontWeight = FontWeight.Bold) + } } - } - if (showIssues) { - IconButton(onClick = { uriHandler.openUri(BuildConfig.COMMUNITY_ISSUES_URL) }) { - Icon(Icons.Default.Feedback, contentDescription = "Issues") + FilledIconButton(onClick = { uriHandler.openUri(link) }) { + Icon(Icons.AutoMirrored.Default.OpenInNew, contentDescription = null) } } - } - CompositionLocalProvider( - LocalContentColor provides MaterialTheme.colorScheme.secondary, - ) { - if (showDiscord) { - IconButton(onClick = { uriHandler.openUri(BuildConfig.COMMUNITY_DISCORD_URL) }) { - Icon( - ImageVector.vectorResource(R.drawable.simple_icons_discord), - contentDescription = "Discord", - ) - } - } + OutlinedTextField( + value = link, + onValueChange = {}, + readOnly = true, + trailingIcon = { CopyLinkIconButton(entry) }, + ) + } + } + } - if (showQq) { - IconButton(onClick = { uriHandler.openUri(BuildConfig.COMMUNITY_QQ_URL) }) { - Icon( - ImageVector.vectorResource(R.drawable.simple_icons_tencentqq), - contentDescription = "QQ", - ) - } + Row( + modifier, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider( + LocalContentColor provides accentColor, + ) { + COMMUNITY_ENTRIES.forEach { + IconButton(onClick = { selectedEntryTitle = it.title }) { + Icon(it.icon(), contentDescription = "Community link (${it.title})") } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/about/SettingsAboutScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/about/SettingsAboutScreen.kt index c25c6765..b3d45a4b 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/about/SettingsAboutScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/about/SettingsAboutScreen.kt @@ -4,18 +4,18 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Balance -import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.DataObject import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -24,13 +24,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.graphics.drawable.toBitmap -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher -import org.koin.compose.viewmodel.koinViewModel import xyz.sevive.arcaeaoffline.BuildConfig import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.ui.SubScreenContainer -import xyz.sevive.arcaeaoffline.ui.components.OperationTextItem import xyz.sevive.arcaeaoffline.ui.navigation.SettingsSubScreen import xyz.sevive.arcaeaoffline.ui.screens.NavEntryNavigateButton import xyz.sevive.arcaeaoffline.ui.theme.spacing @@ -58,13 +54,6 @@ internal fun SettingsAboutScreen( onNavigateToLicenseScreen: () -> Unit, onNavigateToAboutlibrariesScreen: () -> Unit, ) { - val viewModel: SettingsAboutViewModel = koinViewModel() - val diagnosticsExportState by viewModel.diagnosticsExportState.collectAsStateWithLifecycle() - val diagnosticsDirPicker = - rememberDirectoryPickerLauncher { dir -> - dir?.let(viewModel::exportDiagnostics) - } - val appIconClickCrasherState = rememberAppIconClickCrasherState() val versionText = remember { "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" } @@ -82,60 +71,43 @@ internal fun SettingsAboutScreen( SubScreenContainer( title = stringResource(SettingsSubScreen.About.title), ) { - LazyColumn { - item { - Column( - Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppIcon( - Modifier - .clickable { appIconClickCrasherState.clicked() } - .size(100.dp), - ) - - Text(appNameText, style = MaterialTheme.typography.titleLarge) - Text(versionText, color = MaterialTheme.colorScheme.onSurfaceVariant) - - SettingsAboutCommunities() - } - } + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppIcon( + Modifier + .clickable { appIconClickCrasherState.clicked() } + .size(100.dp), + ) - item { - HorizontalDivider() - } + Text(appNameText, style = MaterialTheme.typography.titleLarge) + Text(versionText, color = MaterialTheme.colorScheme.onSurfaceVariant) - item { - NavEntryNavigateButton( - titleResId = R.string.open_source_license_notice, - descriptionResId = R.string.copyright_notice, - icon = Icons.Default.Balance, - ) { - onNavigateToLicenseScreen() - } + SettingsAboutCommunities() } - item { - NavEntryNavigateButton( - titleResId = SettingsSubScreen.Aboutlibraries.title, - icon = Icons.Default.DataObject, - ) { - onNavigateToAboutlibrariesScreen() - } - } + HorizontalDivider() - item { - HorizontalDivider() + NavEntryNavigateButton( + titleResId = R.string.open_source_license_notice, + descriptionResId = R.string.copyright_notice, + icon = Icons.Default.Balance, + ) { + onNavigateToLicenseScreen() } - item { - OperationTextItem( - title = stringResource(R.string.diagnostics_export), - state = diagnosticsExportState, - leadingIcon = Icons.Default.BugReport, - onClick = { diagnosticsDirPicker.launch() }, - ) + NavEntryNavigateButton( + titleResId = SettingsSubScreen.Aboutlibraries.title, + icon = Icons.Default.DataObject, + ) { + onNavigateToAboutlibrariesScreen() } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/about/SettingsAboutViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/about/SettingsAboutViewModel.kt deleted file mode 100644 index 5ea4b891..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/about/SettingsAboutViewModel.kt +++ /dev/null @@ -1,51 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.screens.settings.about - -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.github.vinceglb.filekit.PlatformFile -import io.github.vinceglb.filekit.name -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.getAndUpdate -import kotlinx.coroutines.launch -import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.helpers.diagnostics.DiagnosticsCollector -import xyz.sevive.arcaeaoffline.ui.components.OperationState - -class SettingsAboutViewModel( - context: Context, - private val diagnosticsCollector: DiagnosticsCollector, -) : ViewModel() { - private val appContext = context.applicationContext - - private val _diagnosticsExportState = MutableStateFlow(null) - val diagnosticsExportState: StateFlow = _diagnosticsExportState.asStateFlow() - - fun exportDiagnostics(outputDir: PlatformFile) { - // atomic check & set progress state - val previous = _diagnosticsExportState.getAndUpdate { OperationState.InProgress } - if (previous is OperationState.InProgress) return - - viewModelScope.launch { - try { - _diagnosticsExportState.value = - try { - val target = diagnosticsCollector.exportTo(outputDir) - OperationState.Success( - appContext.getString(R.string.diagnostics_generated, target.name), - ) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - OperationState.Failed(e.message) - } - } finally { - // reset progress if canceled mid-operation - _diagnosticsExportState.compareAndSet(OperationState.InProgress, null) - } - } - } -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/general/SettingsGeneralScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/general/SettingsGeneralScreen.kt deleted file mode 100644 index cf61694a..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/settings/general/SettingsGeneralScreen.kt +++ /dev/null @@ -1,131 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.screens.settings.general - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Undo -import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType -import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.core.api.ArcaeaResourcesApiClient -import xyz.sevive.arcaeaoffline.ui.SubScreenContainer -import xyz.sevive.arcaeaoffline.ui.components.SwitchItem -import xyz.sevive.arcaeaoffline.ui.components.TextItem -import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogConfirmButton -import xyz.sevive.arcaeaoffline.ui.components.dialogs.DialogDismissTextButton -import xyz.sevive.arcaeaoffline.ui.navigation.SettingsSubScreen -import xyz.sevive.arcaeaoffline.ui.screens.settings.SettingsViewModel - -@Composable -private fun ResourcesApiBaseUrlEditDialog( - initialValue: String, - onConfirm: (String) -> Unit, - onReset: () -> Unit, - onDismiss: () -> Unit, -) { - var value by rememberSaveable { mutableStateOf(initialValue) } - - val trimmed = value.trim() - val isValid = trimmed.startsWith("http://") || trimmed.startsWith("https://") - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.settings_general_pref_resources_api_base_url_dialog_title)) }, - text = { - TextField( - value = value, - onValueChange = { value = it }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - isError = value.isNotBlank() && !isValid, - supportingText = { - if (value.isNotBlank() && !isValid) { - Text(stringResource(R.string.settings_general_pref_resources_api_base_url_invalid)) - } else if (trimmed.startsWith("http://")) { - // Android 9+ silently blocks cleartext: an http URL is "valid" here, but - // every probe and download would fail with a generic network error. - Text(stringResource(R.string.settings_general_pref_resources_api_base_url_http_hint)) - } - }, - modifier = Modifier.fillMaxWidth(), - ) - }, - dismissButton = { - DialogDismissTextButton( - onClick = { - onReset() - onDismiss() - }, - customIcon = { Icon(Icons.AutoMirrored.Filled.Undo, contentDescription = null) }, - customLabel = { Text(stringResource(R.string.general_reset)) }, - ) - }, - confirmButton = { - DialogConfirmButton( - onClick = { - onConfirm(trimmed) - onDismiss() - }, - enabled = isValid, - customIcon = { Icon(Icons.Default.Edit, contentDescription = null) }, - customLabel = { Text(stringResource(R.string.general_save)) }, - ) - }, - ) -} - -@Composable -internal fun SettingsGeneralScreen( - uiState: SettingsViewModel.AppPreferencesUiState, - onSetAutoSendCrashReports: (Boolean) -> Unit, - onSetResourcesApiBaseUrl: (String) -> Unit, - modifier: Modifier = Modifier, -) { - var showBaseUrlEditDialog by rememberSaveable { mutableStateOf(false) } - if (showBaseUrlEditDialog) { - ResourcesApiBaseUrlEditDialog( - initialValue = uiState.resourcesApiBaseUrl, - onConfirm = onSetResourcesApiBaseUrl, - onReset = { onSetResourcesApiBaseUrl(ArcaeaResourcesApiClient.DEFAULT_BASE_URL) }, - onDismiss = { showBaseUrlEditDialog = false }, - ) - } - - SubScreenContainer( - title = stringResource(SettingsSubScreen.General.title), - ) { - LazyColumn(modifier) { - item { - SwitchItem( - value = uiState.autoSendCrashReports, - onValueChange = { onSetAutoSendCrashReports(it) }, - icon = Icons.Default.BugReport, - title = stringResource(R.string.settings_app_pref_enable_sentry), - ) - } - - item { - TextItem( - onClick = { showBaseUrlEditDialog = true }, - leadingIcon = Icons.Default.CloudDownload, - title = stringResource(R.string.settings_general_pref_resources_api_base_url), - content = uiState.resourcesApiBaseUrl, - ) - } - } - } -} diff --git a/app/src/main/res/drawable/simple_icons_github.xml b/app/src/main/res/drawable/simple_icons_github.xml new file mode 100644 index 00000000..13051992 --- /dev/null +++ b/app/src/main/res/drawable/simple_icons_github.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 759a6376..b696fac9 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -313,8 +313,9 @@ 基准测试失败: %s - 发送崩溃报告 + 自动发送崩溃报告 通用 + 调试 资源 API base URL 编辑资源 API base URL 必须以 http:// 或 https:// 开头 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 36b6f9e1..cdbee92b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -346,8 +346,9 @@ CC BY-SA 3.0 Benchmark failed: %s - Send crash reports + Send crash reports automatically General + Debug Resources API base URL Edit resources API base URL Must start with http:// or https:// diff --git a/build.gradle.kts b/build.gradle.kts index a6012150..af90ae57 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,5 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - alias(libs.plugins.secrets.gradle.plugin) apply false - alias(libs.plugins.android.application) apply false alias(libs.plugins.android.kotlin.multiplatform.library) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 92a83489..d5cb2d3b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -112,9 +112,9 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = " kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } -secrets-gradle-plugin = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version = "2.0.1" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } appVersioning = { id = "io.github.reactivecircus.app-versioning", version = "1.6.0" } +buildkonfig = { id = "com.codingfeline.buildkonfig", version = "0.21.0" } aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries" } aboutlibraries-android = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutlibraries" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 09547512..dfecbbd9 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,4 +1,7 @@ +import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.io.FileInputStream +import java.util.Properties plugins { alias(libs.plugins.android.kotlin.multiplatform.library) @@ -11,6 +14,38 @@ plugins { alias(libs.plugins.kotlin.plugin.serialization) alias(androidx.plugins.room3) + alias(libs.plugins.buildkonfig) +} + +// https://stackoverflow.com/a/69268957/16484891, CC BY-SA 4.0 +val localProperties = + Properties().apply { + FileInputStream(File(rootDir, "local.properties")).use(::load) + } + +val localDefaults = + Properties().apply { + FileInputStream(File(rootDir, "local.defaults.properties")).use(::load) + } + +buildkonfig { + packageName = "xyz.sevive.arcaeaoffline.shared" + exposeObjectWithName = "BuildKonfig" + + defaultConfigs { + val keys = + listOf( + "COMMUNITY_VALUE_PLACEHOLDER", + "COMMUNITY_REPO_URL", + "COMMUNITY_ISSUES_URL", + "COMMUNITY_DISCORD_URL", + "COMMUNITY_QQ_URL", + ) + for (key in keys) { + val value = localProperties.getProperty(key) ?: localDefaults.getProperty(key) + buildConfigField(STRING, key, value) + } + } } room3 {