From 26261adf4e9c2841aeb2d90dc33c32cf5aa4b00c Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sun, 16 Aug 2026 13:49:23 +0900 Subject: [PATCH 1/6] feat: release GF2logger 2.2.1 --- CHANGELOG.md | 25 ++ README.md | 3 +- README_KR.md | 3 +- TODO.md | 6 + app/build.gradle | 4 +- .../PlatoonBackupManagerIntegrationTest.kt | 4 +- app/src/main/AndroidManifest.xml | 4 + .../java/dev/gf2log/app/LocalizedActivity.kt | 18 +- .../main/java/dev/gf2log/app/MainActivity.kt | 13 +- .../dev/gf2log/app/MemberDetailActivity.kt | 2 +- .../dev/gf2log/app/MemberOrderActivity.kt | 2 +- app/src/main/java/dev/gf2log/app/ModernUi.kt | 87 ++++ .../java/dev/gf2log/app/OnboardingActivity.kt | 375 ++++++++++++++++++ .../dev/gf2log/app/OnboardingPreferences.kt | 14 + .../java/dev/gf2log/app/OptionsActivity.kt | 37 +- .../dev/gf2log/app/PacketHistoryActivity.kt | 7 +- .../java/dev/gf2log/app/ThemePreferences.kt | 37 ++ .../dev/gf2log/app/WeeklyReportActivity.kt | 33 +- .../gf2log/app/settings/AppBackupSettings.kt | 39 +- .../app/settings/UserSettingsPreferences.kt | 27 ++ app/src/main/res/drawable/bg_button.xml | 29 ++ app/src/main/res/drawable/bg_edit_text.xml | 17 + app/src/main/res/values-ko/strings.xml | 39 ++ app/src/main/res/values-night/colors.xml | 18 + app/src/main/res/values-night/styles.xml | 16 +- app/src/main/res/values/colors.xml | 18 + app/src/main/res/values/strings.xml | 39 ++ app/src/main/res/values/styles.xml | 47 ++- .../settings/AppBackupSettingsCodecTest.kt | 16 + build.gradle | 9 + docs/ARCHITECTURE.md | 18 + 31 files changed, 956 insertions(+), 50 deletions(-) create mode 100644 TODO.md create mode 100644 app/src/main/java/dev/gf2log/app/ModernUi.kt create mode 100644 app/src/main/java/dev/gf2log/app/OnboardingActivity.kt create mode 100644 app/src/main/java/dev/gf2log/app/OnboardingPreferences.kt create mode 100644 app/src/main/java/dev/gf2log/app/ThemePreferences.kt create mode 100644 app/src/main/res/drawable/bg_button.xml create mode 100644 app/src/main/res/drawable/bg_edit_text.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index f0faf86..e7bfa40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ All notable changes to mobileGF2logger are documented here. +## 2.2.1 - 2026-08-16 + +### Added + +- Add a five-page first-launch guide for Main, Settings, Platoon management, + weekly-table controls, and parsed-packet pages. The bilingual sliding + selector persists its language choice immediately, while Skip and Get + started permanently complete onboarding for that installation. +- Add System, Light, and Dark appearance choices to Settings and preserve the + selected theme in complete backups. + +### Changed + +- Refresh every Activity with shared modern surfaces, rounded controls, + accessible colors, and theme-aware packet and weekly-table cells. +- Extend complete-backup settings to schema v2 while restoring v1 backups with + safe theme and onboarding defaults. + +### Fixed + +- Explicitly resolve the build-only Kotlin Gradle Plugin to patched + `2.4.20-Beta2`, replacing the vulnerable `2.2.10` version pulled transitively + by Android Gradle Plugin 9.3.1 and giving Dependabot a direct manifest entry + it can update in the future. + ## 2.2.0 - 2026-08-11 ### Added diff --git a/README.md b/README.md index 27bdf23..73a8f88 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ capture to the selected game package, and raw traffic is never stored. - Can send a validated original CSV to an optional user-owned Discord incoming webhook after confirmation. - Stores the latest 100 parsed packets and up to 50 saved packets, with table and raw views, copy, export, selection, and deletion. - Supports member sorting, persistent drag ordering, snapshot comparison, single-week and all-week CSV export, and complete `.gf2backup` export/restore. -- Supports English and Korean and uses the Android device timezone for display. +- Guides first-time users through Main, Settings, Platoon management, weekly controls, and parsed-packet pages, with a persistent English/Korean selector and Skip action. +- Supports English and Korean, System/Light/Dark themes, and the Android device timezone for display. - Creates UTF-8 Platoon-member CSV files with this column order: ```text diff --git a/README_KR.md b/README_KR.md index ee97837..bc8e178 100644 --- a/README_KR.md +++ b/README_KR.md @@ -20,7 +20,8 @@ mobileGF2logger는 서클장을 위한 가벼운 비루팅 앱입니다. Android - 확인 후 검증된 원본 CSV를 사용자가 소유한 선택적 Discord 수신 웹훅으로 전송할 수 있습니다. - 최근 파싱 패킷 100개와 저장 패킷 50개를 보관하며, 표 및 원본 보기, 복사, 내보내기, 선택, 삭제를 지원합니다. - 멤버 정렬, 드래그 순서 유지, 최근 스냅샷 비교, 주간 CSV 내보내기, 서클 관리 데이터 백업 및 복원을 지원합니다. -- 영어와 한국어를 지원하며 화면에 표시하는 시각에는 Android 기기의 시간대를 사용합니다. +- 첫 사용 시 메인, 설정, 서클 관리, 주간 기능, 파싱 패킷 화면을 안내하며, 한국어/English 전환과 건너뛰기를 지원합니다. +- 영어와 한국어, 시스템/라이트/다크 테마를 지원하며 화면에 표시하는 시각에는 Android 기기의 시간대를 사용합니다. - 다음 열 순서의 UTF-8 서클 멤버 CSV 파일을 만듭니다. ```text diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..d4a992b --- /dev/null +++ b/TODO.md @@ -0,0 +1,6 @@ +# TODO + +## Future work + +- [x] Add a bilingual first-use onboarding guide with a bottom Skip action. + Implemented for v2.2.1. diff --git a/app/build.gradle b/app/build.gradle index 29d1e1f..726780c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -154,8 +154,8 @@ android { applicationId = 'dev.gf2log' minSdk = 26 targetSdk = 36 - versionCode = 20200 - versionName = '2.2.0' + versionCode = 20201 + versionName = '2.2.1' testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner' ndk { diff --git a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt index 7b478cf..a3d3e4b 100644 --- a/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt +++ b/app/src/androidTest/java/dev/gf2log/app/management/PlatoonBackupManagerIntegrationTest.kt @@ -651,6 +651,8 @@ class PlatoonBackupManagerIntegrationTest { dailyMerit: Long, ) = AppBackupSettings( language = language, + themeMode = "system", + onboardingCompleted = true, detailedNotifications = detailedNotifications, targetPackage = targetPackage, payloadHistory = PayloadCatalog.categories.associate { category -> @@ -882,4 +884,4 @@ class WeeklyReportActivityStateTest { target.delete() } } -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 033493e..b8ec918 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,6 +26,10 @@ + + diff --git a/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt b/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt index 2353237..c6be2c8 100644 --- a/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt +++ b/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt @@ -2,17 +2,31 @@ package dev.gf2log.app import android.app.Activity import android.content.Context +import android.os.Bundle abstract class LocalizedActivity : Activity() { private var attachedLanguage = LanguagePreferences.DEFAULT_LANGUAGE + private var attachedTheme = ThemePreferences.SYSTEM override fun attachBaseContext(newBase: Context) { attachedLanguage = LanguagePreferences.get(newBase) - super.attachBaseContext(LanguagePreferences.wrap(newBase, attachedLanguage)) + attachedTheme = ThemePreferences.get(newBase) + val localized = LanguagePreferences.wrap(newBase, attachedLanguage) + super.attachBaseContext(ThemePreferences.wrap(localized, attachedTheme)) } override fun onResume() { super.onResume() - if (LanguagePreferences.get(this) != attachedLanguage) recreate() + if ( + LanguagePreferences.get(this) != attachedLanguage || + ThemePreferences.get(this) != attachedTheme + ) { + recreate() + } + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + ModernUi.prepareContent(window.decorView) } } diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index eac9ace..ce35ae9 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -6,7 +6,6 @@ import android.app.AlertDialog import android.content.Intent import android.content.pm.PackageManager import android.database.Cursor -import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.net.Uri @@ -72,6 +71,11 @@ class MainActivity : LocalizedActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (!OnboardingPreferences.isCompleted(this)) { + startActivity(Intent(this, OnboardingActivity::class.java)) + finish() + return + } historyStore = CaptureHistoryStore( File(filesDir, CaptureHistoryStore.HISTORY_DIRECTORY), ) @@ -84,6 +88,7 @@ class MainActivity : LocalizedActivity() { override fun onResume() { super.onResume() + if (!::captureStatusText.isInitialized) return captureStatusText.text = CaptureStatus.read() refreshHistory() statusHandler.postDelayed(refreshStatus, STATUS_REFRESH_MILLIS) @@ -201,7 +206,7 @@ class MainActivity : LocalizedActivity() { addView(ImageButton(context).apply { setImageResource(R.drawable.ic_settings) contentDescription = getString(R.string.open_options) - setBackgroundColor(Color.TRANSPARENT) + useModernIconStyle() setPadding(dp(10), dp(10), dp(10), dp(10)) setOnClickListener { startActivity(Intent(this@MainActivity, OptionsActivity::class.java)) @@ -731,13 +736,13 @@ class MainActivity : LocalizedActivity() { val localizedTag = localizedPayloadTag(entry.payloadType) text = localizedTag textSize = 12f - setTextColor(Color.WHITE) + setTextColor(getColor(R.color.primary_action_foreground)) gravity = Gravity.CENTER setPadding(dp(8), dp(5), dp(8), dp(5)) background = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE cornerRadius = tagHeight / 2f - setColor(Color.rgb(49, 93, 168)) + setColor(getColor(R.color.primary_action_background)) } contentDescription = getString( R.string.payload_tag_description, diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index 445e786..c0e31db 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -199,7 +199,7 @@ class MemberDetailActivity : LocalizedActivity() { if (status.membershipPeriods.size == 1) { addView(TextView(context).apply { setText(R.string.membership_period_delete_last_hint) - setTextColor(getColor(android.R.color.darker_gray)) + setTextColor(getColor(R.color.text_secondary)) setPadding(0, dp(8), 0, 0) }, matchWidth()) } diff --git a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt index c3bd41e..da40f6e 100644 --- a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt @@ -159,7 +159,7 @@ class MemberOrderActivity : LocalizedActivity() { text = "\u2630" textSize = 25f gravity = Gravity.CENTER - setTextColor(Color.rgb(90, 94, 101)) + setTextColor(getColor(R.color.text_secondary)) contentDescription = getString(R.string.drag_member, member.name) setOnLongClickListener { performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) diff --git a/app/src/main/java/dev/gf2log/app/ModernUi.kt b/app/src/main/java/dev/gf2log/app/ModernUi.kt new file mode 100644 index 0000000..9bf4682 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ModernUi.kt @@ -0,0 +1,87 @@ +package dev.gf2log.app + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.GradientDrawable +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.CheckBox +import android.widget.EditText +import android.widget.ImageButton +import android.widget.RadioButton +import android.widget.ScrollView + +/** Shared presentation primitives for the modern, minimal UI surface. */ +object ModernUi { + fun prepareContent(root: View) { + root.setBackgroundColor(root.context.getColor(R.color.app_background)) + prepareScreenSurface(root) + applyControls(root) + } + + fun panelBackground(context: Context, emphasized: Boolean = false): GradientDrawable = + GradientDrawable().apply { + setColor(context.getColor(if (emphasized) R.color.primary_soft else R.color.surface)) + cornerRadius = context.dp(22).toFloat() + setStroke( + context.dp(1), + context.getColor(if (emphasized) R.color.primary else R.color.outline), + ) + } + + private fun applyControls(view: View) { + when (view) { + is ScrollView -> view.isFillViewport = true + is Button -> { + view.isAllCaps = false + view.elevation = 0f + view.stateListAnimator = null + } + is EditText -> view.elevation = 0f + is ImageButton -> { + view.useModernIconStyle() + } + is CheckBox -> view.buttonTintList = controlTint(view.context) + is RadioButton -> view.buttonTintList = controlTint(view.context) + } + if (view is ViewGroup) { + repeat(view.childCount) { index -> applyControls(view.getChildAt(index)) } + } + } + + private fun prepareScreenSurface(root: View) { + val content = root.findViewById(android.R.id.content) ?: return + val screen = content.getChildAt(0) ?: return + screen.setBackgroundColor(screen.context.getColor(R.color.app_background)) + if (screen !is ScrollView || screen.childCount == 0) return + screen.isFillViewport = true + screen.clipToPadding = false + screen.setPadding(screen.context.dp(8), screen.context.dp(8), screen.context.dp(8), screen.context.dp(8)) + screen.getChildAt(0).apply { + background = panelBackground(context) + elevation = context.dp(1).toFloat() + } + } + + private fun controlTint(context: Context) = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(android.R.attr.state_checked), + intArrayOf(), + ), + intArrayOf( + context.getColor(R.color.outline), + context.getColor(R.color.primary), + context.getColor(R.color.outline_strong), + ), + ) +} + +internal fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + +internal fun ImageButton.useModernIconStyle() { + background = ModernUi.panelBackground(context, emphasized = true) + imageTintList = ColorStateList.valueOf(context.getColor(R.color.primary)) + elevation = 0f +} diff --git a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt new file mode 100644 index 0000000..5c3379d --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt @@ -0,0 +1,375 @@ +package dev.gf2log.app + +import android.content.Context +import android.content.Intent +import android.graphics.Typeface +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes + +/** One-time, bilingual walkthrough of the app's five user-facing workflows. */ +class OnboardingActivity : LocalizedActivity() { + private lateinit var pageHost: FrameLayout + private lateinit var stepLabel: TextView + private lateinit var backButton: Button + private lateinit var nextButton: Button + private lateinit var skipButton: Button + private var pageIndex = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + pageIndex = savedInstanceState?.getInt(STATE_PAGE_INDEX) + ?.coerceIn(PAGES.indices) + ?: 0 + setContentView(buildContent()) + renderPage(animate = false) + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putInt(STATE_PAGE_INDEX, pageIndex) + super.onSaveInstanceState(outState) + } + + private fun buildContent(): View = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(20), dp(16), dp(20), dp(16)) + + addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(TextView(context).apply { + text = getString(R.string.app_name) + textSize = 22f + setTypeface(typeface, Typeface.BOLD) + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + addView( + OnboardingLanguageToggle(context, LanguagePreferences.get(context)) { language -> + if (LanguagePreferences.get(this@OnboardingActivity) != language) { + LanguagePreferences.set(this@OnboardingActivity, language) + recreate() + } + }, + LinearLayout.LayoutParams(dp(184), dp(44)), + ) + }, matchWidth()) + + addView(TextView(context).apply { + text = getString(R.string.onboarding_welcome) + textSize = 28f + setTypeface(typeface, Typeface.BOLD) + setPadding(0, dp(24), 0, dp(4)) + }, matchWidth()) + addView(TextView(context).apply { + text = getString(R.string.onboarding_intro) + textSize = 15f + setTextColor(getColor(R.color.text_secondary)) + setPadding(0, 0, 0, dp(16)) + }, matchWidth()) + + pageHost = FrameLayout(context) + addView(pageHost, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, + 1f, + )) + + stepLabel = TextView(context).apply { + gravity = Gravity.CENTER + textSize = 13f + setTextColor(getColor(R.color.text_secondary)) + setPadding(0, dp(12), 0, dp(8)) + } + addView(stepLabel, matchWidth()) + + addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + backButton = Button(context).apply { + text = getString(R.string.onboarding_back) + setOnClickListener { + if (pageIndex > 0) { + pageIndex-- + renderPage(animate = true, direction = -1) + } + } + } + addView(backButton, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { + marginEnd = dp(6) + }) + nextButton = Button(context).apply { + usePrimaryActionStyle() + setOnClickListener { + if (pageIndex == PAGES.lastIndex) { + finishOnboarding() + } else { + pageIndex++ + renderPage(animate = true, direction = 1) + } + } + } + addView(nextButton, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { + marginStart = dp(6) + }) + }, matchWidth()) + + skipButton = Button(context).apply { + text = getString(R.string.onboarding_skip) + contentDescription = getString(R.string.onboarding_skip) + setOnClickListener { finishOnboarding() } + } + addView(skipButton, matchWidth()) + } + + private fun renderPage(animate: Boolean, direction: Int = 1) { + val content = pageView(PAGES[pageIndex]) + pageHost.removeAllViews() + pageHost.addView(content, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + )) + if (animate) { + content.alpha = 0f + content.translationX = (direction * dp(24)).toFloat() + content.animate().alpha(1f).translationX(0f).setDuration(180L).start() + } + stepLabel.text = getString(R.string.onboarding_step, pageIndex + 1, PAGES.size) + backButton.isEnabled = pageIndex > 0 + nextButton.text = getString( + if (pageIndex == PAGES.lastIndex) R.string.onboarding_finish else R.string.onboarding_next, + ) + skipButton.visibility = if (pageIndex == PAGES.lastIndex) View.INVISIBLE else View.VISIBLE + } + + private fun pageView(page: OnboardingPage): View = ScrollView(this).apply { + isFillViewport = true + clipToPadding = false + addView(LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + setPadding(dp(22), dp(22), dp(22), dp(22)) + background = ModernUi.panelBackground(context) + elevation = dp(2).toFloat() + + addView(FrameLayout(context).apply { + background = ModernUi.panelBackground(context, emphasized = true) + addView(ImageView(context).apply { + setImageResource(page.icon) + imageTintList = if (page.icon == R.mipmap.ic_launcher) null else + android.content.res.ColorStateList.valueOf(getColor(R.color.primary)) + scaleType = ImageView.ScaleType.CENTER_INSIDE + setPadding(dp(16), dp(16), dp(16), dp(16)) + }, FrameLayout.LayoutParams(dp(78), dp(78), Gravity.CENTER)) + }, LinearLayout.LayoutParams(dp(92), dp(92)).apply { + bottomMargin = dp(18) + }) + addView(TextView(context).apply { + text = getString(page.title) + textSize = 25f + gravity = Gravity.CENTER + setTypeface(typeface, Typeface.BOLD) + }, matchWidth()) + addView(TextView(context).apply { + text = getString(page.description) + textSize = 15f + gravity = Gravity.CENTER + setTextColor(getColor(R.color.text_secondary)) + setPadding(0, dp(8), 0, dp(18)) + }, matchWidth()) + page.features.forEach { feature -> addView(featureRow(feature), matchWidth()) } + page.warning?.let { warning -> + addView(TextView(context).apply { + text = getString(warning) + textSize = 13f + setTextColor(getColor(R.color.warning_text)) + setPadding(dp(14), dp(12), dp(14), dp(12)) + background = ModernUi.panelBackground(context, emphasized = true).apply { + setColor(getColor(R.color.warning_surface)) + setStroke(dp(1), getColor(R.color.warning_text)) + } + }, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(8) }) + } + }, matchWidth()) + } + + private fun featureRow(@StringRes feature: Int): View = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.TOP + setPadding(0, dp(7), 0, dp(7)) + addView(TextView(context).apply { + text = "✓" + textSize = 17f + setTypeface(typeface, Typeface.BOLD) + setTextColor(getColor(R.color.primary)) + gravity = Gravity.CENTER + }, LinearLayout.LayoutParams(dp(28), ViewGroup.LayoutParams.WRAP_CONTENT)) + addView(TextView(context).apply { + text = getString(feature) + textSize = 14f + setTextColor(getColor(R.color.text_primary)) + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + } + + private fun finishOnboarding() { + OnboardingPreferences.complete(this) + startActivity(Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)) + finish() + } + + private fun matchWidth() = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + + private data class OnboardingPage( + @StringRes val title: Int, + @StringRes val description: Int, + val features: List, + @DrawableRes val icon: Int, + @StringRes val warning: Int? = null, + ) + + companion object { + private const val STATE_PAGE_INDEX = "page_index" + private val PAGES = listOf( + OnboardingPage( + R.string.onboarding_main_title, + R.string.onboarding_main_description, + listOf( + R.string.onboarding_main_feature_1, + R.string.onboarding_main_feature_2, + R.string.onboarding_main_feature_3, + ), + R.mipmap.ic_launcher, + R.string.onboarding_main_warning, + ), + OnboardingPage( + R.string.onboarding_settings_title, + R.string.onboarding_settings_description, + listOf( + R.string.onboarding_settings_feature_1, + R.string.onboarding_settings_feature_2, + R.string.onboarding_settings_feature_3, + ), + R.drawable.ic_settings, + ), + OnboardingPage( + R.string.onboarding_management_title, + R.string.onboarding_management_description, + listOf( + R.string.onboarding_management_feature_1, + R.string.onboarding_management_feature_2, + R.string.onboarding_management_feature_3, + ), + R.drawable.ic_edit, + ), + OnboardingPage( + R.string.onboarding_weekly_title, + R.string.onboarding_weekly_description, + listOf( + R.string.onboarding_weekly_feature_1, + R.string.onboarding_weekly_feature_2, + R.string.onboarding_weekly_feature_3, + ), + R.drawable.ic_share, + ), + OnboardingPage( + R.string.onboarding_packet_title, + R.string.onboarding_packet_description, + listOf( + R.string.onboarding_packet_feature_1, + R.string.onboarding_packet_feature_2, + R.string.onboarding_packet_feature_3, + ), + R.drawable.ic_save, + ), + ) + } +} + +/** Two-label segmented control whose selection thumb slides between English and Korean. */ +private class OnboardingLanguageToggle( + context: Context, + initialLanguage: String, + private val onLanguageChanged: (String) -> Unit, +) : FrameLayout(context) { + private val thumb = View(context) + private val english = label("English") { select(LanguagePreferences.DEFAULT_LANGUAGE, true) } + private val korean = label("한국어") { select(LanguagePreferences.KOREAN, true) } + private var selectedLanguage = initialLanguage + + init { + setPadding(dp(3), dp(3), dp(3), dp(3)) + background = ModernUi.panelBackground(context) + contentDescription = context.getString(R.string.onboarding_language_toggle) + thumb.background = ModernUi.panelBackground(context, emphasized = true).apply { + setColor(context.getColor(R.color.primary)) + setStroke(0, context.getColor(R.color.primary)) + } + addView(thumb) + addView(english) + addView(korean) + post { select(selectedLanguage, false) } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = resolveSize(dp(184), widthMeasureSpec) + val height = resolveSize(dp(44), heightMeasureSpec) + setMeasuredDimension(width, height) + val childWidth = (width - paddingLeft - paddingRight) / 2 + val childHeight = height - paddingTop - paddingBottom + val widthSpec = MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY) + val heightSpec = MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY) + repeat(childCount) { index -> getChildAt(index).measure(widthSpec, heightSpec) } + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + val width = right - left - paddingLeft - paddingRight + val height = bottom - top - paddingTop - paddingBottom + val half = width / 2 + thumb.layout(paddingLeft, paddingTop, paddingLeft + half, paddingTop + height) + english.layout(paddingLeft, paddingTop, paddingLeft + half, paddingTop + height) + korean.layout(paddingLeft + half, paddingTop, paddingLeft + width, paddingTop + height) + thumb.translationX = if (selectedLanguage == LanguagePreferences.KOREAN) half.toFloat() else 0f + } + + private fun label(textValue: String, action: () -> Unit) = TextView(context).apply { + text = textValue + gravity = Gravity.CENTER + textSize = 13f + setTypeface(typeface, Typeface.BOLD) + setOnClickListener { action() } + } + + private fun select(language: String, animate: Boolean) { + selectedLanguage = language + val distance = ((width - paddingLeft - paddingRight) / 2).toFloat() + val target = if (language == LanguagePreferences.KOREAN) distance else 0f + if (animate) { + thumb.animate().cancel() + thumb.animate() + .translationX(target) + .setDuration(180L) + .withEndAction { onLanguageChanged(language) } + .start() + } else { + thumb.translationX = target + } + val selectedColor = context.getColor(R.color.primary_action_foreground) + val idleColor = context.getColor(R.color.text_primary) + english.setTextColor(if (language == LanguagePreferences.DEFAULT_LANGUAGE) selectedColor else idleColor) + korean.setTextColor(if (language == LanguagePreferences.KOREAN) selectedColor else idleColor) + } + + private fun dp(value: Int): Int = context.dp(value) +} diff --git a/app/src/main/java/dev/gf2log/app/OnboardingPreferences.kt b/app/src/main/java/dev/gf2log/app/OnboardingPreferences.kt new file mode 100644 index 0000000..ca98ac3 --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/OnboardingPreferences.kt @@ -0,0 +1,14 @@ +package dev.gf2log.app + +import android.content.Context +import dev.gf2log.app.settings.UserSettingsPreferences + +/** Persists the one-time onboarding completion gate in app-private settings. */ +object OnboardingPreferences { + fun isCompleted(context: Context): Boolean = + UserSettingsPreferences.onboardingCompleted(context) + + fun complete(context: Context) { + UserSettingsPreferences.setOnboardingCompleted(context, true) + } +} diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 2250b59..18d852e 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.content.Intent import android.content.res.ColorStateList import android.database.Cursor -import android.graphics.Color import android.graphics.Typeface import android.net.Uri import android.graphics.drawable.GradientDrawable @@ -125,6 +124,34 @@ class OptionsActivity : LocalizedActivity() { }) }, matchWidth()) + addView(TextView(context).apply { + text = getString(R.string.appearance) + textSize = 20f + setTypeface(typeface, Typeface.BOLD) + setPadding(0, spacing, 0, dp(4)) + }, matchWidth()) + addView(TextView(context).apply { + text = getString(R.string.theme_description) + textSize = 14f + setTextColor(getColor(R.color.text_secondary)) + setPadding(0, 0, 0, dp(6)) + }, matchWidth()) + addView(RadioGroup(context).apply { + orientation = RadioGroup.HORIZONTAL + val current = ThemePreferences.get(context) + listOf( + ThemePreferences.SYSTEM to R.string.theme_system, + ThemePreferences.LIGHT to R.string.theme_light, + ThemePreferences.DARK to R.string.theme_dark, + ).forEach { (mode, label) -> + addView(RadioButton(context).apply { + text = getString(label) + isChecked = current == mode + setOnClickListener { changeTheme(mode) } + }) + } + }, matchWidth()) + addView(TextView(context).apply { text = getString(R.string.backup) textSize = 20f @@ -274,7 +301,7 @@ class OptionsActivity : LocalizedActivity() { if (category.isRequired) { buttonTintList = ColorStateList( arrayOf(intArrayOf(-android.R.attr.state_enabled), intArrayOf()), - intArrayOf(Color.rgb(49, 93, 168), Color.rgb(49, 93, 168)), + intArrayOf(getColor(R.color.primary), getColor(R.color.primary)), ) } setOnCheckedChangeListener { _, enabled -> @@ -402,6 +429,12 @@ class OptionsActivity : LocalizedActivity() { recreate() } + private fun changeTheme(mode: String) { + if (ThemePreferences.get(this) == mode) return + ThemePreferences.set(this, mode) + recreate() + } + private fun payloadName(payloadType: Int): String = getString( when (payloadType) { Gfl2PayloadDecoder.TYPE_GUILD_MEMBERS -> R.string.payload_name_platoon_members diff --git a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt index 657e7eb..24072bf 100644 --- a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt @@ -5,7 +5,6 @@ import android.content.Intent import android.content.res.ColorStateList import android.content.ClipData import android.content.ClipboardManager -import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.os.Bundle @@ -237,14 +236,14 @@ class PacketHistoryActivity : LocalizedActivity() { text = value textSize = if (header) 14f else 13f gravity = Gravity.CENTER_VERTICAL - setTextColor(Color.rgb(28, 32, 38)) + setTextColor(getColor(R.color.text_primary)) if (header) setTypeface(typeface, Typeface.BOLD) val horizontal = (10 * resources.displayMetrics.density).toInt() val vertical = (8 * resources.displayMetrics.density).toInt() setPadding(horizontal, vertical, horizontal, vertical) background = GradientDrawable().apply { - setColor(if (header) Color.rgb(225, 231, 241) else Color.WHITE) - setStroke(1, Color.rgb(160, 170, 185)) + setColor(getColor(if (header) R.color.table_header else R.color.table_cell)) + setStroke(1, getColor(R.color.outline_strong)) } }, TableRow.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, diff --git a/app/src/main/java/dev/gf2log/app/ThemePreferences.kt b/app/src/main/java/dev/gf2log/app/ThemePreferences.kt new file mode 100644 index 0000000..1bd068a --- /dev/null +++ b/app/src/main/java/dev/gf2log/app/ThemePreferences.kt @@ -0,0 +1,37 @@ +package dev.gf2log.app + +import android.content.Context +import android.content.res.Configuration +import dev.gf2log.app.settings.UserSettingsPreferences + +/** Owns the persisted light, dark, or system display choice. */ +object ThemePreferences { + const val SYSTEM = "system" + const val LIGHT = "light" + const val DARK = "dark" + + fun get(context: Context): String = UserSettingsPreferences.themeMode(context) + .takeIf { it in SUPPORTED } + ?: SYSTEM + + fun set(context: Context, mode: String) { + require(mode in SUPPORTED) + UserSettingsPreferences.setThemeMode(context, mode) + } + + fun wrap(context: Context, mode: String = get(context)): Context { + if (mode == SYSTEM) return context + val configuration = Configuration(context.resources.configuration) + val requestedNightMode = if (mode == DARK) { + Configuration.UI_MODE_NIGHT_YES + } else { + Configuration.UI_MODE_NIGHT_NO + } + configuration.uiMode = + (configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or + requestedNightMode + return context.createConfigurationContext(configuration) + } + + private val SUPPORTED = setOf(SYSTEM, LIGHT, DARK) +} diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 8a5124a..5a58b1a 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -273,7 +273,7 @@ class WeeklyReportActivity : LocalizedActivity() { }, LinearLayout.LayoutParams(0, wrap(), 1f)) addView(ImageButton(context).apply { setImageResource(if (isEditing) R.drawable.ic_save else R.drawable.ic_edit) - setBackgroundColor(Color.TRANSPARENT) + useModernIconStyle() contentDescription = getString( if (isEditing) R.string.save_weekly_edits else R.string.edit_weekly_table, ) @@ -288,7 +288,7 @@ class WeeklyReportActivity : LocalizedActivity() { }, LinearLayout.LayoutParams(dp(48), dp(48))) addView(ImageButton(context).apply { setImageResource(R.drawable.ic_share) - setBackgroundColor(Color.TRANSPARENT) + useModernIconStyle() contentDescription = getString(R.string.share_weekly_table) setPadding(dp(10), dp(10), dp(10), dp(10)) isEnabled = !isEditing && report.members.isNotEmpty() @@ -299,7 +299,7 @@ class WeeklyReportActivity : LocalizedActivity() { }, LinearLayout.LayoutParams(dp(48), dp(48))) addView(ImageButton(context).apply { setImageResource(R.drawable.ic_settings) - setBackgroundColor(Color.TRANSPARENT) + useModernIconStyle() contentDescription = getString(R.string.weekly_table_settings) setPadding(dp(10), dp(10), dp(10), dp(10)) isEnabled = !isEditing @@ -970,14 +970,14 @@ class WeeklyReportActivity : LocalizedActivity() { text = value gravity = Gravity.CENTER this.textSize = textSize + setTextColor(getColor(R.color.text_primary)) if (header) { - setTextColor(Color.rgb(28, 32, 38)) setTypeface(typeface, Typeface.BOLD) } setPadding(dp(4), dp(2), dp(4), dp(2)) background = GradientDrawable().apply { - setColor(if (header) Color.rgb(210, 222, 241) else Color.TRANSPARENT) - setStroke(1, GRID_COLOR) + setColor(if (header) getColor(R.color.table_header) else Color.TRANSPARENT) + setStroke(1, getColor(R.color.outline_strong)) } layoutParams = LinearLayout.LayoutParams(dp(width), dp(height)) onClick?.let { @@ -1008,11 +1008,15 @@ class WeeklyReportActivity : LocalizedActivity() { ) } textSize = 14f - setTextColor(if (health.isComplete) Color.rgb(35, 105, 62) else WARNING_COLOR) + setTextColor(getColor(if (health.isComplete) R.color.success_text else R.color.warning_text)) setPadding(dp(12), dp(10), dp(12), dp(10)) background = GradientDrawable().apply { - setColor(if (health.isComplete) Color.rgb(231, 246, 236) else Color.rgb(255, 247, 222)) - cornerRadius = dp(8).toFloat() + setColor(getColor(if (health.isComplete) R.color.success_surface else R.color.warning_surface)) + cornerRadius = dp(14).toFloat() + setStroke( + dp(1), + getColor(if (health.isComplete) R.color.success_text else R.color.warning_text), + ) } }, matchWidth()) } @@ -1669,13 +1673,13 @@ class WeeklyReportActivity : LocalizedActivity() { private fun gridBackground() = GradientDrawable().apply { setColor(Color.TRANSPARENT) - setStroke(1, GRID_COLOR) + setStroke(1, getColor(R.color.outline_strong)) } private fun editableFieldBackground() = GradientDrawable().apply { - setColor(EDITABLE_FIELD_COLOR) - setStroke(dp(1), EDITABLE_FIELD_BORDER_COLOR) - cornerRadius = dp(3).toFloat() + setColor(getColor(R.color.surface_variant)) + setStroke(dp(1), getColor(R.color.primary)) + cornerRadius = dp(8).toFloat() } companion object { @@ -1692,9 +1696,6 @@ class WeeklyReportActivity : LocalizedActivity() { private const val MEMBER_WIDTH = 120 private const val DAILY_WIDTH = 128 private const val MAX_VISIBLE_TABLE_ROWS = 6 - private val GRID_COLOR = Color.rgb(112, 118, 128) - private val EDITABLE_FIELD_COLOR = Color.rgb(47, 58, 72) - private val EDITABLE_FIELD_BORDER_COLOR = Color.rgb(126, 164, 218) private val WARNING_COLOR = Color.rgb(255, 193, 7) private val SUCCESS_GREEN = Color.rgb(45, 170, 75) private val FAILURE_RED = Color.rgb(215, 60, 55) diff --git a/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt b/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt index 4796334..9d84cd6 100644 --- a/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt +++ b/app/src/main/java/dev/gf2log/app/settings/AppBackupSettings.kt @@ -8,6 +8,8 @@ import java.util.Properties data class AppBackupSettings( val language: String, + val themeMode: String, + val onboardingCompleted: Boolean, val detailedNotifications: Boolean, val targetPackage: String, val payloadHistory: Map, @@ -16,10 +18,13 @@ data class AppBackupSettings( ) object AppBackupSettingsCodec { - private const val SCHEMA_VERSION = 1 + private const val SCHEMA_VERSION = 2 + private const val LEGACY_SCHEMA_VERSION = 1 private const val NONE = "none" private const val KEY_SCHEMA_VERSION = "schemaVersion" private const val KEY_LANGUAGE = "language" + private const val KEY_THEME_MODE = "themeMode" + private const val KEY_ONBOARDING_COMPLETED = "onboardingCompleted" private const val KEY_DETAILED_NOTIFICATIONS = "detailedNotifications" private const val KEY_TARGET_PACKAGE = "targetPackage" private const val KEY_MEMBER_ORDER = "memberOrder" @@ -37,6 +42,8 @@ object AppBackupSettingsCodec { val properties = Properties().apply { setProperty(KEY_SCHEMA_VERSION, SCHEMA_VERSION.toString()) setProperty(KEY_LANGUAGE, settings.language) + setProperty(KEY_THEME_MODE, settings.themeMode) + setProperty(KEY_ONBOARDING_COMPLETED, settings.onboardingCompleted.toString()) setProperty(KEY_DETAILED_NOTIFICATIONS, settings.detailedNotifications.toString()) setProperty(KEY_TARGET_PACKAGE, settings.targetPackage) setProperty(KEY_MEMBER_ORDER, settings.memberOrder.joinToString(",")) @@ -61,14 +68,25 @@ object AppBackupSettingsCodec { val properties = StrictProperties("Backup settings").apply { ByteArrayInputStream(bytes).use(::load) } - require(properties.stringPropertyNames() == expectedKeys()) { - "Backup settings are incomplete or contain unknown fields" - } - require(properties.required(KEY_SCHEMA_VERSION) == SCHEMA_VERSION.toString()) { + val schemaVersion = properties.required(KEY_SCHEMA_VERSION).toIntOrNull() + require(schemaVersion == LEGACY_SCHEMA_VERSION || schemaVersion == SCHEMA_VERSION) { "Unsupported settings schema" } + require(properties.stringPropertyNames() == expectedKeys(schemaVersion)) { + "Backup settings are incomplete or contain unknown fields" + } val settings = AppBackupSettings( language = properties.required(KEY_LANGUAGE), + themeMode = if (schemaVersion == SCHEMA_VERSION) { + properties.required(KEY_THEME_MODE) + } else { + "system" + }, + onboardingCompleted = if (schemaVersion == SCHEMA_VERSION) { + properties.strictBoolean(KEY_ONBOARDING_COMPLETED) + } else { + true + }, detailedNotifications = properties.strictBoolean(KEY_DETAILED_NOTIFICATIONS), targetPackage = properties.required(KEY_TARGET_PACKAGE), payloadHistory = PayloadCatalog.categories.associate { category -> @@ -92,6 +110,9 @@ object AppBackupSettingsCodec { private fun validate(settings: AppBackupSettings) { require(settings.language in setOf("en", "ko")) { "Unsupported display language" } + require(settings.themeMode in setOf("system", "light", "dark")) { + "Unsupported display theme" + } require( settings.targetPackage.length in 3..255 && PACKAGE_NAME.matches(settings.targetPackage), @@ -119,8 +140,9 @@ object AppBackupSettingsCodec { } } - private fun expectedKeys(): Set = BASE_KEYS + - PayloadCatalog.categories.map { payloadKey(it.payloadType) } + private fun expectedKeys(schemaVersion: Int): Set = + (if (schemaVersion == SCHEMA_VERSION) BASE_KEYS else LEGACY_BASE_KEYS) + + PayloadCatalog.categories.map { payloadKey(it.payloadType) } private fun payloadKey(payloadType: Int) = "payloadHistory.$payloadType" @@ -172,6 +194,8 @@ object AppBackupSettingsCodec { private val BASE_KEYS = setOf( KEY_SCHEMA_VERSION, KEY_LANGUAGE, + KEY_THEME_MODE, + KEY_ONBOARDING_COMPLETED, KEY_DETAILED_NOTIFICATIONS, KEY_TARGET_PACKAGE, KEY_MEMBER_ORDER, @@ -184,4 +208,5 @@ object AppBackupSettingsCodec { KEY_WEEKLY_LOGIN_DAYS, KEY_WEEKLY_PATROL_DAYS, ) + private val LEGACY_BASE_KEYS = BASE_KEYS - setOf(KEY_THEME_MODE, KEY_ONBOARDING_COMPLETED) } diff --git a/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt b/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt index 415c46e..1b91b50 100644 --- a/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt @@ -11,6 +11,8 @@ internal object UserSettingsPreferences { private const val SCHEMA_VERSION = 1 private const val KEY_SCHEMA_VERSION = "schema_version" private const val KEY_LANGUAGE = "language" + private const val KEY_THEME_MODE = "theme_mode" + private const val KEY_ONBOARDING_COMPLETED = "onboarding_completed" private const val KEY_DETAILED_NOTIFICATIONS = "detailed_notifications" private const val KEY_TARGET_PACKAGE = "target_package" private const val KEY_MEMBER_ORDER = "member_order" @@ -47,6 +49,25 @@ internal object UserSettingsPreferences { putString(KEY_LANGUAGE, language) } + fun themeMode(context: Context): String = synchronized(lock) { + preferencesLocked(context.applicationContext) + .getString(KEY_THEME_MODE, "system") + .orEmpty() + } + + fun setThemeMode(context: Context, mode: String) = edit(context) { + putString(KEY_THEME_MODE, mode) + } + + fun onboardingCompleted(context: Context): Boolean = synchronized(lock) { + preferencesLocked(context.applicationContext) + .getBoolean(KEY_ONBOARDING_COMPLETED, false) + } + + fun setOnboardingCompleted(context: Context, completed: Boolean) = edit(context) { + putBoolean(KEY_ONBOARDING_COMPLETED, completed) + } + fun detailedNotifications(context: Context): Boolean = synchronized(lock) { preferencesLocked(context.applicationContext) .getBoolean(KEY_DETAILED_NOTIFICATIONS, true) @@ -120,6 +141,8 @@ internal object UserSettingsPreferences { private fun readLocked(preferences: SharedPreferences) = AppBackupSettings( language = preferences.getString(KEY_LANGUAGE, LanguagePreferences.DEFAULT_LANGUAGE) .orEmpty(), + themeMode = preferences.getString(KEY_THEME_MODE, "system").orEmpty(), + onboardingCompleted = preferences.getBoolean(KEY_ONBOARDING_COMPLETED, false), detailedNotifications = preferences.getBoolean(KEY_DETAILED_NOTIFICATIONS, true), targetPackage = preferences.getString( KEY_TARGET_PACKAGE, @@ -161,6 +184,8 @@ internal object UserSettingsPreferences { ?: TargetPackagePreferences.DEFAULT_TARGET_PACKAGE return AppBackupSettings( language = language, + themeMode = "system", + onboardingCompleted = false, detailedNotifications = detailedNotifications, targetPackage = targetPackage, payloadHistory = PayloadCatalog.categories.associate { category -> @@ -234,6 +259,8 @@ internal object UserSettingsPreferences { ): SharedPreferences.Editor = editor .putInt(KEY_SCHEMA_VERSION, SCHEMA_VERSION) .putString(KEY_LANGUAGE, settings.language) + .putString(KEY_THEME_MODE, settings.themeMode) + .putBoolean(KEY_ONBOARDING_COMPLETED, settings.onboardingCompleted) .putBoolean(KEY_DETAILED_NOTIFICATIONS, settings.detailedNotifications) .putString(KEY_TARGET_PACKAGE, settings.targetPackage) .putString(KEY_MEMBER_ORDER, settings.memberOrder.joinToString(",")) diff --git a/app/src/main/res/drawable/bg_button.xml b/app/src/main/res/drawable/bg_button.xml new file mode 100644 index 0000000..0d0acf6 --- /dev/null +++ b/app/src/main/res/drawable/bg_button.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_edit_text.xml b/app/src/main/res/drawable/bg_edit_text.xml new file mode 100644 index 0000000..41a0ff9 --- /dev/null +++ b/app/src/main/res/drawable/bg_edit_text.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 8b2aef9..84e7740 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -21,6 +21,45 @@ 언어 English 한국어 + 화면 테마 + 시스템 설정 + 라이트 + 다크 + 기기 설정을 따르거나 GF2logger를 라이트 또는 다크 모드로 고정합니다. + 건너뛰기 + 이전 + 다음 + 시작하기 + %2$d개 중 %1$d + 온보딩 언어 변경 + GF2logger 빠른 안내 + 캡처와 서클 관리에 필요한 화면을 다섯 페이지로 간단히 안내합니다. + 메인 화면 + 선택한 GF2 앱에 대해 사용자가 승인한 캡처를 시작하고, 필요한 패킷 수집 현황을 한눈에 확인합니다. + 캡처 준비는 사용자가 중지할 때까지 계속 실행됩니다. + 안내형 캡처는 멤버, 활동, 가입·탈퇴 패킷을 확인한 뒤 자동으로 중지합니다. + 최근 및 저장한 패킷 기록은 이 기기의 앱 전용 공간에 보관됩니다. + Android의 VPN 동의 화면이 표시됩니다. GF2logger는 선택한 앱만 관찰하며, TLS를 해독하거나 원본 네트워크 패킷을 저장하지 않습니다. + 설정 + 화면을 편하게 조정하고, 기기에 저장하거나 외부로 보내는 정보를 직접 관리합니다. + 한국어 또는 English와 시스템·라이트·다크 테마를 선택합니다. + 중요한 변경 전 전체 백업을 만들거나 기존 백업을 복원합니다. + Discord 웹훅과 선택 패킷 기록은 사용자가 명시적으로 설정할 때만 사용됩니다. + 서클 관리 + 캡처하거나 가져온 멤버 데이터가 하나의 구조화된 데이터베이스를 통해 멤버 정보와 주간 표에 반영됩니다. + 활동 및 탈퇴 멤버, 메모, 별명과 가입·탈퇴 이력을 검색하고 관리합니다. + CSV를 적용하기 전에 변경 내용을 미리 보고, 자동 체크포인트로 최근 가져오기를 되돌릴 수 있습니다. + 스냅샷을 비교하거나 선택한 멤버만 내보낼 수 있습니다. + 주간 테이블 + 일반 주간과 흙먼지 주간을 확정값, 하한값, 미확정값으로 구분하여 확인합니다. + 모든 셀을 눌러 값의 근거와 사용된 패킷 정보를 확인할 수 있습니다. + 편집은 수동 보정을 추가하고, 공유는 공개 범위를 정한 PNG를 만듭니다. + 커트라인 설정은 선택한 기준보다 낮을 가능성이 있는 결과를 강조합니다. + 파싱된 패킷 화면 + 기록을 열어 제한된 표 또는 디코딩된 CSV 전체 내용을 확인합니다. + 원본 CSV 보기는 디코딩된 전체 내용을 표시하고, 복사는 클립보드에 저장합니다. + 원본 CSV 전송은 확인 후 설정된 Discord 웹훅으로만 전송합니다. + 패킷 종류와 시각을 통해 멤버 및 주간 셀의 근거를 찾을 수 있습니다. 백업 백업 불러오기 전체 설정, 서클 관리와 멤버별 가입 기록을 포함한 상세 정보, 모든 주간 과업표를 전체 백업 파일에서 불러옵니다. diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 546afd4..28c0418 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -1,5 +1,23 @@ + #0F131A + #171C25 + #222936 + #2D3646 + #EEF2F8 + #AEB8C7 + #3D4758 + #69778C + #86AFFF + #223454 + #2B426B + #293A55 + #171C25 + #1F3A2A + #9DDBB3 + #3B3120 + #F2D28C + #4086AFFF #86AFFF #111214 #FF8AA4 diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index 3e579ca..f90c749 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -1,10 +1,18 @@ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 25f34c4..e610a11 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,6 +1,24 @@ #111214 + #F5F7FB + #FFFFFF + #EDF1F7 + #E1E7F0 + #172033 + #5E687A + #C9D1DD + #8994A6 + #315DA8 + #E8EEFB + #D8E3F8 + #DDE7F7 + #FFFFFF + #E7F6EC + #23693E + #FFF5D8 + #8A6200 + #24315DA8 #315DA8 #FFFFFF #C43D5C diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a500594..98431e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,6 +21,45 @@ Language English Korean + Appearance + System + Light + Dark + Use the device theme, or keep GF2logger in light or dark mode. + Skip + Back + Next + Get started + %1$d of %2$d + Change onboarding language + A compact guide to GF2logger + Five quick pages explain every screen you need for capture and Platoon management. + Main page + Start a consent-based capture for the selected GF2 package and see useful payload progress at a glance. + Prepare capture keeps running until you stop it. + Guided capture checks roster, activity, and membership updates, then stops automatically. + Recent and saved packet history stays private on this device. + Android will show its VPN consent screen. GF2logger only observes the selected app, does not decrypt TLS, and never stores raw network packets. + Settings + Keep the app comfortable and control exactly what is retained or sent outside the device. + Choose English or Korean and System, Light, or Dark appearance. + Create or restore complete backups before major changes. + Optional Discord webhook and packet categories remain under your control. + Platoon management + Captured or imported roster evidence updates member records and weekly reports through one structured database. + Search active and withdrawn members, notes, nicknames, and membership history. + Preview CSV imports before commit and use the automatic checkpoint to undo the latest import. + Compare snapshots or export only the members you select. + Weekly table + Review Standard and Gunsmoke weeks with evidence-aware exact, lower-bound, and unknown values. + Tap any cell to see why it has that value and which evidence supports it. + Edit adds a manual correction; Share creates a privacy-controlled PNG. + Cut-off settings highlight results that may remain below your selected targets. + Parsed packet pages + Open a history entry to inspect its bounded table or the complete decoded CSV text. + Show raw CSV reveals the original decoded content; Copy places it on the clipboard. + Send original CSV uses your configured Discord webhook only after confirmation. + Payload type and time labels help you connect evidence to members and weekly cells. Backup Restore backup Restore all app settings, Platoon management and member details including membership history, and every weekly table from a complete backup. diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 76cc987..6ce942e 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,10 +1,49 @@ + + + + + + diff --git a/app/src/test/java/dev/gf2log/app/settings/AppBackupSettingsCodecTest.kt b/app/src/test/java/dev/gf2log/app/settings/AppBackupSettingsCodecTest.kt index e8b4a06..7bc9cee 100644 --- a/app/src/test/java/dev/gf2log/app/settings/AppBackupSettingsCodecTest.kt +++ b/app/src/test/java/dev/gf2log/app/settings/AppBackupSettingsCodecTest.kt @@ -18,6 +18,20 @@ class AppBackupSettingsCodecTest { assertEquals(settings, restored) } + @Test + fun `restores legacy settings with safe display defaults`() { + val legacy = encodedProperties().apply { + setProperty("schemaVersion", "1") + remove("themeMode") + remove("onboardingCompleted") + } + + val restored = AppBackupSettingsCodec.decode(legacy.toBytes()) + + assertEquals("system", restored.themeMode) + assertEquals(true, restored.onboardingCompleted) + } + @Test fun `rejects a missing required setting`() { val properties = encodedProperties() @@ -91,6 +105,8 @@ class AppBackupSettingsCodecTest { private fun completeSettings() = AppBackupSettings( language = "ko", + themeMode = "dark", + onboardingCompleted = true, detailedNotifications = false, targetPackage = "com.example.game_client", payloadHistory = PayloadCatalog.categories.associate { category -> diff --git a/build.gradle b/build.gradle index 02884c8..9e47928 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,12 @@ +// AGP 9.3.1 otherwise contributes vulnerable Kotlin Gradle Plugin 2.2.10 only +// as a transitive build dependency. Declaring the patched plugin explicitly +// gives Gradle and Dependabot a concrete root constraint to resolve and track. +buildscript { + dependencies { + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.20-Beta2' + } +} + plugins { id 'com.android.application' version '9.3.1' apply false id 'com.android.library' version '9.3.1' apply false diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6665300..1c6c563 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -163,6 +163,24 @@ plaintext TCP streams. Do not add pinning or anti-cheat bypasses. - Activities own Android presentation and delegate evidence precedence, inference, ordering, and CSV construction to pure policy objects. +## Display and first-launch boundary + +`LocalizedActivity` applies both locale and System, Light, or Dark appearance +through a configuration-wrapped base context before an Activity creates its +views. `ModernUi` supplies shared presentation primitives only; it does not own +capture, parsing, persistence, or report policy. Theme-specific resources keep +packet tables, weekly grids, controls, and status surfaces readable without +duplicating domain logic in Activities. + +`MainActivity` routes an installation with no completed first-use flag to the +non-exported `OnboardingActivity`. The five-page guide may be finished or +skipped, and writes completion only at that explicit exit. Its English/Korean +segmented control persists the same language preference used by Settings. +Complete backup settings schema v2 includes language, theme, and onboarding +completion; schema-v1 backups remain accepted with System theme and completed +onboarding defaults so an experienced restoring user is not trapped in the +guide. + The design deliberately favors composition over deep inheritance. Abstraction and polymorphism appear at real variation points (`GameData`, `ParseEvent`, and the native listener contract); encapsulation is provided by stores and the From ccc79e5bc9853b9d04a4a890f0578f24cc5c2f0a Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sun, 16 Aug 2026 14:22:17 +0900 Subject: [PATCH 2/6] fix: resolve v2.2.1 release review --- CHANGELOG.md | 9 ++- .../UserSettingsPreferencesIntegrationTest.kt | 70 +++++++++++++++++++ app/src/main/java/dev/gf2log/app/ModernUi.kt | 28 +++++--- .../java/dev/gf2log/app/OnboardingActivity.kt | 24 +++---- .../app/settings/UserSettingsPreferences.kt | 16 ++++- app/src/main/res/drawable/bg_button.xml | 8 +-- app/src/main/res/drawable/bg_edit_text.xml | 6 +- app/src/main/res/values-night/colors.xml | 32 ++++----- app/src/main/res/values-night/styles.xml | 2 +- app/src/main/res/values/colors.xml | 26 +++---- app/src/main/res/values/styles.xml | 26 +++---- build.gradle | 5 +- 12 files changed, 173 insertions(+), 79 deletions(-) create mode 100644 app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index e7bfa40..c0261d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,15 +15,18 @@ All notable changes to mobileGF2logger are documented here. ### Changed -- Refresh every Activity with shared modern surfaces, rounded controls, - accessible colors, and theme-aware packet and weekly-table cells. +- Refresh every Activity with a compact, black-and-white-first interface, + restrained accent color, flatter geometry, accessible touch targets, and + theme-aware packet and weekly-table cells. +- Preserve existing unified settings while migrating upgraded v2.2.0 installs + past the first-use guide; fresh installs still receive onboarding once. - Extend complete-backup settings to schema v2 while restoring v1 backups with safe theme and onboarding defaults. ### Fixed - Explicitly resolve the build-only Kotlin Gradle Plugin to patched - `2.4.20-Beta2`, replacing the vulnerable `2.2.10` version pulled transitively + `2.4.20-Beta1`, replacing the vulnerable `2.2.10` version pulled transitively by Android Gradle Plugin 9.3.1 and giving Dependabot a direct manifest entry it can update in the future. diff --git a/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt b/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt new file mode 100644 index 0000000..4ba9463 --- /dev/null +++ b/app/src/androidTest/java/dev/gf2log/app/settings/UserSettingsPreferencesIntegrationTest.kt @@ -0,0 +1,70 @@ +package dev.gf2log.app.settings + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class UserSettingsPreferencesIntegrationTest { + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + clearSettings() + } + + @After + fun tearDown() { + clearSettings() + } + + @Test + fun freshInstallKeepsOnboardingPending() { + assertFalse(UserSettingsPreferences.onboardingCompleted(context)) + } + + @Test + fun unifiedV1UpgradePreservesSettingsAndCompletesOnboarding() { + context.getSharedPreferences("user_settings", Context.MODE_PRIVATE).edit() + .putInt("schema_version", 1) + .putString("language", "ko") + .putBoolean("detailed_notifications", false) + .putString("target_package", "com.example.gf2") + .commit() + + val settings = UserSettingsPreferences.read(context) + + assertEquals("ko", settings.language) + assertEquals("com.example.gf2", settings.targetPackage) + assertFalse(settings.detailedNotifications) + assertEquals("system", settings.themeMode) + assertTrue(settings.onboardingCompleted) + assertEquals( + 2, + context.getSharedPreferences("user_settings", Context.MODE_PRIVATE) + .getInt("schema_version", 0), + ) + } + + private fun clearSettings() { + listOf( + "user_settings", + "display_settings", + "capture_preferences", + "payload_history_options", + "weekly_member_order", + "weekly_cutlines", + ".app.MainActivity", + ).forEach { name -> + context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit() + } + } +} diff --git a/app/src/main/java/dev/gf2log/app/ModernUi.kt b/app/src/main/java/dev/gf2log/app/ModernUi.kt index 9bf4682..9ec1ee3 100644 --- a/app/src/main/java/dev/gf2log/app/ModernUi.kt +++ b/app/src/main/java/dev/gf2log/app/ModernUi.kt @@ -3,6 +3,7 @@ package dev.gf2log.app import android.content.Context import android.content.res.ColorStateList import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.InsetDrawable import android.view.View import android.view.ViewGroup import android.widget.Button @@ -23,10 +24,10 @@ object ModernUi { fun panelBackground(context: Context, emphasized: Boolean = false): GradientDrawable = GradientDrawable().apply { setColor(context.getColor(if (emphasized) R.color.primary_soft else R.color.surface)) - cornerRadius = context.dp(22).toFloat() + cornerRadius = context.dp(10).toFloat() setStroke( context.dp(1), - context.getColor(if (emphasized) R.color.primary else R.color.outline), + context.getColor(if (emphasized) R.color.outline_strong else R.color.outline), ) } @@ -37,6 +38,15 @@ object ModernUi { view.isAllCaps = false view.elevation = 0f view.stateListAnimator = null + view.background?.let { drawable -> + view.background = InsetDrawable( + drawable, + view.context.dp(3), + view.context.dp(7), + view.context.dp(3), + view.context.dp(7), + ) + } } is EditText -> view.elevation = 0f is ImageButton -> { @@ -54,13 +64,9 @@ object ModernUi { val content = root.findViewById(android.R.id.content) ?: return val screen = content.getChildAt(0) ?: return screen.setBackgroundColor(screen.context.getColor(R.color.app_background)) - if (screen !is ScrollView || screen.childCount == 0) return - screen.isFillViewport = true - screen.clipToPadding = false - screen.setPadding(screen.context.dp(8), screen.context.dp(8), screen.context.dp(8), screen.context.dp(8)) - screen.getChildAt(0).apply { - background = panelBackground(context) - elevation = context.dp(1).toFloat() + if (screen is ScrollView) { + screen.isFillViewport = true + screen.clipToPadding = false } } @@ -72,7 +78,7 @@ object ModernUi { ), intArrayOf( context.getColor(R.color.outline), - context.getColor(R.color.primary), + context.getColor(R.color.accent), context.getColor(R.color.outline_strong), ), ) @@ -81,7 +87,7 @@ object ModernUi { internal fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() internal fun ImageButton.useModernIconStyle() { - background = ModernUi.panelBackground(context, emphasized = true) + background = InsetDrawable(ModernUi.panelBackground(context), context.dp(6)) imageTintList = ColorStateList.valueOf(context.getColor(R.color.primary)) elevation = 0f } diff --git a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt index 5c3379d..90af2cd 100644 --- a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt @@ -58,13 +58,13 @@ class OnboardingActivity : LocalizedActivity() { recreate() } }, - LinearLayout.LayoutParams(dp(184), dp(44)), + LinearLayout.LayoutParams(dp(148), dp(40)), ) }, matchWidth()) addView(TextView(context).apply { text = getString(R.string.onboarding_welcome) - textSize = 28f + textSize = 26f setTypeface(typeface, Typeface.BOLD) setPadding(0, dp(24), 0, dp(4)) }, matchWidth()) @@ -154,9 +154,9 @@ class OnboardingActivity : LocalizedActivity() { addView(LinearLayout(context).apply { orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER_HORIZONTAL - setPadding(dp(22), dp(22), dp(22), dp(22)) + setPadding(dp(18), dp(18), dp(18), dp(18)) background = ModernUi.panelBackground(context) - elevation = dp(2).toFloat() + elevation = 0f addView(FrameLayout(context).apply { background = ModernUi.panelBackground(context, emphasized = true) @@ -165,14 +165,14 @@ class OnboardingActivity : LocalizedActivity() { imageTintList = if (page.icon == R.mipmap.ic_launcher) null else android.content.res.ColorStateList.valueOf(getColor(R.color.primary)) scaleType = ImageView.ScaleType.CENTER_INSIDE - setPadding(dp(16), dp(16), dp(16), dp(16)) - }, FrameLayout.LayoutParams(dp(78), dp(78), Gravity.CENTER)) - }, LinearLayout.LayoutParams(dp(92), dp(92)).apply { - bottomMargin = dp(18) + setPadding(dp(13), dp(13), dp(13), dp(13)) + }, FrameLayout.LayoutParams(dp(62), dp(62), Gravity.CENTER)) + }, LinearLayout.LayoutParams(dp(72), dp(72)).apply { + bottomMargin = dp(16) }) addView(TextView(context).apply { text = getString(page.title) - textSize = 25f + textSize = 23f gravity = Gravity.CENTER setTypeface(typeface, Typeface.BOLD) }, matchWidth()) @@ -210,7 +210,7 @@ class OnboardingActivity : LocalizedActivity() { text = "✓" textSize = 17f setTypeface(typeface, Typeface.BOLD) - setTextColor(getColor(R.color.primary)) + setTextColor(getColor(R.color.accent)) gravity = Gravity.CENTER }, LinearLayout.LayoutParams(dp(28), ViewGroup.LayoutParams.WRAP_CONTENT)) addView(TextView(context).apply { @@ -323,8 +323,8 @@ private class OnboardingLanguageToggle( } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val width = resolveSize(dp(184), widthMeasureSpec) - val height = resolveSize(dp(44), heightMeasureSpec) + val width = resolveSize(dp(148), widthMeasureSpec) + val height = resolveSize(dp(40), heightMeasureSpec) setMeasuredDimension(width, height) val childWidth = (width - paddingLeft - paddingRight) / 2 val childHeight = height - paddingTop - paddingBottom diff --git a/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt b/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt index 1b91b50..e86f186 100644 --- a/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt +++ b/app/src/main/java/dev/gf2log/app/settings/UserSettingsPreferences.kt @@ -8,7 +8,8 @@ import dev.gf2log.protocol.PayloadCatalog internal object UserSettingsPreferences { private const val PREFERENCES = "user_settings" - private const val SCHEMA_VERSION = 1 + private const val SCHEMA_VERSION = 2 + private const val LEGACY_UNIFIED_SCHEMA_VERSION = 1 private const val KEY_SCHEMA_VERSION = "schema_version" private const val KEY_LANGUAGE = "language" private const val KEY_THEME_MODE = "theme_mode" @@ -130,8 +131,17 @@ internal object UserSettingsPreferences { private fun preferencesLocked(context: Context): SharedPreferences { val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) - if (preferences.getInt(KEY_SCHEMA_VERSION, 0) == SCHEMA_VERSION) return preferences - val migrated = readLegacy(context) + val storedVersion = preferences.getInt(KEY_SCHEMA_VERSION, 0) + if (storedVersion == SCHEMA_VERSION) return preferences + val migrated = if (storedVersion == LEGACY_UNIFIED_SCHEMA_VERSION) { + // v2.2.0 already used the unified store. Preserve it and keep the + // new-user walkthrough exclusive to genuinely fresh installs. + readLocked(preferences).copy( + onboardingCompleted = preferences.getBoolean(KEY_ONBOARDING_COMPLETED, true), + ) + } else { + readLegacy(context) + } check(writeSettings(preferences.edit().clear(), migrated).commit()) { "Unable to migrate app settings" } diff --git a/app/src/main/res/drawable/bg_button.xml b/app/src/main/res/drawable/bg_button.xml index 0d0acf6..fb16577 100644 --- a/app/src/main/res/drawable/bg_button.xml +++ b/app/src/main/res/drawable/bg_button.xml @@ -5,22 +5,22 @@ - + - + - - + + diff --git a/app/src/main/res/drawable/bg_edit_text.xml b/app/src/main/res/drawable/bg_edit_text.xml index 41a0ff9..5e3e9ac 100644 --- a/app/src/main/res/drawable/bg_edit_text.xml +++ b/app/src/main/res/drawable/bg_edit_text.xml @@ -2,14 +2,14 @@ - + - + - + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 28c0418..07fd045 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -1,25 +1,25 @@ - #0F131A - #171C25 - #222936 - #2D3646 - #EEF2F8 - #AEB8C7 - #3D4758 - #69778C - #86AFFF - #223454 - #2B426B - #293A55 - #171C25 + #0B0B0C + #121214 + #1D1D20 + #29292D + #F5F5F2 + #AAA9A4 + #343438 + #77777C + #F5F5F2 + #1D1D20 + #6F8FFF + #202023 + #121214 #1F3A2A #9DDBB3 #3B3120 #F2D28C - #4086AFFF - #86AFFF - #111214 + #40F5F5F2 + #F5F5F2 + #0B0B0C #FF8AA4 #E18484 #111214 diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index f90c749..52f293a 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -3,7 +3,7 @@ diff --git a/build.gradle b/build.gradle index 9e47928..6df953a 100644 --- a/build.gradle +++ b/build.gradle @@ -2,8 +2,11 @@ // as a transitive build dependency. Declaring the patched plugin explicitly // gives Gradle and Dependabot a concrete root constraint to resolve and track. buildscript { + repositories { + mavenCentral() + } dependencies { - classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.20-Beta2' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.20-Beta1' } } From f6d80980c8d6601d76f9232fd88d08dc5f205750 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sun, 16 Aug 2026 14:35:36 +0900 Subject: [PATCH 3/6] ci: isolate CodeQL Kotlin compatibility build --- .github/codeql/codeql-kotlin-compat.init.gradle | 14 ++++++++++++++ .github/workflows/codeql.yml | 9 +++++++++ 2 files changed, 23 insertions(+) create mode 100644 .github/codeql/codeql-kotlin-compat.init.gradle diff --git a/.github/codeql/codeql-kotlin-compat.init.gradle b/.github/codeql/codeql-kotlin-compat.init.gradle new file mode 100644 index 0000000..5e7a357 --- /dev/null +++ b/.github/codeql/codeql-kotlin-compat.init.gradle @@ -0,0 +1,14 @@ +// CodeQL 2.26.x can analyze Kotlin 2.4.0 through 2.4.19, while the production +// build must use 2.4.20-Beta1 or newer to address GHSA-r937-wjx7-w2jp. +// This init script is used only by the isolated, cache-disabled CodeQL compile. +gradle.beforeProject { project -> + project.buildscript.configurations.configureEach { configuration -> + configuration.resolutionStrategy.eachDependency { details -> + if (details.requested.group == 'org.jetbrains.kotlin' && + details.requested.name == 'kotlin-gradle-plugin') { + details.useVersion('2.4.10') + details.because('CodeQL 2.26.x supports Kotlin versions below 2.4.20') + } + } + } +} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index da23826..aea2bd7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -99,10 +99,19 @@ jobs: queries: security-extended - name: Compile Kotlin sources + env: + # Keep the scanner-only compatibility compiler isolated from every + # persistent Gradle cache. Production builds continue to use the + # patched 2.4.20-Beta1 plugin declared in build.gradle. + GRADLE_USER_HOME: ${{ runner.temp }}/codeql-gradle-home run: > ./gradlew :app:compileDebugKotlin :protocol:compileDebugKotlin + --init-script .github/codeql/codeql-kotlin-compat.init.gradle + --rerun-tasks + --no-build-cache + --no-configuration-cache --no-daemon --stacktrace From d103cba05aa5faace220472ef36b2987882b97d2 Mon Sep 17 00:00:00 2001 From: Alex Han Date: Sun, 16 Aug 2026 16:37:49 +0900 Subject: [PATCH 4/6] refactor: polish v2.2.1 interface and resilience --- .../java/dev/gf2log/app/LocalizedActivity.kt | 6 + .../main/java/dev/gf2log/app/MainActivity.kt | 221 ++++++----- .../dev/gf2log/app/MemberDetailActivity.kt | 84 ++-- .../dev/gf2log/app/MemberOrderActivity.kt | 23 +- app/src/main/java/dev/gf2log/app/ModernUi.kt | 365 ++++++++++++++++-- .../java/dev/gf2log/app/OnboardingActivity.kt | 112 +++--- .../java/dev/gf2log/app/OptionsActivity.kt | 112 +++--- .../dev/gf2log/app/PacketHistoryActivity.kt | 89 +++-- .../java/dev/gf2log/app/PlatoonActivity.kt | 43 ++- .../java/dev/gf2log/app/PrimaryActionStyle.kt | 34 +- .../gf2log/app/SnapshotComparisonActivity.kt | 6 +- .../dev/gf2log/app/WeeklyReportActivity.kt | 116 +++--- .../dev/gf2log/app/WeeklySettingsActivity.kt | 14 +- .../gf2log/app/capture/CaptureVpnService.kt | 4 +- app/src/main/res/drawable/bg_button.xml | 6 +- app/src/main/res/drawable/bg_edit_text.xml | 4 +- app/src/main/res/drawable/ic_calendar.xml | 10 + .../main/res/drawable/ic_chevron_right.xml | 14 + app/src/main/res/drawable/ic_drag_handle.xml | 13 + app/src/main/res/drawable/ic_group.xml | 10 + app/src/main/res/values-ko/strings.xml | 17 +- app/src/main/res/values-night-v27/styles.xml | 6 + app/src/main/res/values-night/colors.xml | 65 ++-- app/src/main/res/values-night/styles.xml | 2 +- app/src/main/res/values-v27/styles.xml | 6 + app/src/main/res/values/colors.xml | 65 ++-- app/src/main/res/values/ids.xml | 5 + app/src/main/res/values/strings.xml | 17 +- app/src/main/res/values/styles.xml | 20 +- docs/ARCHITECTURE.md | 6 + docs/UI_DESIGN_SYSTEM.md | 70 ++++ .../dev/gf2log/protocol/GuildMembersCsv.kt | 5 +- 32 files changed, 1081 insertions(+), 489 deletions(-) create mode 100644 app/src/main/res/drawable/ic_calendar.xml create mode 100644 app/src/main/res/drawable/ic_chevron_right.xml create mode 100644 app/src/main/res/drawable/ic_drag_handle.xml create mode 100644 app/src/main/res/drawable/ic_group.xml create mode 100644 app/src/main/res/values-night-v27/styles.xml create mode 100644 app/src/main/res/values-v27/styles.xml create mode 100644 app/src/main/res/values/ids.xml create mode 100644 docs/UI_DESIGN_SYSTEM.md diff --git a/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt b/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt index c6be2c8..2de01a3 100644 --- a/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt +++ b/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt @@ -28,5 +28,11 @@ abstract class LocalizedActivity : Activity() { override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) ModernUi.prepareContent(window.decorView) + // Android restores parts of the view hierarchy after onPostCreate during + // locale/theme recreation. Reapply presentation roles on the next frame so + // restored platform drawables cannot replace the app's semantic controls. + window.decorView.post { + if (!isFinishing) ModernUi.prepareContent(window.decorView) + } } } diff --git a/app/src/main/java/dev/gf2log/app/MainActivity.kt b/app/src/main/java/dev/gf2log/app/MainActivity.kt index ce35ae9..31c5b1d 100644 --- a/app/src/main/java/dev/gf2log/app/MainActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt @@ -5,6 +5,7 @@ import android.app.Activity import android.app.AlertDialog import android.content.Intent import android.content.pm.PackageManager +import android.content.res.ColorStateList import android.database.Cursor import android.graphics.Typeface import android.graphics.drawable.GradientDrawable @@ -190,17 +191,23 @@ class MainActivity : LocalizedActivity() { } private fun buildContentView(): ScrollView { - val spacing = (16 * resources.displayMetrics.density).toInt() + val spacing = dp(16) val container = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL - setPadding(spacing, spacing, spacing, spacing) + setPadding(spacing, dp(12), spacing, dp(24)) addView(LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL + addView(android.widget.ImageView(context).apply { + setImageResource(R.mipmap.ic_launcher) + scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE + }, LinearLayout.LayoutParams(dp(40), dp(40)).apply { + marginEnd = dp(10) + }) addView(TextView(context).apply { text = getString(R.string.app_name) - textSize = 28f + textSize = 23f setTypeface(typeface, Typeface.BOLD) }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) addView(ImageButton(context).apply { @@ -215,126 +222,167 @@ class MainActivity : LocalizedActivity() { }, matchWidth()) addView(TextView(context).apply { text = getString(R.string.app_description) - textSize = 16f - setPadding(0, spacing / 2, 0, spacing) - }) - - packageNameInput = EditText(context).apply { - hint = getString(R.string.target_package_hint) - setSingleLine(true) - setText(TargetPackagePreferences.get(context)) - } - addView(packageNameInput, matchWidth()) - - addView(Button(context).apply { - text = getString(R.string.prepare_capture) - usePrimaryActionStyle() - setOnClickListener { requestVpnAndStart(captureOnce = false) } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.capture_one_roster) - usePrimaryActionStyle() - setOnClickListener { requestVpnAndStart(captureOnce = true) } - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.guided_capture_description) textSize = 13f - setPadding(dp(16), 0, 0, dp(8)) + setTextColor(getColor(R.color.text_secondary)) + setPadding(0, dp(4), 0, dp(14)) }, matchWidth()) - guidedCaptureText = TextView(context).apply { - visibility = View.GONE - textSize = 14f - setPadding(dp(16), 0, 0, dp(8)) - } - addView(guidedCaptureText, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.stop_capture) - useDestructiveActionStyle() - setOnClickListener { stopCaptureService() } + + addView(LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(14), dp(12), dp(14), dp(12)) + background = ModernUi.panelBackground(context) + addView(sectionLabel(getString(R.string.capture_workspace), topPadding = 0), matchWidth()) + packageNameInput = EditText(context).apply { + hint = getString(R.string.target_package_hint) + setSingleLine(true) + textSize = 14f + setText(TargetPackagePreferences.get(context)) + } + addView(packageNameInput, matchWidth()) + addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + addView(Button(context).apply { + text = getString(R.string.prepare_capture) + useCaptureActionStyle() + allowCompactMultilineLabel() + setOnClickListener { requestVpnAndStart(captureOnce = false) } + }, LinearLayout.LayoutParams(0, dp(48), 1f).apply { marginEnd = dp(3) }) + addView(Button(context).apply { + text = getString(R.string.capture_one_roster) + usePrimaryActionStyle() + allowCompactMultilineLabel() + setOnClickListener { requestVpnAndStart(captureOnce = true) } + }, LinearLayout.LayoutParams(0, dp(48), 1f).apply { + marginStart = dp(3) + marginEnd = dp(3) + }) + addView(Button(context).apply { + text = getString(R.string.stop_capture) + useDestructiveActionStyle() + setOnClickListener { stopCaptureService() } + }, LinearLayout.LayoutParams(0, dp(48), 1f).apply { marginStart = dp(3) }) + }, matchWidth()) + captureStatusText = TextView(context).apply { + text = CaptureStatus.read() + textSize = 14f + setTextColor(getColor(R.color.success_text)) + setTypeface(typeface, Typeface.BOLD) + setPadding(dp(4), dp(8), dp(4), 0) + } + addView(captureStatusText, matchWidth()) + guidedCaptureText = TextView(context).apply { + visibility = View.GONE + textSize = 13f + setTextColor(getColor(R.color.text_secondary)) + setPadding(dp(4), dp(4), dp(4), 0) + } + addView(guidedCaptureText, matchWidth()) }, matchWidth()) - captureStatusText = TextView(context).apply { - text = CaptureStatus.read() - textSize = 15f - setPadding(0, spacing, 0, 0) - } - addView(captureStatusText, matchWidth()) statusText = TextView(context).apply { - textSize = 14f + textSize = 13f + setTextColor(getColor(R.color.text_secondary)) + setPadding(dp(4), dp(6), dp(4), 0) } addView(statusText, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.open_platoon_management) - usePrimaryActionStyle() - setOnClickListener { - startActivity(Intent(this@MainActivity, PlatoonActivity::class.java)) - } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.weekly_table) - usePrimaryActionStyle() - setOnClickListener { - startActivity(Intent(this@MainActivity, WeeklyReportActivity::class.java)) - } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.import_platoon_csv) - setOnClickListener { selectPlatoonCsvFiles() } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.export_platoon_backup) - setOnClickListener { exportPlatoonBackup() } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.undo_last_csv_import) - setOnClickListener { confirmUndoLastCsvImport() } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.import_platoon_backup) - setOnClickListener { confirmImportPlatoonBackup() } - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.recent_packets, CaptureHistoryStore.MAX_ENTRIES) - textSize = 20f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, spacing, 0, spacing / 2) - }, matchWidth()) + addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + addView(featureShortcut( + title = getString(R.string.platoon_management), + detail = getString(R.string.platoon_shortcut_detail), + icon = R.drawable.ic_group, + ) { startActivity(Intent(this@MainActivity, PlatoonActivity::class.java)) }, + LinearLayout.LayoutParams(0, dp(76), 1f).apply { marginEnd = dp(5) }) + addView(featureShortcut( + title = getString(R.string.weekly_table), + detail = getString(R.string.weekly_shortcut_detail), + icon = R.drawable.ic_calendar, + ) { startActivity(Intent(this@MainActivity, WeeklyReportActivity::class.java)) }, + LinearLayout.LayoutParams(0, dp(76), 1f).apply { marginStart = dp(5) }) + }, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(10) }) + + addView(sectionLabel(getString(R.string.data_tools)), matchWidth()) + listOf( + ModernUi.actionRow(context, getString(R.string.import_platoon_csv)) { + selectPlatoonCsvFiles() + }, + ModernUi.actionRow(context, getString(R.string.export_platoon_backup)) { + exportPlatoonBackup() + }, + ModernUi.actionRow(context, getString(R.string.undo_last_csv_import)) { + confirmUndoLastCsvImport() + }, + ModernUi.actionRow(context, getString(R.string.import_platoon_backup)) { + confirmImportPlatoonBackup() + }, + ).forEach { row -> + addView(row, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { bottomMargin = dp(5) }) + } + + addView(sectionLabel(getString(R.string.recent_packets, CaptureHistoryStore.MAX_ENTRIES)), matchWidth()) historyContainer = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } addView(historyContainer, matchWidth()) addView(Button(context).apply { text = getString(R.string.delete_selected_history) + useDestructiveTextActionStyle() setOnClickListener { deleteSelectedHistory() } }, matchWidth()) addView(Button(context).apply { text = getString(R.string.save_selected_history) + useSecondaryActionStyle() setOnClickListener { saveSelectedHistory() } }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.saved_packets, SavedHistoryStore.MAX_ENTRIES) - textSize = 20f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, spacing, 0, spacing / 2) - }, matchWidth()) + addView(sectionLabel(getString(R.string.saved_packets, SavedHistoryStore.MAX_ENTRIES)), matchWidth()) savedHistoryContainer = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } addView(savedHistoryContainer, matchWidth()) addView(Button(context).apply { text = getString(R.string.delete_selected_saved_history) + useDestructiveTextActionStyle() setOnClickListener { deleteSelectedSavedHistory() } }, matchWidth()) addView(Button(context).apply { text = getString(R.string.export_latest_platoon_csv) + useSecondaryActionStyle() setOnClickListener { exportLatestPlatoonCsv() } }, matchWidth()) } return ScrollView(this).apply { addView(container, matchWidth()) } } + private fun sectionLabel(textValue: CharSequence, topPadding: Int = dp(18)) = TextView(this).apply { + text = textValue + textSize = 15f + setTypeface(typeface, Typeface.BOLD) + setPadding(dp(2), topPadding, 0, dp(7)) + } + + private fun featureShortcut( + title: CharSequence, + detail: CharSequence, + icon: Int, + onClick: () -> Unit, + ) = Button(this).apply { + text = "$title\n$detail" + contentDescription = "$title. $detail" + setCompoundDrawablesRelativeWithIntrinsicBounds(icon, 0, 0, 0) + compoundDrawablePadding = dp(8) + compoundDrawableTintList = ColorStateList.valueOf(getColor(R.color.accent_text)) + useFeatureActionStyle() + setOnClickListener { onClick() } + } + // Function Name: renderGuidedCaptureProgress // Description: // - Renders the three useful Platoon payloads as a live checklist. @@ -723,6 +771,7 @@ class MainActivity : LocalizedActivity() { addView(Button(context).apply { text = entry.title isAllCaps = false + useNavigationActionStyle() setOnClickListener { startActivity( Intent(this@MainActivity, PacketHistoryActivity::class.java) diff --git a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt index c0e31db..83c7990 100644 --- a/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberDetailActivity.kt @@ -45,13 +45,13 @@ class MemberDetailActivity : LocalizedActivity() { setText(status.note) minLines = 2 } - setContentView(ScrollView(this).apply { + val content = ScrollView(this).apply { addView(LinearLayout(context).apply { orientation = LinearLayout.VERTICAL setPadding(dp(16), dp(16), dp(16), dp(16)) addView(TextView(context).apply { text = getString(R.string.member_details) - textSize = 28f + textSize = 24f setTypeface(typeface, Typeface.BOLD) }, matchWidth()) addView(TextView(context).apply { @@ -66,48 +66,60 @@ class MemberDetailActivity : LocalizedActivity() { }, ), ) - textSize = 16f + textSize = 14f + setTextColor(getColor(R.color.text_secondary)) setPadding(0, dp(4), 0, dp(8)) }, matchWidth()) addView(nameInput, matchWidth()) addView(noteInput, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.save_member) - setOnClickListener { - val saved = runCatching { - repository.updateMember( - status.uid, - nameInput.text.toString(), - noteInput.text.toString(), - ) - }.getOrDefault(false) - Toast.makeText( - this@MemberDetailActivity, - getString(if (saved) R.string.saved else R.string.save_failed), - Toast.LENGTH_SHORT, - ).show() - } - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.add_membership_history) - setOnClickListener { addMembershipHistory(status) } + addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + addView(Button(context).apply { + text = getString(R.string.save_member) + usePrimaryActionStyle() + setOnClickListener { + val saved = runCatching { + repository.updateMember( + status.uid, + nameInput.text.toString(), + noteInput.text.toString(), + ) + }.getOrDefault(false) + Toast.makeText( + this@MemberDetailActivity, + getString(if (saved) R.string.saved else R.string.save_failed), + Toast.LENGTH_SHORT, + ).show() + } + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { + marginEnd = dp(4) + }) + addView(Button(context).apply { + text = getString(R.string.add_membership_history_short) + useSecondaryActionStyle() + setOnClickListener { addMembershipHistory(status) } + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { + marginStart = dp(4) + }) }, matchWidth()) addView(Button(context).apply { text = getString(R.string.delete_member) - setTextColor(getColor(R.color.destructive_action)) + useDestructiveTextActionStyle() setOnClickListener { confirmMemberDeletion(status) } }, matchWidth()) addView(TextView(context).apply { text = getString(R.string.membership_history) - textSize = 21f + textSize = 17f setTypeface(typeface, Typeface.BOLD) setPadding(0, dp(16), 0, dp(4)) }, matchWidth()) status.membershipPeriods.forEachIndexed { index, membershipPeriod -> - addView(membershipPeriodButton(status, membershipPeriod, index), matchWidth()) + addView(membershipPeriodRow(status, membershipPeriod, index), matchWidth()) } }, matchWidth()) - }) + } + setContentView(content) + ModernUi.prepareContent(window.decorView) } private fun confirmMemberDeletion(status: MemberStatus) { @@ -145,15 +157,15 @@ class MemberDetailActivity : LocalizedActivity() { dialog.show() } - private fun membershipPeriodButton( + private fun membershipPeriodRow( status: MemberStatus, membershipPeriod: MembershipPeriod, index: Int, - ) = Button(this).apply { - isAllCaps = false - text = getString( - R.string.membership_period_summary, - index + 1, + ) = ModernUi.actionRow( + context = this, + title = getString(R.string.membership_period_title, index + 1), + detail = getString( + R.string.membership_period_dates, format( membershipPeriod.joinedDate, membershipPeriod.joinedAt, @@ -164,9 +176,9 @@ class MemberDetailActivity : LocalizedActivity() { membershipPeriod.leftAt, membershipPeriod.leftTimeKnown ?: (membershipPeriod.leftAt != null), ), - ) - setOnClickListener { editMembershipPeriod(status, membershipPeriod) } - } + ), + onClick = { editMembershipPeriod(status, membershipPeriod) }, + ) private fun editMembershipPeriod(status: MemberStatus, membershipPeriod: MembershipPeriod) { val joined = DateTimePickerInput( diff --git a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt index da40f6e..b6305e3 100644 --- a/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt +++ b/app/src/main/java/dev/gf2log/app/MemberOrderActivity.kt @@ -2,7 +2,6 @@ package dev.gf2log.app import android.animation.LayoutTransition import android.content.ClipData -import android.graphics.Color import android.graphics.drawable.GradientDrawable import android.graphics.Typeface import android.os.Bundle @@ -13,6 +12,7 @@ import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.Button +import android.widget.ImageView import android.widget.LinearLayout import android.widget.ScrollView import android.widget.Spinner @@ -88,7 +88,7 @@ class MemberOrderActivity : LocalizedActivity() { setPadding(dp(16), dp(16), dp(16), dp(16)) addView(TextView(context).apply { text = getString(R.string.member_order) - textSize = 26f + textSize = 24f setTypeface(typeface, Typeface.BOLD) }, matchWidth()) addView(TextView(context).apply { @@ -99,6 +99,7 @@ class MemberOrderActivity : LocalizedActivity() { addView(sortControlRow(R.string.sort_direction, sortDirection), matchWidth()) addView(Button(context).apply { text = getString(R.string.apply_sort) + usePrimaryActionStyle() setOnClickListener { members = MemberOrderSorter.sort( members = members, @@ -111,6 +112,7 @@ class MemberOrderActivity : LocalizedActivity() { }, matchWidth()) addView(Button(context).apply { text = getString(R.string.reset_member_order) + useTertiaryActionStyle() setOnClickListener { preferences.clear() members = defaultMembers.toMutableList() @@ -155,12 +157,11 @@ class MemberOrderActivity : LocalizedActivity() { setPadding(dp(8), dp(6), dp(8), dp(6)) }, LinearLayout.LayoutParams(0, wrap(), 1f)) } - row.addView(TextView(this).apply { - text = "\u2630" - textSize = 25f - gravity = Gravity.CENTER - setTextColor(getColor(R.color.text_secondary)) + row.addView(ImageView(this).apply { + setImageResource(R.drawable.ic_drag_handle) + setPadding(dp(14), dp(14), dp(14), dp(14)) contentDescription = getString(R.string.drag_member, member.name) + isFocusable = true setOnLongClickListener { performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) startDragAndDrop( @@ -260,9 +261,9 @@ class MemberOrderActivity : LocalizedActivity() { row.elevation = if (selected) dp(5).toFloat() else 0f row.background = if (selected) { GradientDrawable().apply { - setColor(DRAG_SURFACE) - setStroke(dp(2), DRAG_BORDER) - cornerRadius = dp(7).toFloat() + setColor(getColor(R.color.accent_surface)) + setStroke(dp(2), getColor(R.color.accent)) + cornerRadius = dp(2).toFloat() } } else { null @@ -284,7 +285,5 @@ class MemberOrderActivity : LocalizedActivity() { private companion object { const val DRAG_TRANSITION_MILLIS = 120L - val DRAG_BORDER = Color.rgb(232, 132, 32) - val DRAG_SURFACE = Color.argb(48, 232, 132, 32) } } diff --git a/app/src/main/java/dev/gf2log/app/ModernUi.kt b/app/src/main/java/dev/gf2log/app/ModernUi.kt index 9ec1ee3..eb171a1 100644 --- a/app/src/main/java/dev/gf2log/app/ModernUi.kt +++ b/app/src/main/java/dev/gf2log/app/ModernUi.kt @@ -2,19 +2,39 @@ package dev.gf2log.app import android.content.Context import android.content.res.ColorStateList +import android.graphics.Typeface +import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.StateListDrawable +import android.text.TextUtils +import android.view.Gravity import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.CheckBox import android.widget.EditText import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout import android.widget.RadioButton import android.widget.ScrollView +import android.widget.TextView -/** Shared presentation primitives for the modern, minimal UI surface. */ +/** Shared presentation primitives for the warm, editorial product surface. */ object ModernUi { + internal enum class ControlRole { + PRIMARY, + CAPTURE, + FEATURE, + SEGMENT_SELECTED, + SECONDARY, + TERTIARY, + NAVIGATION, + DESTRUCTIVE, + DESTRUCTIVE_TEXT, + } + fun prepareContent(root: View) { root.setBackgroundColor(root.context.getColor(R.color.app_background)) prepareScreenSurface(root) @@ -23,43 +43,275 @@ object ModernUi { fun panelBackground(context: Context, emphasized: Boolean = false): GradientDrawable = GradientDrawable().apply { - setColor(context.getColor(if (emphasized) R.color.primary_soft else R.color.surface)) - cornerRadius = context.dp(10).toFloat() - setStroke( - context.dp(1), - context.getColor(if (emphasized) R.color.outline_strong else R.color.outline), + setColor(context.getColor(if (emphasized) R.color.accent_surface else R.color.surface)) + cornerRadius = context.dp(16).toFloat() + } + + /** + * Creates a compact multi-line navigation row without presenting its copy as an oversized button. + * The complete row remains a native 56dp touch target and exposes button semantics. + */ + fun actionRow( + context: Context, + title: CharSequence, + detail: CharSequence? = null, + onClick: () -> Unit, + ): View = AccessibleActionRow(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = context.dp(56) + setPadding(context.dp(14), context.dp(6), context.dp(10), context.dp(6)) + background = statefulSurface( + context = context, + normalColor = R.color.surface, + pressedColor = R.color.surface_pressed, + disabledColor = R.color.surface_variant, + normalStroke = android.R.color.transparent, + pressedStroke = R.color.outline, + radiusDp = 14, + ) + contentDescription = listOfNotNull(title, detail?.takeIf(CharSequence::isNotBlank)) + .joinToString(". ") + setOnClickListener { onClick() } + addView(LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + addView(TextView(context).apply { + text = title + textSize = 15f + setTextColor(context.getColor(R.color.text_primary)) + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + }, ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + )) + detail?.takeIf(CharSequence::isNotBlank)?.let { supportingText -> + addView(TextView(context).apply { + text = supportingText + textSize = 13f + setTextColor(context.getColor(R.color.text_secondary)) + maxLines = 2 + ellipsize = TextUtils.TruncateAt.END + setPadding(0, context.dp(2), 0, 0) + }, ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + )) + } + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + addView(ImageView(context).apply { + setImageResource(R.drawable.ic_chevron_right) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, LinearLayout.LayoutParams(context.dp(28), context.dp(28)).apply { + marginStart = context.dp(8) + }) + } + + /** Compact mutually-exclusive choices used for language and theme settings. */ + fun segmentedControl( + context: Context, + options: List>, + selectedValue: String, + onSelected: (String) -> Unit, + ): View = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + setPadding(context.dp(3), context.dp(3), context.dp(3), context.dp(3)) + background = GradientDrawable().apply { + setColor(context.getColor(R.color.surface_variant)) + cornerRadius = context.dp(14).toFloat() + } + options.forEach { (value, label) -> + addView(Button(context).apply { + text = label + setTag( + R.id.gf2_ui_role, + if (value == selectedValue) ControlRole.SEGMENT_SELECTED else ControlRole.TERTIARY, + ) + styleButton(this) + setOnClickListener { onSelected(value) } + }, LinearLayout.LayoutParams(0, context.dp(46), 1f)) + } + } + + internal fun styleButton(button: Button) { + val role = button.getTag(R.id.gf2_ui_role) as? ControlRole ?: ControlRole.SECONDARY + button.isAllCaps = false + button.elevation = 0f + button.stateListAnimator = null + button.minHeight = button.context.dp(48) + button.minWidth = button.context.dp(48) + val compactMultiline = button.getTag(R.id.gf2_ui_compact_multiline) == true + button.textSize = when { + compactMultiline -> 12f + role == ControlRole.FEATURE -> 13f + else -> 14f + } + button.typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + button.maxLines = if (role == ControlRole.FEATURE || compactMultiline) 2 else 1 + button.ellipsize = TextUtils.TruncateAt.END + button.gravity = if (role == ControlRole.NAVIGATION || role == ControlRole.FEATURE) { + Gravity.START or Gravity.CENTER_VERTICAL + } else { + Gravity.CENTER + } + button.setPaddingRelative(button.context.dp(12), 0, button.context.dp(12), 0) + + val colors = when (role) { + ControlRole.PRIMARY -> ButtonColors( + normal = R.color.primary_action_background, + pressed = R.color.primary_action_pressed, + disabled = R.color.surface_variant, + stroke = R.color.primary_action_background, + pressedStroke = R.color.primary_action_pressed, + text = R.color.primary_action_foreground, + insetVertical = 4, + radiusDp = 12, + ) + ControlRole.CAPTURE -> ButtonColors( + normal = R.color.success_surface, + pressed = R.color.success_pressed, + disabled = R.color.surface_variant, + stroke = R.color.success_text, + pressedStroke = R.color.success_text, + text = R.color.success_text, + insetVertical = 4, + radiusDp = 12, + ) + ControlRole.FEATURE -> ButtonColors( + normal = R.color.accent_surface, + pressed = R.color.accent_surface_pressed, + disabled = R.color.surface_variant, + stroke = android.R.color.transparent, + pressedStroke = R.color.accent, + text = R.color.accent_text, + insetVertical = 4, + radiusDp = 14, + ) + ControlRole.SEGMENT_SELECTED -> ButtonColors( + normal = R.color.accent_surface, + pressed = R.color.accent_surface_pressed, + disabled = R.color.surface_variant, + stroke = android.R.color.transparent, + pressedStroke = R.color.accent, + text = R.color.accent_text, + insetVertical = 2, + radiusDp = 11, + ) + ControlRole.DESTRUCTIVE -> ButtonColors( + normal = R.color.destructive_action_background, + pressed = R.color.destructive_action_pressed, + disabled = R.color.surface_variant, + stroke = R.color.destructive_action_background, + pressedStroke = R.color.destructive_action_pressed, + text = R.color.destructive_action_foreground, + insetVertical = 4, + radiusDp = 12, + ) + ControlRole.TERTIARY -> ButtonColors( + normal = R.color.app_background, + pressed = R.color.surface_pressed, + disabled = R.color.app_background, + stroke = android.R.color.transparent, + pressedStroke = android.R.color.transparent, + text = R.color.text_primary, + insetVertical = 5, + ) + ControlRole.DESTRUCTIVE_TEXT -> ButtonColors( + normal = R.color.app_background, + pressed = R.color.surface_pressed, + disabled = R.color.app_background, + stroke = android.R.color.transparent, + pressedStroke = android.R.color.transparent, + text = R.color.destructive_action, + insetVertical = 5, + ) + ControlRole.NAVIGATION -> ButtonColors( + normal = R.color.surface, + pressed = R.color.surface_pressed, + disabled = R.color.surface_variant, + stroke = android.R.color.transparent, + pressedStroke = R.color.outline_strong, + text = R.color.text_primary, + insetVertical = 4, + radiusDp = 12, + ) + ControlRole.SECONDARY -> ButtonColors( + normal = R.color.surface, + pressed = R.color.surface_pressed, + disabled = R.color.surface_variant, + stroke = android.R.color.transparent, + pressedStroke = R.color.outline_strong, + text = R.color.text_primary, + insetVertical = 4, + radiusDp = 12, ) } + button.setTextColor(buttonTextColors(button.context, colors.text)) + button.backgroundTintList = null + button.background = InsetDrawable( + statefulSurface( + context = button.context, + normalColor = colors.normal, + pressedColor = colors.pressed, + disabledColor = colors.disabled, + normalStroke = colors.stroke, + pressedStroke = colors.pressedStroke, + radiusDp = colors.radiusDp, + ), + 0, + button.context.dp(colors.insetVertical), + 0, + button.context.dp(colors.insetVertical), + ) + // A restored Button can keep the drawable state from its previous platform + // background even though View.isEnabled is already true. Synchronize the new + // semantic drawable immediately after replacing it. + button.refreshDrawableState() + button.jumpDrawablesToCurrentState() + } private fun applyControls(view: View) { when (view) { is ScrollView -> view.isFillViewport = true - is Button -> { - view.isAllCaps = false - view.elevation = 0f - view.stateListAnimator = null - view.background?.let { drawable -> - view.background = InsetDrawable( - drawable, - view.context.dp(3), - view.context.dp(7), - view.context.dp(3), - view.context.dp(7), - ) - } + is CheckBox -> { + view.buttonTintList = controlTint(view.context) + view.minimumHeight = view.context.dp(48) + } + is RadioButton -> { + view.buttonTintList = controlTint(view.context) + view.minimumHeight = view.context.dp(48) } - is EditText -> view.elevation = 0f - is ImageButton -> { - view.useModernIconStyle() + is Button -> styleButton(view) + is EditText -> { + view.elevation = 0f + view.typeface = Typeface.create("sans-serif", Typeface.NORMAL) } - is CheckBox -> view.buttonTintList = controlTint(view.context) - is RadioButton -> view.buttonTintList = controlTint(view.context) + is ImageButton -> view.useModernIconStyle() + is TextView -> styleText(view) } if (view is ViewGroup) { repeat(view.childCount) { index -> applyControls(view.getChildAt(index)) } } } + private fun styleText(textView: TextView) { + if (textView.typeface == Typeface.MONOSPACE) return + val sizeSp = textView.textSize / + (textView.resources.displayMetrics.density * textView.resources.configuration.fontScale) + when { + sizeSp >= 24f && textView.typeface?.isBold == true -> { + textView.typeface = Typeface.create("sans-serif", Typeface.BOLD) + textView.letterSpacing = -0.01f + } + sizeSp >= 18f && textView.typeface?.isBold == true -> { + textView.typeface = Typeface.create("sans-serif-medium", Typeface.BOLD) + textView.letterSpacing = 0f + } + } + } + private fun prepareScreenSurface(root: View) { val content = root.findViewById(android.R.id.content) ?: return val screen = content.getChildAt(0) ?: return @@ -70,6 +322,52 @@ object ModernUi { } } + private fun statefulSurface( + context: Context, + normalColor: Int, + pressedColor: Int, + disabledColor: Int, + normalStroke: Int, + pressedStroke: Int, + radiusDp: Int, + ): Drawable { + return StateListDrawable().apply { + addState( + intArrayOf(-android.R.attr.state_enabled), + shape(context, disabledColor, normalStroke, radiusDp), + ) + addState( + intArrayOf(android.R.attr.state_pressed), + shape(context, pressedColor, pressedStroke, radiusDp), + ) + addState(intArrayOf(), shape(context, normalColor, normalStroke, radiusDp)) + } + } + + private fun shape( + context: Context, + fillColor: Int, + strokeColor: Int, + radiusDp: Int, + ) = GradientDrawable().apply { + setColor(context.getColor(fillColor)) + cornerRadius = context.dp(radiusDp).toFloat() + if (strokeColor != android.R.color.transparent) { + setStroke(context.dp(1), context.getColor(strokeColor)) + } + } + + private fun buttonTextColors(context: Context, enabledColor: Int) = ColorStateList( + arrayOf( + intArrayOf(-android.R.attr.state_enabled), + intArrayOf(), + ), + intArrayOf( + context.getColor(R.color.text_secondary), + context.getColor(enabledColor), + ), + ) + private fun controlTint(context: Context) = ColorStateList( arrayOf( intArrayOf(-android.R.attr.state_enabled), @@ -77,17 +375,32 @@ object ModernUi { intArrayOf(), ), intArrayOf( - context.getColor(R.color.outline), + context.getColor(R.color.outline_strong), context.getColor(R.color.accent), context.getColor(R.color.outline_strong), ), ) + + private data class ButtonColors( + val normal: Int, + val pressed: Int, + val disabled: Int, + val stroke: Int, + val pressedStroke: Int, + val text: Int, + val insetVertical: Int, + val radiusDp: Int = 2, + ) +} + +private class AccessibleActionRow(context: Context) : LinearLayout(context) { + override fun getAccessibilityClassName(): CharSequence = Button::class.java.name } internal fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() internal fun ImageButton.useModernIconStyle() { - background = InsetDrawable(ModernUi.panelBackground(context), context.dp(6)) + background = InsetDrawable(ModernUi.panelBackground(context), context.dp(4)) imageTintList = ColorStateList.valueOf(context.getColor(R.color.primary)) elevation = 0f } diff --git a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt index 90af2cd..54c07dc 100644 --- a/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt @@ -1,8 +1,10 @@ package dev.gf2log.app +import android.animation.ValueAnimator import android.content.Context import android.content.Intent import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable import android.os.Bundle import android.view.Gravity import android.view.View @@ -41,14 +43,14 @@ class OnboardingActivity : LocalizedActivity() { private fun buildContent(): View = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL - setPadding(dp(20), dp(16), dp(20), dp(16)) + setPadding(dp(20), dp(10), dp(20), dp(12)) addView(LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL addView(TextView(context).apply { text = getString(R.string.app_name) - textSize = 22f + textSize = 20f setTypeface(typeface, Typeface.BOLD) }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) addView( @@ -58,23 +60,10 @@ class OnboardingActivity : LocalizedActivity() { recreate() } }, - LinearLayout.LayoutParams(dp(148), dp(40)), + LinearLayout.LayoutParams(dp(136), dp(42)), ) }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.onboarding_welcome) - textSize = 26f - setTypeface(typeface, Typeface.BOLD) - setPadding(0, dp(24), 0, dp(4)) - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.onboarding_intro) - textSize = 15f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, 0, 0, dp(16)) - }, matchWidth()) - pageHost = FrameLayout(context) addView(pageHost, LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -84,9 +73,10 @@ class OnboardingActivity : LocalizedActivity() { stepLabel = TextView(context).apply { gravity = Gravity.CENTER - textSize = 13f - setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(12), 0, dp(8)) + textSize = 15f + letterSpacing = 0.16f + setTextColor(getColor(R.color.accent)) + setPadding(0, dp(8), 0, dp(6)) } addView(stepLabel, matchWidth()) @@ -94,6 +84,7 @@ class OnboardingActivity : LocalizedActivity() { orientation = LinearLayout.HORIZONTAL backButton = Button(context).apply { text = getString(R.string.onboarding_back) + useSecondaryActionStyle() setOnClickListener { if (pageIndex > 0) { pageIndex-- @@ -123,6 +114,7 @@ class OnboardingActivity : LocalizedActivity() { skipButton = Button(context).apply { text = getString(R.string.onboarding_skip) contentDescription = getString(R.string.onboarding_skip) + useTertiaryActionStyle() setOnClickListener { finishOnboarding() } } addView(skipButton, matchWidth()) @@ -135,17 +127,24 @@ class OnboardingActivity : LocalizedActivity() { ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, )) - if (animate) { + if (animate && ValueAnimator.areAnimatorsEnabled()) { content.alpha = 0f - content.translationX = (direction * dp(24)).toFloat() - content.animate().alpha(1f).translationX(0f).setDuration(180L).start() + content.translationX = (direction * dp(12)).toFloat() + content.animate().alpha(1f).translationX(0f).setDuration(140L).start() + } + stepLabel.text = PAGES.indices.joinToString(" ") { index -> + if (index == pageIndex) "●" else "○" } - stepLabel.text = getString(R.string.onboarding_step, pageIndex + 1, PAGES.size) + stepLabel.contentDescription = getString(R.string.onboarding_step, pageIndex + 1, PAGES.size) backButton.isEnabled = pageIndex > 0 nextButton.text = getString( if (pageIndex == PAGES.lastIndex) R.string.onboarding_finish else R.string.onboarding_next, ) - skipButton.visibility = if (pageIndex == PAGES.lastIndex) View.INVISIBLE else View.VISIBLE + // Reapply the role after locale recreation so the translated final action cannot + // fall back to the platform button drawable while the page is restored. + nextButton.isEnabled = true + nextButton.usePrimaryActionStyle() + skipButton.visibility = View.VISIBLE } private fun pageView(page: OnboardingPage): View = ScrollView(this).apply { @@ -154,69 +153,71 @@ class OnboardingActivity : LocalizedActivity() { addView(LinearLayout(context).apply { orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER_HORIZONTAL - setPadding(dp(18), dp(18), dp(18), dp(18)) - background = ModernUi.panelBackground(context) - elevation = 0f + setPadding(0, dp(22), 0, dp(10)) addView(FrameLayout(context).apply { - background = ModernUi.panelBackground(context, emphasized = true) + background = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(getColor(R.color.accent_surface)) + setStroke(dp(1), getColor(R.color.outline)) + } addView(ImageView(context).apply { setImageResource(page.icon) imageTintList = if (page.icon == R.mipmap.ic_launcher) null else - android.content.res.ColorStateList.valueOf(getColor(R.color.primary)) + android.content.res.ColorStateList.valueOf(getColor(R.color.accent_text)) scaleType = ImageView.ScaleType.CENTER_INSIDE - setPadding(dp(13), dp(13), dp(13), dp(13)) - }, FrameLayout.LayoutParams(dp(62), dp(62), Gravity.CENTER)) - }, LinearLayout.LayoutParams(dp(72), dp(72)).apply { - bottomMargin = dp(16) - }) + setPadding(dp(18), dp(18), dp(18), dp(18)) + }, FrameLayout.LayoutParams(dp(76), dp(76), Gravity.CENTER)) + }, LinearLayout.LayoutParams(dp(100), dp(100)).apply { bottomMargin = dp(20) }) addView(TextView(context).apply { text = getString(page.title) - textSize = 23f gravity = Gravity.CENTER + textSize = 26f setTypeface(typeface, Typeface.BOLD) }, matchWidth()) addView(TextView(context).apply { text = getString(page.description) - textSize = 15f gravity = Gravity.CENTER + textSize = 14f setTextColor(getColor(R.color.text_secondary)) - setPadding(0, dp(8), 0, dp(18)) + setPadding(dp(12), dp(7), dp(12), dp(18)) }, matchWidth()) - page.features.forEach { feature -> addView(featureRow(feature), matchWidth()) } + page.features.forEach { feature -> + addView(featureRow(feature), matchWidth()) + } page.warning?.let { warning -> addView(TextView(context).apply { text = getString(warning) - textSize = 13f + textSize = 14f setTextColor(getColor(R.color.warning_text)) - setPadding(dp(14), dp(12), dp(14), dp(12)) + setPadding(dp(14), dp(11), dp(14), dp(11)) background = ModernUi.panelBackground(context, emphasized = true).apply { setColor(getColor(R.color.warning_surface)) - setStroke(dp(1), getColor(R.color.warning_text)) } }, LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { topMargin = dp(8) }) + ).apply { topMargin = dp(12) }) } }, matchWidth()) } private fun featureRow(@StringRes feature: Int): View = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL - gravity = Gravity.TOP - setPadding(0, dp(7), 0, dp(7)) + gravity = Gravity.CENTER_VERTICAL + minimumHeight = dp(48) + setPadding(dp(12), dp(4), dp(12), dp(4)) addView(TextView(context).apply { - text = "✓" - textSize = 17f - setTypeface(typeface, Typeface.BOLD) + text = "•" + textSize = 20f setTextColor(getColor(R.color.accent)) gravity = Gravity.CENTER - }, LinearLayout.LayoutParams(dp(28), ViewGroup.LayoutParams.WRAP_CONTENT)) + }, LinearLayout.LayoutParams(dp(24), ViewGroup.LayoutParams.MATCH_PARENT)) addView(TextView(context).apply { text = getString(feature) textSize = 14f setTextColor(getColor(R.color.text_primary)) + setPadding(dp(8), 0, 0, 0) }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) } @@ -312,10 +313,7 @@ private class OnboardingLanguageToggle( setPadding(dp(3), dp(3), dp(3), dp(3)) background = ModernUi.panelBackground(context) contentDescription = context.getString(R.string.onboarding_language_toggle) - thumb.background = ModernUi.panelBackground(context, emphasized = true).apply { - setColor(context.getColor(R.color.primary)) - setStroke(0, context.getColor(R.color.primary)) - } + thumb.background = ModernUi.panelBackground(context, emphasized = true) addView(thumb) addView(english) addView(korean) @@ -323,8 +321,8 @@ private class OnboardingLanguageToggle( } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val width = resolveSize(dp(148), widthMeasureSpec) - val height = resolveSize(dp(40), heightMeasureSpec) + val width = resolveSize(dp(136), widthMeasureSpec) + val height = resolveSize(dp(42), heightMeasureSpec) setMeasuredDimension(width, height) val childWidth = (width - paddingLeft - paddingRight) / 2 val childHeight = height - paddingTop - paddingBottom @@ -355,17 +353,17 @@ private class OnboardingLanguageToggle( selectedLanguage = language val distance = ((width - paddingLeft - paddingRight) / 2).toFloat() val target = if (language == LanguagePreferences.KOREAN) distance else 0f - if (animate) { + if (animate && ValueAnimator.areAnimatorsEnabled()) { thumb.animate().cancel() thumb.animate() .translationX(target) - .setDuration(180L) + .setDuration(140L) .withEndAction { onLanguageChanged(language) } .start() } else { thumb.translationX = target } - val selectedColor = context.getColor(R.color.primary_action_foreground) + val selectedColor = context.getColor(R.color.accent_text) val idleColor = context.getColor(R.color.text_primary) english.setTextColor(if (language == LanguagePreferences.DEFAULT_LANGUAGE) selectedColor else idleColor) korean.setTextColor(if (language == LanguagePreferences.KOREAN) selectedColor else idleColor) diff --git a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt index 18d852e..2c22f4f 100644 --- a/app/src/main/java/dev/gf2log/app/OptionsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/OptionsActivity.kt @@ -17,8 +17,6 @@ import android.widget.Button import android.widget.CheckBox import android.widget.EditText import android.widget.LinearLayout -import android.widget.RadioButton -import android.widget.RadioGroup import android.widget.ScrollView import android.widget.TextView import android.widget.Toast @@ -94,39 +92,35 @@ class OptionsActivity : LocalizedActivity() { addView(TextView(context).apply { text = getString(R.string.payload_options) - textSize = 26f + textSize = 23f setTypeface(typeface, Typeface.BOLD) }, matchWidth()) addView(TextView(context).apply { text = getString(R.string.payload_options_description) - textSize = 15f - setPadding(0, dp(8), 0, spacing) + textSize = 13f + setTextColor(getColor(R.color.text_secondary)) + setPadding(0, dp(4), 0, spacing) }, matchWidth()) addView(TextView(context).apply { text = getString(R.string.language) - textSize = 20f + textSize = 17f setTypeface(typeface, Typeface.BOLD) setPadding(0, 0, 0, dp(4)) }, matchWidth()) - addView(RadioGroup(context).apply { - orientation = RadioGroup.HORIZONTAL - val current = LanguagePreferences.get(context) - addView(RadioButton(context).apply { - text = getString(R.string.language_english) - isChecked = current == LanguagePreferences.DEFAULT_LANGUAGE - setOnClickListener { changeLanguage(LanguagePreferences.DEFAULT_LANGUAGE) } - }) - addView(RadioButton(context).apply { - text = getString(R.string.language_korean) - isChecked = current == LanguagePreferences.KOREAN - setOnClickListener { changeLanguage(LanguagePreferences.KOREAN) } - }) - }, matchWidth()) + addView(ModernUi.segmentedControl( + context = context, + options = listOf( + LanguagePreferences.DEFAULT_LANGUAGE to getString(R.string.language_english), + LanguagePreferences.KOREAN to getString(R.string.language_korean), + ), + selectedValue = LanguagePreferences.get(context), + onSelected = ::changeLanguage, + ), matchWidth()) addView(TextView(context).apply { text = getString(R.string.appearance) - textSize = 20f + textSize = 17f setTypeface(typeface, Typeface.BOLD) setPadding(0, spacing, 0, dp(4)) }, matchWidth()) @@ -136,50 +130,42 @@ class OptionsActivity : LocalizedActivity() { setTextColor(getColor(R.color.text_secondary)) setPadding(0, 0, 0, dp(6)) }, matchWidth()) - addView(RadioGroup(context).apply { - orientation = RadioGroup.HORIZONTAL - val current = ThemePreferences.get(context) - listOf( - ThemePreferences.SYSTEM to R.string.theme_system, - ThemePreferences.LIGHT to R.string.theme_light, - ThemePreferences.DARK to R.string.theme_dark, - ).forEach { (mode, label) -> - addView(RadioButton(context).apply { - text = getString(label) - isChecked = current == mode - setOnClickListener { changeTheme(mode) } - }) - } - }, matchWidth()) + addView(ModernUi.segmentedControl( + context = context, + options = listOf( + ThemePreferences.SYSTEM to getString(R.string.theme_system), + ThemePreferences.LIGHT to getString(R.string.theme_light), + ThemePreferences.DARK to getString(R.string.theme_dark), + ), + selectedValue = ThemePreferences.get(context), + onSelected = ::changeTheme, + ), matchWidth()) addView(TextView(context).apply { text = getString(R.string.backup) - textSize = 20f + textSize = 17f setTypeface(typeface, Typeface.BOLD) setPadding(0, spacing, 0, dp(4)) }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.restore_full_backup) - setOnClickListener { confirmFullRestore() } - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.restore_full_backup_description) - textSize = 14f - setPadding(dp(16), 0, 0, spacing) - }, matchWidth()) - addView(Button(context).apply { - text = getString(R.string.back_up_all_information) - setOnClickListener { exportFullBackup() } - }, matchWidth()) - addView(TextView(context).apply { - text = getString(R.string.back_up_all_information_description) - textSize = 14f - setPadding(dp(16), 0, 0, spacing) - }, matchWidth()) + addView(ModernUi.actionRow( + context, + getString(R.string.restore_full_backup), + getString(R.string.restore_full_backup_description), + ::confirmFullRestore, + ), matchWidth()) + addView(ModernUi.actionRow( + context, + getString(R.string.back_up_all_information), + getString(R.string.back_up_all_information_description), + ::exportFullBackup, + ), LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(5) }) addView(TextView(context).apply { text = getString(R.string.discord_webhook) - textSize = 20f + textSize = 17f setTypeface(typeface, Typeface.BOLD) val discordIcon = context.getDrawable(R.drawable.ic_discord)?.mutate() discordIcon?.setTint(currentTextColor) @@ -216,16 +202,10 @@ class OptionsActivity : LocalizedActivity() { } else { R.color.webhook_missing_foreground } - val borderColor = if (webhookConfigured) { - R.color.webhook_configured_border - } else { - R.color.webhook_missing_border - } setTextColor(context.getColor(foregroundColor)) background = GradientDrawable().apply { setColor(context.getColor(backgroundColor)) - cornerRadius = dp(10).toFloat() - setStroke(dp(1), context.getColor(borderColor)) + cornerRadius = dp(12).toFloat() } setPadding(dp(12), dp(10), dp(12), dp(10)) }, matchWidth()) @@ -237,6 +217,7 @@ class OptionsActivity : LocalizedActivity() { addView(webhookInput, matchWidth()) addView(Button(context).apply { text = getString(R.string.save_discord_webhook) + usePrimaryActionStyle() setOnClickListener { val saved = runCatching { webhookStore.save(webhookInput.text.toString()) @@ -255,6 +236,7 @@ class OptionsActivity : LocalizedActivity() { }, matchWidth()) addView(Button(context).apply { text = getString(R.string.clear_discord_webhook) + useDestructiveTextActionStyle() isEnabled = webhookConfigured setOnClickListener { val cleared = runCatching(webhookStore::clear).isSuccess @@ -269,7 +251,7 @@ class OptionsActivity : LocalizedActivity() { addView(TextView(context).apply { text = getString(R.string.payload_history) - textSize = 20f + textSize = 17f setTypeface(typeface, Typeface.BOLD) setPadding(0, spacing, 0, dp(4)) }, matchWidth()) @@ -324,7 +306,7 @@ class OptionsActivity : LocalizedActivity() { addView(TextView(context).apply { text = getString(R.string.last_capture_diagnostics) - textSize = 20f + textSize = 17f setTypeface(typeface, Typeface.BOLD) setPadding(0, spacing, 0, dp(4)) }, matchWidth()) diff --git a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt index 24072bf..a5e4a38 100644 --- a/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PacketHistoryActivity.kt @@ -69,48 +69,57 @@ class PacketHistoryActivity : LocalizedActivity() { textSize = 24f setTypeface(typeface, Typeface.BOLD) }, matchWidth()) - actionButton = Button(context).apply { - setOnClickListener { - if (showingRaw) copyToClipboard(rawContent) else showRawContent() + addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + actionButton = Button(context).apply { + useSecondaryActionStyle() + setOnClickListener { + if (showingRaw) copyToClipboard(rawContent) else showRawContent() + } } - } - addView(actionButton, matchWidth()) - sendButton = Button(context).apply { - text = getString(R.string.send_original_csv) - usePrimaryActionStyle() - setCompoundDrawablesRelativeWithIntrinsicBounds( - R.drawable.ic_discord, 0, 0, 0, - ) - compoundDrawableTintList = ColorStateList.valueOf( - context.getColor(R.color.primary_action_foreground), - ) - compoundDrawablePadding = dp(8) - setPaddingRelative( - paddingStart, paddingTop, paddingEnd + dp(32), paddingBottom, - ) - setOnClickListener { confirmSendOriginalCsv() } - } - addView(FrameLayout(context).apply { - addView( - sendButton, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ), - ) - sendUnavailableOverlay = View(context).apply { - isClickable = true - isFocusable = true - contentDescription = getString(R.string.discord_webhook_not_configured) - setOnClickListener { showWebhookRequiredDialog() } + addView(actionButton, LinearLayout.LayoutParams( + 0, + dp(48), + 1f, + ).apply { marginEnd = dp(4) }) + sendButton = Button(context).apply { + text = getString(R.string.send_original_csv) + usePrimaryActionStyle() + setCompoundDrawablesRelativeWithIntrinsicBounds( + R.drawable.ic_discord, 0, 0, 0, + ) + compoundDrawableTintList = ColorStateList.valueOf( + context.getColor(R.color.primary_action_foreground), + ) + compoundDrawablePadding = dp(6) + setOnClickListener { confirmSendOriginalCsv() } } - addView( - sendUnavailableOverlay, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ), - ) + addView(FrameLayout(context).apply { + addView( + sendButton, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), + ) + sendUnavailableOverlay = View(context).apply { + isClickable = true + isFocusable = true + contentDescription = getString(R.string.discord_webhook_not_configured) + setOnClickListener { showWebhookRequiredDialog() } + } + addView( + sendUnavailableOverlay, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), + ) + }, LinearLayout.LayoutParams( + 0, + dp(48), + 1f, + ).apply { marginStart = dp(4) }) }, matchWidth()) contentContainer = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL diff --git a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt index df4a8c7..f210744 100644 --- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt @@ -91,23 +91,25 @@ class PlatoonActivity : LocalizedActivity() { setPadding(spacing, spacing, spacing, spacing) addView(TextView(context).apply { text = getString(R.string.platoon_management) - textSize = 28f + textSize = 24f setTypeface(typeface, Typeface.BOLD) }, matchWidth()) summary = TextView(context).apply { - textSize = 15f - setPadding(0, dp(8), 0, dp(8)) + textSize = 14f + setTextColor(getColor(R.color.text_secondary)) + setPadding(0, dp(6), 0, dp(8)) } addView(summary, matchWidth()) addView(Button(context).apply { text = getString(R.string.weekly_table) - usePrimaryActionStyle() + useFeatureActionStyle() setOnClickListener { startActivity(Intent(this@PlatoonActivity, WeeklyReportActivity::class.java)) } }, matchWidth()) addView(Button(context).apply { text = getString(R.string.compare_latest_snapshots) + useNavigationActionStyle() setOnClickListener { startActivity( Intent(this@PlatoonActivity, SnapshotComparisonActivity::class.java), @@ -116,10 +118,12 @@ class PlatoonActivity : LocalizedActivity() { }, matchWidth()) addView(Button(context).apply { text = getString(R.string.export_selected_members) + useNavigationActionStyle() setOnClickListener { exportSelectedMembers() } }, matchWidth()) addView(Button(context).apply { text = getString(R.string.add_withdrawn_member) + useNavigationActionStyle() setOnClickListener { showAddWithdrawnMemberDialog() } }, matchWidth()) searchInput = EditText(context).apply { @@ -228,31 +232,38 @@ class PlatoonActivity : LocalizedActivity() { if (checked) selectedUids += status.uid else selectedUids -= status.uid } }, LinearLayout.LayoutParams(wrap(), ViewGroup.LayoutParams.MATCH_PARENT)) - addView(Button(context).apply { - isAllCaps = false - text = buildString { - append(if (status.isActive) "● " else "○ ") + addView(ModernUi.actionRow( + context = context, + title = buildString { append(status.name) - append(" #") + append(" · ") + append( + getString( + if (status.isActive) R.string.active_member else R.string.departed_member, + ), + ) + }, + detail = buildString { + append("#") append(status.uid) if (latest != null) { - append("\n") + append(" · ") append(getString(R.string.merit_this_week)) - append(": ") + append(" ") append(latest.weeklyMerit) append(" · ") append(getString(R.string.total_merit)) - append(": ") + append(" ") append(latest.totalMerit) } - } - setOnClickListener { + }, + onClick = { startActivity( Intent(this@PlatoonActivity, MemberDetailActivity::class.java) .putExtra(MemberDetailActivity.EXTRA_UID, status.uid), ) - } - }, LinearLayout.LayoutParams(0, wrap(), 1f)) + }, + ), LinearLayout.LayoutParams(0, wrap(), 1f)) }, matchWidth()) } } diff --git a/app/src/main/java/dev/gf2log/app/PrimaryActionStyle.kt b/app/src/main/java/dev/gf2log/app/PrimaryActionStyle.kt index 4d86b95..43ee1b4 100644 --- a/app/src/main/java/dev/gf2log/app/PrimaryActionStyle.kt +++ b/app/src/main/java/dev/gf2log/app/PrimaryActionStyle.kt @@ -1,19 +1,29 @@ package dev.gf2log.app -import android.content.res.ColorStateList -import android.graphics.Typeface import android.widget.Button -fun Button.usePrimaryActionStyle() { - backgroundTintList = ColorStateList.valueOf(context.getColor(R.color.primary_action_background)) - setTextColor(context.getColor(R.color.primary_action_foreground)) - setTypeface(typeface, Typeface.BOLD) - minHeight = (48 * resources.displayMetrics.density).toInt() +fun Button.usePrimaryActionStyle() = useActionRole(ModernUi.ControlRole.PRIMARY) + +fun Button.useCaptureActionStyle() = useActionRole(ModernUi.ControlRole.CAPTURE) + +fun Button.useFeatureActionStyle() = useActionRole(ModernUi.ControlRole.FEATURE) + +fun Button.useSecondaryActionStyle() = useActionRole(ModernUi.ControlRole.SECONDARY) + +fun Button.useTertiaryActionStyle() = useActionRole(ModernUi.ControlRole.TERTIARY) + +fun Button.useNavigationActionStyle() = useActionRole(ModernUi.ControlRole.NAVIGATION) + +fun Button.useDestructiveActionStyle() = useActionRole(ModernUi.ControlRole.DESTRUCTIVE) + +fun Button.useDestructiveTextActionStyle() = useActionRole(ModernUi.ControlRole.DESTRUCTIVE_TEXT) + +fun Button.allowCompactMultilineLabel() { + setTag(R.id.gf2_ui_compact_multiline, true) + ModernUi.styleButton(this) } -fun Button.useDestructiveActionStyle() { - backgroundTintList = ColorStateList.valueOf(context.getColor(R.color.destructive_action_background)) - setTextColor(context.getColor(R.color.destructive_action_foreground)) - setTypeface(typeface, Typeface.BOLD) - minHeight = (48 * resources.displayMetrics.density).toInt() +private fun Button.useActionRole(role: ModernUi.ControlRole) { + setTag(R.id.gf2_ui_role, role) + ModernUi.styleButton(this) } diff --git a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt index d0814c5..6abdeb2 100644 --- a/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt +++ b/app/src/main/java/dev/gf2log/app/SnapshotComparisonActivity.kt @@ -24,7 +24,7 @@ class SnapshotComparisonActivity : LocalizedActivity() { addView(LinearLayout(context).apply { orientation = LinearLayout.VERTICAL setPadding(dp(16), dp(16), dp(16), dp(16)) - addView(heading(getString(R.string.snapshot_comparison), 28f)) + addView(heading(getString(R.string.snapshot_comparison), 24f)) if (snapshots.size < 2) { addView(text(getString(R.string.need_two_snapshots))) } else { @@ -41,9 +41,7 @@ class SnapshotComparisonActivity : LocalizedActivity() { ) addView(Button(context).apply { text = getString(R.string.copy_csv) - textSize = 12f - minHeight = 0 - setPadding(dp(12), dp(4), dp(12), dp(4)) + useTertiaryActionStyle() setOnClickListener { getSystemService(ClipboardManager::class.java).setPrimaryClip( ClipData.newPlainText( diff --git a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt index 5a58b1a..883f1e1 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklyReportActivity.kt @@ -16,6 +16,7 @@ import android.text.Spanned import android.text.TextWatcher import android.text.style.ForegroundColorSpan import android.view.Gravity +import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.Button @@ -268,7 +269,7 @@ class WeeklyReportActivity : LocalizedActivity() { text = getString( if (report.isGunsmokeWeek) R.string.gunsmoke_week else R.string.off_week, ) - textSize = 27f + textSize = 24f setTypeface(typeface, Typeface.BOLD) }, LinearLayout.LayoutParams(0, wrap(), 1f)) addView(ImageButton(context).apply { @@ -318,26 +319,26 @@ class WeeklyReportActivity : LocalizedActivity() { report.periodEnd.format(DATE), zone.id, ) - textSize = 15f + textSize = 14f + setTextColor(getColor(R.color.text_secondary)) }, matchWidth()) if (report.hasIncompleteDailyEvidence) { - body.addView(TextView(this).apply { - text = getString( - if (report.isGunsmokeWeek) { - R.string.incomplete_daily_evidence_gunsmoke - } else { - R.string.incomplete_daily_evidence_standard - }, - ) - setTextColor(WARNING_COLOR) - setPadding(0, dp(8), 0, dp(8)) - }, matchWidth()) + body.addView(ModernUi.actionRow( + context = this, + title = getString(R.string.weekly_evidence_guide), + detail = getString(R.string.weekly_evidence_guide_summary), + onClick = { showWeeklyEvidenceGuide(report.isGunsmokeWeek) }, + ), LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(8) }) } addEvidenceHealthPanel(report) body.addView(LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL addView(Button(context).apply { text = "\u2039" + useTertiaryActionStyle() isEnabled = !isEditing contentDescription = getString(R.string.previous_week) setOnClickListener { @@ -347,11 +348,13 @@ class WeeklyReportActivity : LocalizedActivity() { }, LinearLayout.LayoutParams(0, wrap(), 1f)) addView(Button(context).apply { text = getString(R.string.current_week) + useSecondaryActionStyle() isEnabled = !isEditing setOnClickListener { showDatePicker() } }, LinearLayout.LayoutParams(0, wrap(), 2f)) addView(Button(context).apply { text = "\u203a" + useTertiaryActionStyle() isEnabled = !isEditing contentDescription = getString(R.string.next_week) setOnClickListener { @@ -364,22 +367,26 @@ class WeeklyReportActivity : LocalizedActivity() { orientation = LinearLayout.HORIZONTAL addView(Button(context).apply { text = getString(R.string.export_weekly_csv) + useSecondaryActionStyle() isEnabled = !isEditing setOnClickListener { exportWeeklyCsv(report) } }, LinearLayout.LayoutParams(0, wrap(), 1f)) addView(Button(context).apply { text = getString(R.string.copy_weekly_csv) + useSecondaryActionStyle() isEnabled = !isEditing setOnClickListener { copyWeeklyCsv(report) } }, LinearLayout.LayoutParams(0, wrap(), 1f)) }, matchWidth()) body.addView(Button(this).apply { text = getString(R.string.export_all_weekly_tables) + useSecondaryActionStyle() isEnabled = !isEditing setOnClickListener { exportAllWeeklyTables() } }, matchWidth()) body.addView(Button(this).apply { text = getString(R.string.edit_member_order) + useNavigationActionStyle() isEnabled = !isEditing setOnClickListener { startActivity(Intent(this@WeeklyReportActivity, MemberOrderActivity::class.java)) @@ -989,12 +996,28 @@ class WeeklyReportActivity : LocalizedActivity() { private fun addEvidenceHealthPanel(report: WeeklyReportBuilder.Report) { val health = WeeklyEvidenceAnalyzer.health(report) - body.addView(TextView(this).apply { - text = buildString { - append(getString(R.string.evidence_health_title)) - append("\n") - append( - getString( + val statusColor = if (health.isComplete) R.color.success_text else R.color.warning_text + body.addView(LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + background = GradientDrawable().apply { + setColor(getColor(R.color.surface)) + cornerRadius = dp(14).toFloat() + } + addView(View(context).apply { + setBackgroundColor(getColor(statusColor)) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, LinearLayout.LayoutParams(dp(3), ViewGroup.LayoutParams.MATCH_PARENT)) + addView(LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(12), dp(8), dp(12), dp(8)) + addView(TextView(context).apply { + text = getString(R.string.evidence_health_title) + textSize = 14f + setTypeface(typeface, Typeface.BOLD) + setTextColor(getColor(statusColor)) + }, matchWidth()) + addView(TextView(context).apply { + text = getString( R.string.evidence_health_summary, health.observedDays, health.totalDays, @@ -1004,21 +1027,30 @@ class WeeklyReportActivity : LocalizedActivity() { health.directLoginDays, health.directPatrolDays, health.closingBoundaries, - ), - ) - } - textSize = 14f - setTextColor(getColor(if (health.isComplete) R.color.success_text else R.color.warning_text)) - setPadding(dp(12), dp(10), dp(12), dp(10)) - background = GradientDrawable().apply { - setColor(getColor(if (health.isComplete) R.color.success_surface else R.color.warning_surface)) - cornerRadius = dp(14).toFloat() - setStroke( - dp(1), - getColor(if (health.isComplete) R.color.success_text else R.color.warning_text), - ) - } - }, matchWidth()) + ) + textSize = 13f + setTextColor(getColor(R.color.text_secondary)) + setPadding(0, dp(2), 0, 0) + }, matchWidth()) + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + }, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(6) }) + } + + private fun showWeeklyEvidenceGuide(isGunsmokeWeek: Boolean) { + AlertDialog.Builder(this) + .setTitle(R.string.weekly_evidence_guide) + .setMessage( + if (isGunsmokeWeek) { + R.string.incomplete_daily_evidence_gunsmoke + } else { + R.string.incomplete_daily_evidence_standard + }, + ) + .setPositiveButton(android.R.string.ok, null) + .show() } private fun showEvidenceExplanation( @@ -1087,7 +1119,7 @@ class WeeklyReportActivity : LocalizedActivity() { val result = SpannableString("$label\n$value") if (highlighted) { result.setSpan( - ForegroundColorSpan(CUTLINE_YELLOW), + ForegroundColorSpan(getColor(R.color.cutline_mark)), label.length + 1, result.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, @@ -1111,20 +1143,20 @@ class WeeklyReportActivity : LocalizedActivity() { private fun activityMark(value: Boolean?, observed: Boolean, dayClosed: Boolean): ActivityMark = when { - value == true -> ActivityMark("\u2713", SUCCESS_GREEN) + value == true -> ActivityMark("\u2713", getColor(R.color.success_mark)) !observed -> ActivityMark("-", null) - value == false && dayClosed -> ActivityMark("\u00d7", FAILURE_RED) + value == false && dayClosed -> ActivityMark("\u00d7", getColor(R.color.failure_mark)) value == false -> ActivityMark("-", null) else -> ActivityMark("?", null) } private fun editableActivityMark(value: Boolean?): ActivityMark = when (value) { - true -> ActivityMark("\u2713", SUCCESS_GREEN) - false -> ActivityMark("\u00d7", FAILURE_RED) + true -> ActivityMark("\u2713", getColor(R.color.success_mark)) + false -> ActivityMark("\u00d7", getColor(R.color.failure_mark)) null -> ActivityMark("-", null) } - private fun unknownActivityMark() = ActivityMark("?", WARNING_COLOR) + private fun unknownActivityMark() = ActivityMark("?", getColor(R.color.warning_mark)) private fun showManualEditWarning(report: WeeklyReportBuilder.Report) { AlertDialog.Builder(this) @@ -1696,9 +1728,5 @@ class WeeklyReportActivity : LocalizedActivity() { private const val MEMBER_WIDTH = 120 private const val DAILY_WIDTH = 128 private const val MAX_VISIBLE_TABLE_ROWS = 6 - private val WARNING_COLOR = Color.rgb(255, 193, 7) - private val SUCCESS_GREEN = Color.rgb(45, 170, 75) - private val FAILURE_RED = Color.rgb(215, 60, 55) - private val CUTLINE_YELLOW = Color.rgb(232, 174, 22) } } diff --git a/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt b/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt index 037be27..bf912c9 100644 --- a/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt +++ b/app/src/main/java/dev/gf2log/app/WeeklySettingsActivity.kt @@ -1,7 +1,9 @@ package dev.gf2log.app +import android.graphics.Typeface import android.os.Bundle import android.text.InputType +import android.view.Gravity import android.view.ViewGroup import android.widget.Button import android.widget.CheckBox @@ -36,7 +38,8 @@ class WeeklySettingsActivity : LocalizedActivity() { addView(TextView(context).apply { text = getString(R.string.weekly_table_settings) - textSize = 26f + textSize = 24f + setTypeface(typeface, Typeface.BOLD) }, matchWidth()) addView(TextView(context).apply { text = getString(R.string.cutline_description) @@ -81,6 +84,7 @@ class WeeklySettingsActivity : LocalizedActivity() { addView(Button(context).apply { text = getString(R.string.save_settings) + usePrimaryActionStyle() setOnClickListener { save() } }, matchWidth()) } @@ -104,6 +108,7 @@ class WeeklySettingsActivity : LocalizedActivity() { checkBox = CheckBox(context).apply { text = getString(label) isChecked = value != null + gravity = Gravity.CENTER_VERTICAL }, value = EditText(context).apply { inputType = InputType.TYPE_CLASS_NUMBER @@ -111,6 +116,7 @@ class WeeklySettingsActivity : LocalizedActivity() { hint = getString(R.string.cutline_value) setText(value?.toString().orEmpty()) isEnabled = value != null + gravity = Gravity.CENTER_VERTICAL }, maximum = maximum, ) @@ -120,8 +126,10 @@ class WeeklySettingsActivity : LocalizedActivity() { addView(LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL isBaselineAligned = false - addView(input.checkBox, LinearLayout.LayoutParams(0, wrap(), 2f)) - addView(input.value, LinearLayout.LayoutParams(0, wrap(), 1f)) + gravity = Gravity.CENTER_VERTICAL + minimumHeight = dp(56) + addView(input.checkBox, LinearLayout.LayoutParams(0, dp(48), 2f)) + addView(input.value, LinearLayout.LayoutParams(0, dp(48), 1f)) }, matchWidth()) return input } diff --git a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt index 9c23a95..72396ab 100644 --- a/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt +++ b/app/src/main/java/dev/gf2log/app/capture/CaptureVpnService.kt @@ -105,7 +105,9 @@ class CaptureVpnService : VpnService() { } } if (recovery.isFailure) { - throw directResult.exceptionOrNull() ?: recovery.exceptionOrNull()!! + throw directResult.exceptionOrNull() + ?: recovery.exceptionOrNull() + ?: IllegalStateException("Roster ingestion and recovery both failed") } CaptureStatus.update("Recovered Platoon database from the completed roster CSV") markRosterCaptured() diff --git a/app/src/main/res/drawable/bg_button.xml b/app/src/main/res/drawable/bg_button.xml index fb16577..d6c6066 100644 --- a/app/src/main/res/drawable/bg_button.xml +++ b/app/src/main/res/drawable/bg_button.xml @@ -5,21 +5,21 @@ - + - + - + diff --git a/app/src/main/res/drawable/bg_edit_text.xml b/app/src/main/res/drawable/bg_edit_text.xml index 5e3e9ac..8d3b534 100644 --- a/app/src/main/res/drawable/bg_edit_text.xml +++ b/app/src/main/res/drawable/bg_edit_text.xml @@ -2,14 +2,14 @@ - + - + diff --git a/app/src/main/res/drawable/ic_calendar.xml b/app/src/main/res/drawable/ic_calendar.xml new file mode 100644 index 0000000..19c88c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_calendar.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_chevron_right.xml b/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 0000000..1d0f983 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_drag_handle.xml b/app/src/main/res/drawable/ic_drag_handle.xml new file mode 100644 index 0000000..bb7d6d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_handle.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_group.xml b/app/src/main/res/drawable/ic_group.xml new file mode 100644 index 0000000..5c20986 --- /dev/null +++ b/app/src/main/res/drawable/ic_group.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 84e7740..5e6728d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1,15 +1,17 @@ GF2logger + 캡처 워크스페이스 + 데이터 도구 + 멤버와 가입 이력 + 검토, 편집, 공유 선택한 게임 앱 하나의 프로토콜을 기기에서 캡처합니다. 원본 트래픽은 저장하지 않습니다. 설치된 게임 패키지 이름 캡처 준비 - 안내형 캡처 (자동 중지) - 유용한 페이로드 수집 상태를 보여 주며 명단, 활동, 가입/탈퇴 정보가 모두 도착하면 중지합니다. + 한 번만 캡처 %1$s 서클 명단\n%2$s 활동 근거\n%3$s 가입/탈퇴 변경 중지 설정 열기 - 서클 관리 열기 서클 관리 백업 내보내기 서클 관리 백업 가져오기 현재의 구조화된 서클 기록, 가입·탈퇴 시각, 비고를 대체합니다. 캡처를 중지한 상태로 유지하세요. @@ -32,12 +34,10 @@ 시작하기 %2$d개 중 %1$d 온보딩 언어 변경 - GF2logger 빠른 안내 - 캡처와 서클 관리에 필요한 화면을 다섯 페이지로 간단히 안내합니다. 메인 화면 선택한 GF2 앱에 대해 사용자가 승인한 캡처를 시작하고, 필요한 패킷 수집 현황을 한눈에 확인합니다. 캡처 준비는 사용자가 중지할 때까지 계속 실행됩니다. - 안내형 캡처는 멤버, 활동, 가입·탈퇴 패킷을 확인한 뒤 자동으로 중지합니다. + 한 번만 캡처는 멤버, 활동, 가입·탈퇴 패킷을 확인한 뒤 자동으로 중지합니다. 최근 및 저장한 패킷 기록은 이 기기의 앱 전용 공간에 보관됩니다. Android의 VPN 동의 화면이 표시됩니다. GF2logger는 선택한 앱만 관찰하며, TLS를 해독하거나 원본 네트워크 패킷을 저장하지 않습니다. 설정 @@ -112,6 +112,7 @@ 저장할 수 없습니다 가입 이력 가입 이력 추가 + 가입 이력 추가 멤버 삭제 멤버를 삭제하면 저장된 가입 이력, 비고와 주간 과업표 수정사항이 영구적으로 삭제됩니다. 이후 새 명단 패킷에서 멤버가 다시 추가될 수 있지만 삭제된 정보는 복구되지 않습니다. 멤버를 삭제했습니다 @@ -131,6 +132,8 @@ 지우기 중복되지 않는 양의 UID와 닉네임을 입력하고 가입·탈퇴 날짜를 선택한 뒤 탈퇴를 가입 이후로 설정하세요. 가입 이력 %1$d\n가입: %2$s\n탈퇴: %3$s + 가입 이력 %1$d + %1$s → %2$s 가입 이력 삭제 가입/탈퇴 이력을 삭제합니다. 한 번 삭제하면 되돌릴 수 없습니다. 계속하시겠습니까? 가입 이력을 삭제했습니다 @@ -295,6 +298,8 @@ 보관 기록 %1$d개를 삭제했습니다 근거 상태 + 근거 상태 읽는 법 + ✓ 확정 · ≥ 확인된 최솟값 · ? 알 수 없음 — 자세히 보기 관측 일수 %1$d/%2$d · 확정 %3$d · 최솟값 %4$d · 알 수 없음 %5$d\n직접 로그인 %6$d일 · 직접 과업 %7$d일 · 마감 경계 %8$d개 %1$s · %2$s · %3$s 판정: %1$s\n\n근거:\n%2$s diff --git a/app/src/main/res/values-night-v27/styles.xml b/app/src/main/res/values-night-v27/styles.xml new file mode 100644 index 0000000..691b4b8 --- /dev/null +++ b/app/src/main/res/values-night-v27/styles.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 07fd045..74dce0f 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -1,32 +1,39 @@ - #0B0B0C - #121214 - #1D1D20 - #29292D - #F5F5F2 - #AAA9A4 - #343438 - #77777C - #F5F5F2 - #1D1D20 - #6F8FFF - #202023 - #121214 - #1F3A2A - #9DDBB3 - #3B3120 - #F2D28C - #40F5F5F2 - #F5F5F2 - #0B0B0C - #FF8AA4 - #E18484 - #111214 - #1F3A2A - #9DDBB3 - #4F8E68 - #3B3120 - #F2D28C - #A8853A + #20211F + #292A27 + #33342F + #3D3E38 + #F5F0E8 + #BDB6AC + #44453F + #858077 + #F5F0E8 + #E49B68 + #493529 + #5A4030 + #F2B78E + #33342F + #292A27 + #2E3B30 + #39493B + #A9C9A8 + #A9C9A8 + #42362A + #D9B98D + #D9B98D + #E5A1A5 + #D6AA76 + #3DF1EDE5 + #E49B68 + #2B1D14 + #EEAE81 + #E5A1A5 + #C77F82 + #2F181A + #D99396 + #2B392D + #A9C9A8 + #40362A + #D9B98D diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index 52f293a..4cd7146 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 721cc99..44fda29 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,33 +1,40 @@ #111214 - #FAFAF8 - #FFFFFF - #F2F2EF - #E7E7E2 - #111112 - #626260 - #D4D4CF - #858580 - #111112 - #F2F2EF - #2F5BFF - #EFEFEB - #FFFFFF - #E7F6EC - #23693E - #FFF5D8 - #8A6200 - #22111112 - #111112 - #FFFFFF - #C43D5C - #C45B5B - #FFFFFF - #E4F4EA - #17633A - #7DBA93 - #FFF3D6 - #765311 - #D5B05A + #F7F4EF + #FFFCF8 + #EEEAE3 + #E5DFD7 + #25231F + #6C665F + #DED8CF + #918A81 + #25231F + #D7824B + #F6E4D7 + #EDD3C1 + #7D3F1E + #ECE7DF + #FFFCF8 + #E4EEE3 + #D4E3D3 + #3E6747 + #4B7955 + #F4E8D2 + #7D5D34 + #966A3B + #A85258 + #A8753F + #2426241F + #D7824B + #2B1D14 + #C8733D + #A75258 + #D98A8A + #C77777 + #321A1B + #E2EBDD + #3F6847 + #F3E6CE + #806039 diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml new file mode 100644 index 0000000..6b750ae --- /dev/null +++ b/app/src/main/res/values/ids.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 98431e5..cd0eb09 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,15 +1,17 @@ GF2logger + Capture workspace + Data tools + Members & history + Review, edit & share On-device protocol capture for one explicitly selected game package. Raw traffic is not stored. Installed game package name Prepare capture - Guided capture (auto-stop) - Shows useful payload progress and stops after roster, activity, and update payloads arrive. + One-time Capture %1$s Platoon roster\n%2$s Activity evidence\n%3$s Membership updates Stop Open payload options - Open Platoon management Export Platoon backup Import Platoon backup This replaces current structured Platoon history, membership dates, and notes. Capture must remain stopped. @@ -32,12 +34,10 @@ Get started %1$d of %2$d Change onboarding language - A compact guide to GF2logger - Five quick pages explain every screen you need for capture and Platoon management. Main page Start a consent-based capture for the selected GF2 package and see useful payload progress at a glance. Prepare capture keeps running until you stop it. - Guided capture checks roster, activity, and membership updates, then stops automatically. + One-time Capture checks roster, activity, and membership updates, then stops automatically. Recent and saved packet history stays private on this device. Android will show its VPN consent screen. GF2logger only observes the selected app, does not decrypt TLS, and never stores raw network packets. Settings @@ -112,6 +112,7 @@ Unable to save Membership history Add Membership History + Add period Delete member Deleting this member permanently removes saved history, notes, and weekly edits. A future roster packet may add the member again, but the deleted details cannot be recovered. Member deleted @@ -131,6 +132,8 @@ Clear Enter a unique positive UID and nickname, select both dates, and keep withdrawal after join. Membership period %1$d\nJoin: %2$s\nWithdrawal: %3$s + Period %1$d + %1$s → %2$s Delete membership period Delete this join and withdrawal record? This cannot be undone. Continue? Membership period deleted @@ -299,6 +302,8 @@ Deleted %1$d saved entries Evidence Health + How evidence states work + ✓ exact · ≥ confirmed minimum · ? unknown — tap for details Observed days %1$d/%2$d · Exact %3$d · Minimum %4$d · Unknown %5$d\nDirect login %6$d days · Direct patrol %7$d days · Closing boundaries %8$d %1$s · %2$s · %3$s Answer: %1$s\n\nWhy:\n%2$s diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 2ecb326..e9e29c0 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,6 +1,8 @@ -