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
diff --git a/.gitignore b/.gitignore
index 9451a5c..918b8a6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@ local.properties
*.pcapng
*.csv
*.json
+!/.impeccable/design.json
*.log
*.jks
*.keystore
@@ -21,6 +22,7 @@ keystore.properties
keystore.properties.*
.tmp-*/
.tmp-*
+/.impeccable/review/
# Operating-system metadata
desktop.ini
diff --git a/.impeccable/design.json b/.impeccable/design.json
new file mode 100644
index 0000000..0f6acc0
--- /dev/null
+++ b/.impeccable/design.json
@@ -0,0 +1,116 @@
+{
+ "schemaVersion": 2,
+ "generatedAt": "2026-08-16T23:16:52+09:00",
+ "title": "Design System: GF2logger",
+ "extensions": {
+ "colorMeta": {
+ "brand-orange": {
+ "role": "primary",
+ "displayName": "Muted Signal Orange",
+ "canonical": "#D7824B",
+ "tonalRamp": ["#3C2113", "#65351D", "#914D29", "#BC6838", "#D7824B", "#E8A477", "#F2C9AD", "#FBEDE2"]
+ },
+ "warm-paper": {
+ "role": "neutral",
+ "displayName": "Warm Paper",
+ "canonical": "#F7F4EF",
+ "tonalRamp": ["#272521", "#4A4741", "#6F6A62", "#969087", "#BBB5AC", "#D9D4CC", "#ECE8E1", "#F7F4EF"]
+ },
+ "soft-charcoal": {
+ "role": "neutral",
+ "displayName": "Soft Charcoal",
+ "canonical": "#20211F",
+ "tonalRamp": ["#111210", "#20211F", "#353632", "#555650", "#777871", "#9D9E96", "#C7C7C0", "#F0F0EA"]
+ },
+ "capture-green": {
+ "role": "tertiary",
+ "displayName": "Capture Green",
+ "canonical": "#4B7955",
+ "tonalRamp": ["#17261A", "#294330", "#3C6245", "#4B7955", "#6B966F", "#91B496", "#BDD2BF", "#E9F1E8"]
+ },
+ "stop-red": {
+ "role": "tertiary",
+ "displayName": "Stop Red",
+ "canonical": "#A85258",
+ "tonalRamp": ["#351619", "#5A292E", "#823E44", "#A85258", "#C5777C", "#D99DA0", "#EBC5C7", "#F9ECEC"]
+ }
+ },
+ "typographyMeta": {
+ "headline": { "displayName": "Headline", "purpose": "Screen and onboarding titles." },
+ "title": { "displayName": "Title", "purpose": "Sections, destinations, and prominent states." },
+ "body": { "displayName": "Body", "purpose": "Actions and operational explanations." },
+ "label": { "displayName": "Label", "purpose": "Navigation, metadata, and supporting text." }
+ },
+ "shadows": [],
+ "motion": [],
+ "breakpoints": []
+ },
+ "components": [
+ {
+ "name": "Primary Button",
+ "kind": "button",
+ "refersTo": "button-primary",
+ "description": "Commitment action such as Next or One-time Capture.",
+ "html": "",
+ "css": ".ds-btn-primary { min-height: 48px; padding: 0 16px; border: 0; border-radius: 14px; background: #D7824B; color: #25231F; font: 600 14px sans-serif; } .ds-btn-primary:hover { background: #C8733D; } .ds-btn-primary:focus-visible { outline: 2px solid #7D3F1E; outline-offset: 2px; } .ds-btn-primary:active { background: #C8733D; }"
+ },
+ {
+ "name": "Quiet List Row",
+ "kind": "custom",
+ "refersTo": "list-row",
+ "description": "Compact utility or settings action with optional supporting copy.",
+ "html": "
Packet viewerOpen saved packet data›
",
+ "css": ".ds-list-row { min-height: 60px; padding: 6px 8px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #DED8CF; background: #FFFCF8; color: #25231F; font: 14px sans-serif; } .ds-list-row span { display: grid; gap: 2px; } .ds-list-row small { color: #6C665F; font-size: 12px; } .ds-list-row:focus-within { outline: 2px solid #D7824B; outline-offset: -2px; }"
+ },
+ {
+ "name": "Segmented Choice",
+ "kind": "chip",
+ "description": "Native semantic choice group for language and theme.",
+ "html": "",
+ "css": ".ds-segment { min-height: 54px; padding: 3px; display: flex; border-radius: 14px; background: #EEEAE3; } .ds-segment button { flex: 1; min-height: 48px; border: 0; border-radius: 11px; background: transparent; color: #25231F; font: 600 14px sans-serif; } .ds-segment .ds-segment-active { background: #F6E4D7; color: #7D3F1E; } .ds-segment button:focus-visible { outline: 2px solid #D7824B; outline-offset: -2px; }"
+ },
+ {
+ "name": "Capture Status Panel",
+ "kind": "card",
+ "description": "The primary operational group for state, target, and capture actions.",
+ "html": "● Capture statusReady
Capture is stopped
",
+ "css": ".ds-capture { padding: 16px; border: 1px solid #44453F; border-radius: 16px; background: #292A27; color: #F5F0E8; font-family: sans-serif; } .ds-capture small { color: #E49B68; } .ds-capture h2 { margin: 12px 0 4px; color: #A9C9A8; } .ds-capture p { color: #BDB6AC; } .ds-capture div { display: flex; gap: 8px; } .ds-capture button { flex: 1; min-height: 48px; border: 1px solid #858077; border-radius: 14px; background: #33342F; color: #F5F0E8; } .ds-capture .ds-capture-primary { border: 0; background: #E49B68; color: #2B1D14; } .ds-capture button:focus-visible { outline: 2px solid #E49B68; outline-offset: 2px; } .ds-capture button:disabled { opacity: .55; }"
+ },
+ {
+ "name": "Primary Bottom Navigation",
+ "kind": "nav",
+ "refersTo": "bottom-navigation",
+ "description": "The only persistent navigation, limited to Home, Platoon, and Weekly.",
+ "html": "",
+ "css": ".ds-bottom-nav { height: 64px; display: flex; border-top: 1px solid #DED8CF; background: #FFFCF8; } .ds-bottom-nav button { flex: 1; border: 0; background: transparent; color: #6C665F; display: grid; place-content: center; gap: 2px; font: 500 11px sans-serif; } .ds-bottom-nav .ds-nav-active { color: #D7824B; } .ds-bottom-nav button:focus-visible { outline: 2px solid #D7824B; outline-offset: -2px; }"
+ }
+ ],
+ "narrative": {
+ "northStar": "The Evidence Field Instrument",
+ "overview": "GF2logger should feel like a quiet, dependable instrument used during and after a game session. Its hierarchy is operational rather than decorative: capture state comes first, Platoon and Weekly are unmistakable destinations, and data utilities stay compact and calm. Warm paper and soft charcoal replace sterile white and absolute black. Muted orange is rare enough to remain meaningful, while green and red communicate capture and destructive states only when paired with text or an icon.",
+ "keyCharacteristics": [
+ "Compact, status-led Android layouts.",
+ "Warm neutral fields with one restrained brand accent.",
+ "Flat tonal grouping, hairlines, and minimal shadow.",
+ "Equal care for English, Korean, and enlarged system text."
+ ],
+ "rules": [
+ { "name": "The One Accent Rule", "body": "Orange marks brand, selection, or commitment; it does not fill ordinary utility rows.", "section": "colors" },
+ { "name": "The State Is a Sentence Rule", "body": "Green, red, and warning tones never carry meaning alone.", "section": "colors" },
+ { "name": "The System Scale Rule", "body": "Never replace scalable text with text baked into an image.", "section": "typography" },
+ { "name": "The Flat Instrument Rule", "body": "A surface must earn separation through function before receiving a container.", "section": "elevation" }
+ ],
+ "dos": [
+ "Do keep capture state and next actions visible without scrolling on the primary screen.",
+ "Do preserve 48dp targets, native checked semantics, and Android Back behavior.",
+ "Do verify English, Korean, light/dark themes, system bars, and enlarged text on a physical device.",
+ "Do use orange sparingly for primary action and selection."
+ ],
+ "donts": [
+ "Don't use absolute black, stark white, gradients, glass effects, decorative blur, or deep shadows.",
+ "Don't turn every row into a large card or invent mockup-only settings.",
+ "Don't use color as the only status signal.",
+ "Don't move parser, capture, repository, or report policy into Activities while changing presentation."
+ ]
+ }
+}
diff --git a/.interface-design/system.md b/.interface-design/system.md
new file mode 100644
index 0000000..babdb69
--- /dev/null
+++ b/.interface-design/system.md
@@ -0,0 +1,69 @@
+# GF2logger interface system
+
+## Direction
+
+Use the approved three-screen composition recorded in `PRODUCT.md` as the
+visual ground truth. The product should feel like a compact field instrument:
+quiet, legible, evidence-led, and unmistakably Android rather than a generic
+card stack.
+
+## Layout grammar
+
+- Use a 4dp base grid. Default screen inset is 16dp; related copy is separated
+ by 4dp or 8dp; sections are separated by 16dp or 24dp.
+- Keep every touch target at least 48dp high, while using inset fills and
+ hairlines so the visible control can remain compact.
+- Home is status-led: app bar, capture status, two key destinations, quiet
+ utilities, recent evidence, then the persistent Home/Platoon/Weekly bar.
+- Settings uses a compact back app bar followed by grouped list rows. Do not
+ turn every setting into a card.
+- Onboarding uses a dark editorial field: top actions, emblem, title and short
+ explanation, progress, feature list, bottom Previous/Next actions, step label.
+- Preserve ordinary Android Back behavior. Bottom navigation is reserved for
+ Home, Platoon, and Weekly.
+
+## Color roles
+
+- Background: warm off-white in light mode; soft near-black charcoal in dark.
+- Surface: a subtle one-step tonal lift from the background.
+- Primary text: warm ink/ivory. Secondary text is lower-contrast but AA legible.
+- Brand/primary: muted orange, used sparingly for the selected destination,
+ commitment actions, and the emblem's star.
+- Success/capture: desaturated green with explicit status text or an icon.
+- Stop/destructive/failure: softened red with explicit text or an icon.
+- Avoid absolute black/white, gradients, glass effects, decorative blur, and
+ deep shadows.
+
+## Type
+
+- Use the Android system sans family so English and Korean share consistent
+ metrics and device font scaling.
+- Screen title: 22-24sp medium/bold.
+- Section title: 15-17sp medium.
+- Body/action label: 14-16sp regular/medium.
+- Supporting text and metadata: 12-13sp regular.
+- Keep centered copy to onboarding headlines and short descriptions; operational
+ and settings copy is start-aligned.
+
+## Component rules
+
+- Header icon actions: 48dp hit area with a quiet tonal or transparent surface.
+- Capture status: a bounded outlined/tonal panel with a small label, prominent
+ state, concise detail, and distinct Start/One-time/Stop actions.
+- Feature destinations: paired compact rows/cards with icon, title, supporting
+ text, and chevron. Orange marks their importance without filling the screen.
+- Utility/settings rows: 52-60dp, title plus optional supporting line, trailing
+ chevron or native checkbox/switch. Use dividers or common-region spacing.
+- Bottom navigation: three equal 56-64dp destinations; selected state uses
+ orange icon/label and unselected destinations remain neutral.
+- Onboarding: a compact language action remains available because locale is a
+ required first-use decision; Skip is always visible.
+
+## Accessibility and state
+
+- Controls expose native semantics and readable content descriptions.
+- Color never carries state alone. Pair capture, selection, warning, and error
+ colors with text and/or icons.
+- Pressed, disabled, selected, and busy states must remain visually distinct.
+- Review English and Korean on the physical phone, including onboarding pages,
+ light Settings, dark Home, system bars, and enlarged font behavior.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f0faf86..78b035b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,37 @@
All notable changes to mobileGF2logger are documented here.
+## 2.3.0 - 2026-08-17
+
+### Added
+
+- Add a five-page first-launch guide for Main, Settings, Platoon management,
+ weekly-table controls, and parsed-packet pages. The bilingual segmented
+ 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 a compact, black-and-white-first interface,
+ restrained accent color, flatter geometry, accessible touch targets, and
+ theme-aware packet and weekly-table cells.
+- Refine the bilingual onboarding copy around the actual capture, evidence,
+ backup, Discord, and weekly-table workflows, enlarge its page icons, and use
+ compact `1/5` progress labels in both languages.
+- 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-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.
+
## 2.2.0 - 2026-08-11
### Added
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..43624d0
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,182 @@
+---
+name: GF2logger
+description: A compact evidence-led field instrument for GF2 platoon leaders.
+colors:
+ brand-orange: "#D7824B"
+ warm-paper: "#F7F4EF"
+ paper-surface: "#FFFCF8"
+ warm-ink: "#25231F"
+ muted-ink: "#6C665F"
+ soft-charcoal: "#20211F"
+ charcoal-surface: "#292A27"
+ warm-ivory: "#F5F0E8"
+ capture-green: "#4B7955"
+ stop-red: "#A85258"
+typography:
+ headline:
+ fontFamily: "sans-serif"
+ fontSize: "24sp"
+ fontWeight: 700
+ lineHeight: 1.2
+ title:
+ fontFamily: "sans-serif"
+ fontSize: "16sp"
+ fontWeight: 600
+ lineHeight: 1.3
+ body:
+ fontFamily: "sans-serif"
+ fontSize: "14sp"
+ fontWeight: 400
+ lineHeight: 1.4
+ label:
+ fontFamily: "sans-serif"
+ fontSize: "12sp"
+ fontWeight: 500
+ lineHeight: 1.3
+rounded:
+ control: "14dp"
+ panel: "16dp"
+spacing:
+ xs: "4dp"
+ sm: "8dp"
+ md: "16dp"
+ lg: "24dp"
+components:
+ button-primary:
+ backgroundColor: "{colors.brand-orange}"
+ textColor: "{colors.warm-ink}"
+ typography: "{typography.body}"
+ rounded: "{rounded.control}"
+ height: "48dp"
+ list-row:
+ backgroundColor: "{colors.paper-surface}"
+ textColor: "{colors.warm-ink}"
+ typography: "{typography.body}"
+ height: "60dp"
+ bottom-navigation:
+ backgroundColor: "{colors.paper-surface}"
+ textColor: "{colors.muted-ink}"
+ typography: "{typography.label}"
+ height: "64dp"
+---
+
+# Design System: GF2logger
+
+## Overview
+
+**Creative North Star: "The Evidence Field Instrument"**
+
+GF2logger should feel like a quiet, dependable instrument used during and after a game session. Its hierarchy is operational rather than decorative: capture state comes first, Platoon and Weekly are unmistakable destinations, and data utilities stay compact and calm.
+
+Warm paper and soft charcoal replace sterile white and absolute black. Muted orange is rare enough to remain meaningful, while green and red communicate capture and destructive states only when paired with text or an icon.
+
+**Key Characteristics:**
+
+- Compact, status-led Android layouts.
+- Warm neutral fields with one restrained brand accent.
+- Flat tonal grouping, hairlines, and minimal shadow.
+- Equal care for English, Korean, and enlarged system text.
+
+## Colors
+
+The palette is warm, low-glare, and predominantly neutral; semantic color is never decorative.
+
+### Primary
+
+- **Muted Signal Orange:** The sole brand and commitment-action color, used for the selected destination, primary actions, progress, and the emblem star.
+
+### Neutral
+
+- **Warm Paper and Paper Surface:** Light-mode canvas and the single tonal lift used for grouped controls.
+- **Warm Ink and Muted Ink:** Primary and supporting copy on light surfaces.
+- **Soft Charcoal and Charcoal Surface:** Dark-mode canvas and its one-step tonal lift.
+- **Warm Ivory:** Primary copy on dark surfaces.
+
+### Tertiary
+
+- **Capture Green:** Readiness and successful capture state, always reinforced by copy or an icon.
+- **Stop Red:** Stop, destructive, failed, or dangerous actions, always reinforced by copy or an icon.
+
+**The One Accent Rule.** Orange marks brand, selection, or commitment; it does not fill ordinary utility rows.
+
+**The State Is a Sentence Rule.** Green, red, and warning tones never carry meaning alone.
+
+## Typography
+
+**Display Font:** Android system sans
+**Body Font:** Android system sans
+
+**Character:** Neutral system typography keeps Korean and English metrics predictable and follows the user's Android font scale without introducing a separate font dependency.
+
+### Hierarchy
+
+- **Headline** (bold, 22-24sp): Screen and onboarding titles.
+- **Title** (medium/bold, 15-17sp): Section headings, destination labels, and prominent states.
+- **Body** (regular/medium, 14-16sp): Actions and operational explanations.
+- **Label** (regular/medium, 11-13sp): Navigation, metadata, and supporting text.
+
+**The System Scale Rule.** Never replace scalable text with text baked into an image.
+
+## Layout
+
+Use a 4dp grid, a 16dp default screen inset, and 16-24dp between sections. Every actionable region is at least 48dp high even when its visible fill is inset. Home remains status-led; Settings uses grouped rows; onboarding keeps fixed top controls and bottom navigation around a flexible central explanation. Home, Platoon, and Weekly alone use the three-item bottom navigation.
+
+## Elevation & Depth
+
+The system is flat by default. Depth comes from a one-step tonal surface lift, 1dp hairlines, and spacing rather than deep shadows or nested cards.
+
+**The Flat Instrument Rule.** A surface must earn separation through function before receiving a container.
+
+## Shapes
+
+Controls use gently curved 14dp corners and bounded panels use 16dp corners. Hairline dividers and restrained outlines preserve the compact instrument-like character. Avoid pills unless the control is genuinely binary or segmented.
+
+## Components
+
+### Buttons
+
+- **Shape:** Gently curved controls with a minimum 48dp target.
+- **Primary:** Muted orange fill with warm dark text for commitment actions.
+- **Secondary:** Quiet tonal or outlined treatment for reversible actions.
+- **Destructive:** Soft red treatment with an explicit label; disabled states remain visually distinct.
+
+### Cards / Containers
+
+- **Corner Style:** Bounded panels use 16dp corners.
+- **Background:** One tonal step from the current theme background.
+- **Shadow Strategy:** No default shadow; use spacing and outline.
+- **Internal Padding:** 8-16dp according to content density.
+
+### Inputs / Fields
+
+- **Style:** Tonal or outlined field with a 48dp minimum target.
+- **Focus:** Clear selected/checked semantics plus the current theme's accent treatment.
+- **Error / Disabled:** State remains readable without relying on opacity alone.
+
+### Navigation
+
+Home, Platoon, and Weekly share a 64dp bottom bar. Each destination has a 24dp icon and label; the active destination uses orange while inactive destinations stay neutral. Settings is reached from Home and follows ordinary Android Back behavior.
+
+### Capture Status Panel
+
+Capture state, target package, and Start/One-time/Stop actions form one operational group. Start actions become unavailable while capture is preparing or active; Stop becomes unavailable while stopped.
+
+### Onboarding
+
+Five dark editorial pages present the emblem, one concise topic, progress, three feature statements, persistent language/Skip controls, and Previous/Next actions. Onboarding appears only on first launch, but the debug build exposes a non-production preview alias for visual verification.
+
+## Do's and Don'ts
+
+### Do:
+
+- **Do** keep capture state and next actions visible without scrolling on the primary screen.
+- **Do** preserve 48dp targets, native checked semantics, and Android Back behavior.
+- **Do** verify English, Korean, light/dark themes, system bars, and enlarged text on a physical device.
+- **Do** use orange sparingly for primary action and selection.
+
+### Don't:
+
+- **Don't** use absolute black, stark white, gradients, glass effects, decorative blur, or deep shadows.
+- **Don't** turn every row into a large card or invent mockup-only settings.
+- **Don't** use color as the only status signal.
+- **Don't** move parser, capture, repository, or report policy into Activities while changing presentation.
diff --git a/PRODUCT.md b/PRODUCT.md
new file mode 100644
index 0000000..022e53d
--- /dev/null
+++ b/PRODUCT.md
@@ -0,0 +1,83 @@
+# GF2logger product contract
+
+## Product and platform
+
+GF2logger is a native Android companion for GIRLS' FRONTLINE 2 platoon leaders.
+It observes one explicitly selected game package through Android's consent-based
+`VpnService`, parses only recognized plaintext GF2 payloads, and turns that
+evidence into private on-device packet history, platoon membership records, and
+weekly reports. It does not decrypt TLS, retain raw network packets, or run a
+remote collector.
+
+## Primary user and outcome
+
+The primary user is a platoon leader checking the app during or shortly after a
+game session. Their core job is to capture or import reliable evidence and see
+it reflected automatically in the roster and weekly table without manually
+reconciling packet details.
+
+The interface must answer three questions quickly:
+
+1. Is capture ready, running, complete, or stopped?
+2. Where do I manage the platoon and review the current week?
+3. What recent evidence is available, and what can I safely import or export?
+
+## Product positioning
+
+GF2logger is an evidence-aware platoon operations tool, not a general packet
+analyzer. Its value is the bounded pipeline from recognized payloads to stable
+membership history and weekly reporting, with explicit privacy boundaries and
+recoverable user-controlled backup and export.
+
+## Functional and architectural constraints
+
+- Keep the existing Kotlin/Android Views architecture. Do not introduce a
+ parallel UI framework or move repository, parser, capture, or report policy
+ into Activities.
+- Preserve English and Korean, System/Light/Dark appearance, Android Back
+ behavior, private app storage, and explicit user-driven share/export.
+- Keep the three primary destinations clear: Home, Platoon, and Weekly.
+- Settings is a secondary destination reached from Home and returns with normal
+ Android navigation.
+- The UI may reorganize real functionality, but must not invent mockup-only
+ settings or imply unsupported capture behavior.
+- All actionable controls retain at least a 48dp touch target and meaningful
+ accessibility labels. Motion is restrained and respects system animation
+ settings.
+
+## Brand and visual ground truth
+
+The approved three-screen composition at
+`C:/Users/alex1/AppData/Local/Temp/codex-clipboard-2fc2ac39-8f45-4c66-a753-4cf5ec1b2d44.png`
+is the binding visual direction for the v2.3.0 frontend:
+
+- warm paper-like off-white and soft charcoal are the dominant fields;
+- muted orange is the sole brand and primary-action accent;
+- green communicates capture readiness or success, and red is reserved for
+ stop, destructive, or failed states;
+- layouts are compact, status-led, flat, and separated with hairlines or tonal
+ surfaces instead of nested oversized cards;
+- Home uses a status dashboard, key Platoon/Weekly shortcuts, concise utilities,
+ recent evidence, and a three-destination bottom bar;
+- Settings uses a compact top bar and grouped list rows;
+- onboarding uses an editorial dark composition with the emblem, concise
+ feature guidance, visible progress, top-right Skip, and bottom navigation.
+
+The shipped emblem remains the existing launcher artwork at
+`app/src/main/res/drawable-nodpi/ic_launcher_art.png`.
+
+## Design principles
+
+1. Evidence truth before decoration.
+2. Compact scanability without sacrificing touch targets.
+3. Privacy and capture state are explicit in words and symbols.
+4. Real actions receive hierarchy; secondary utilities remain quiet.
+5. English and Korean layouts are first-class and are reviewed on device.
+
+## Repository evidence
+
+- `docs/ARCHITECTURE.md` defines capture, parser, management, and presentation
+ boundaries.
+- `docs/UI_DESIGN_SYSTEM.md` defines semantic control roles and accessibility
+ gates.
+- The approved composition above defines the current visual contract.
diff --git a/README.md b/README.md
index 27bdf23..ba2a3ee 100644
--- a/README.md
+++ b/README.md
@@ -18,14 +18,15 @@ capture to the selected game package, and raw traffic is never stored.
- Captures the mandatory Members (`21917`), Activity (`21935`), and Updates (`21960`) responses without a computer or root access.
- Tracks active and withdrawn members, repeat membership periods, exact Updates timestamps, editable nicknames, and private notes.
- Builds Sunday-to-Saturday Standard or Gunsmoke Frontline weekly tables around the 05:00 game reset, with cut-off points and manual correction for missing data.
-- Offers guided capture that tracks the three useful Platoon payloads and stops automatically when the checklist is complete.
+- Offers One-time Capture that tracks the three useful Platoon payloads and stops automatically when the checklist is complete.
- Explains every weekly cell on tap and summarizes missing or uncertain evidence in an Evidence Health panel.
- Recovers interrupted imports before previewing roster CSV impact and keeps an automatic one-level checkpoint for undo.
- Saves or shares a weekly PNG with opt-in controls for names, UIDs, and private notes.
- 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
@@ -35,7 +36,7 @@ uid,name,level,weeklyMerit,totalMerit,highScore,totalScore,lastLogin,logTime
## Use
1. Install the ARM64 APK on Android 8.0 or newer.
-2. Open **GF2logger**, confirm the game package, and select **Capture one Platoon roster**.
+2. Open **GF2logger**, confirm the game package, and select **One-time Capture**.
3. Approve Android's VPN prompt, then open the game.
4. Enter **Platoon(서클)** and open **Updates(동향)** and **Members(멤버)**.
5. Return to GF2logger to review the captured packets and Platoon data.
diff --git a/README_KR.md b/README_KR.md
index ee97837..9b07be6 100644
--- a/README_KR.md
+++ b/README_KR.md
@@ -13,14 +13,15 @@ mobileGF2logger는 서클장을 위한 가벼운 비루팅 앱입니다. Android
- 컴퓨터나 루트 권한 없이 필수 멤버(`21917`), 활동(`21935`), 동향(`21960`) 응답을 캡처합니다.
- 가입 및 탈퇴 멤버, 반복 가입 이력, 동향의 정확한 시각, 수정 가능한 닉네임과 개인 비고를 관리합니다.
- 05:00 게임 초기화 시각을 기준으로 일요일부터 토요일까지의 일반 주간 또는 흙먼지 주간 표를 만들며, 커트라인과 누락 데이터 수동 수정을 지원합니다.
-- 안내형 캡처는 세 가지 유용한 서클 페이로드의 수집 상태를 표시하고 체크리스트가 완료되면 자동으로 중지합니다.
+- 한 번만 캡처는 세 가지 유용한 서클 페이로드의 수집 상태를 표시하고 체크리스트가 완료되면 자동으로 중지합니다.
- 모든 주간 셀을 누르면 근거를 설명하고, 근거 상태 패널에서 누락되거나 불확실한 데이터를 요약합니다.
- 중단된 가져오기를 먼저 복구한 뒤 명단 CSV의 영향을 미리 보여 주고, 실행 취소용 1단계 자동 체크포인트를 보관합니다.
- 이름, UID, 비공개 메모의 포함 여부를 선택해 주간 PNG를 저장하거나 공유할 수 있습니다.
- 확인 후 검증된 원본 CSV를 사용자가 소유한 선택적 Discord 수신 웹훅으로 전송할 수 있습니다.
- 최근 파싱 패킷 100개와 저장 패킷 50개를 보관하며, 표 및 원본 보기, 복사, 내보내기, 선택, 삭제를 지원합니다.
- 멤버 정렬, 드래그 순서 유지, 최근 스냅샷 비교, 주간 CSV 내보내기, 서클 관리 데이터 백업 및 복원을 지원합니다.
-- 영어와 한국어를 지원하며 화면에 표시하는 시각에는 Android 기기의 시간대를 사용합니다.
+- 첫 사용 시 메인, 설정, 서클 관리, 주간 기능, 파싱 패킷 화면을 안내하며, 한국어/English 전환과 건너뛰기를 지원합니다.
+- 영어와 한국어, 시스템/라이트/다크 테마를 지원하며 화면에 표시하는 시각에는 Android 기기의 시간대를 사용합니다.
- 다음 열 순서의 UTF-8 서클 멤버 CSV 파일을 만듭니다.
```text
@@ -30,7 +31,7 @@ uid,name,level,weeklyMerit,totalMerit,highScore,totalScore,lastLogin,logTime
## 사용 방법
1. Android 8.0 이상 기기에 ARM64 APK를 설치합니다.
-2. **GF2logger**를 열고 게임 패키지를 확인한 뒤 **서클 명단 한 번 캡처**를 선택합니다.
+2. **GF2logger**를 열고 게임 패키지를 확인한 뒤 **한 번만 캡처**를 선택합니다.
3. Android의 VPN 요청을 승인한 뒤 게임을 엽니다.
4. **서클(Platoon)**에 들어가 **동향(Updates)**과 **멤버(Members)**를 엽니다.
5. GF2logger로 돌아와 캡처한 패킷과 서클 데이터를 확인합니다.
diff --git a/SECURITY.md b/SECURITY.md
index b930f9b..57233e0 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -5,6 +5,11 @@
Security fixes are made on the latest published release and the current
`main` branch. Older releases should be upgraded before a report is evaluated.
+| Version | Supported |
+| --- | --- |
+| 2.3.x | Yes |
+| 2.2.x and earlier | No |
+
## Reporting a vulnerability
Please do not publish exploit details, captured player data, backup archives,
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..2070be5
--- /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.3.0.
diff --git a/app/build.gradle b/app/build.gradle
index 29d1e1f..6d33781 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 = 20300
+ versionName = '2.3.0'
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/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/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..9a7ba9f
--- /dev/null
+++ b/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
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..925b185 100644
--- a/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt
+++ b/app/src/main/java/dev/gf2log/app/LocalizedActivity.kt
@@ -2,17 +2,40 @@ 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 = preferredTheme(newBase)
+ val localized = LanguagePreferences.wrap(newBase, attachedLanguage)
+ super.attachBaseContext(ThemePreferences.wrap(localized, attachedTheme))
}
+ /** Allows a purpose-built surface to use a stable presentation theme. */
+ protected open fun preferredTheme(context: Context): String = ThemePreferences.get(context)
+
override fun onResume() {
super.onResume()
- if (LanguagePreferences.get(this) != attachedLanguage) recreate()
+ if (
+ LanguagePreferences.get(this) != attachedLanguage ||
+ preferredTheme(this) != attachedTheme
+ ) {
+ recreate()
+ }
+ }
+
+ 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 eac9ace..34a7b2d 100644
--- a/app/src/main/java/dev/gf2log/app/MainActivity.kt
+++ b/app/src/main/java/dev/gf2log/app/MainActivity.kt
@@ -5,8 +5,8 @@ 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.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.net.Uri
@@ -47,10 +47,16 @@ import java.util.concurrent.Executors
class MainActivity : LocalizedActivity() {
private lateinit var packageNameInput: EditText
private lateinit var statusText: TextView
+ private lateinit var captureStateText: TextView
private lateinit var captureStatusText: TextView
+ private lateinit var prepareCaptureButton: Button
+ private lateinit var captureOnceButton: Button
+ private lateinit var stopCaptureButton: Button
private lateinit var guidedCaptureText: TextView
private lateinit var historyContainer: LinearLayout
+ private lateinit var historyActions: LinearLayout
private lateinit var savedHistoryContainer: LinearLayout
+ private lateinit var savedHistoryActions: LinearLayout
private lateinit var historyStore: CaptureHistoryStore
private lateinit var savedHistoryStore: SavedHistoryStore
private val selectedHistoryIds = linkedSetOf()
@@ -61,7 +67,7 @@ class MainActivity : LocalizedActivity() {
}
private val refreshStatus = object : Runnable {
override fun run() {
- captureStatusText.text = CaptureStatus.read()
+ renderCaptureStatus()
renderGuidedCaptureProgress()
statusHandler.postDelayed(this, STATUS_REFRESH_MILLIS)
}
@@ -72,6 +78,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,7 +95,8 @@ class MainActivity : LocalizedActivity() {
override fun onResume() {
super.onResume()
- captureStatusText.text = CaptureStatus.read()
+ if (!::captureStatusText.isInitialized) return
+ renderCaptureStatus()
refreshHistory()
statusHandler.postDelayed(refreshStatus, STATUS_REFRESH_MILLIS)
}
@@ -184,150 +196,268 @@ class MainActivity : LocalizedActivity() {
}
}
- private fun buildContentView(): ScrollView {
- val spacing = (16 * resources.displayMetrics.density).toInt()
+ private fun buildContentView(): View {
+ val spacing = dp(16)
val container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
- setPadding(spacing, spacing, spacing, spacing)
+ setPadding(spacing, dp(10), spacing, dp(20))
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 = 22f
setTypeface(typeface, Typeface.BOLD)
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
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))
}
}, LinearLayout.LayoutParams(dp(48), dp(48)))
}, 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))
- }, 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() }
- }, matchWidth())
+ addView(LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(10), dp(10), dp(10), dp(10))
+ background = ModernUi.panelBackground(context).apply {
+ setStroke(dp(1), getColor(R.color.outline))
+ cornerRadius = dp(8).toFloat()
+ }
+ addView(LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ addView(View(context).apply {
+ background = GradientDrawable().apply {
+ shape = GradientDrawable.OVAL
+ setColor(getColor(R.color.accent))
+ }
+ }, LinearLayout.LayoutParams(dp(8), dp(8)).apply { marginEnd = dp(8) })
+ addView(TextView(context).apply {
+ text = getString(R.string.capture_status_label)
+ textSize = 13f
+ typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
+ }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
+ }, matchWidth())
+ captureStateText = TextView(context).apply {
+ textSize = 22f
+ setTextColor(getColor(R.color.success_text))
+ setTypeface(typeface, Typeface.BOLD)
+ setPadding(0, dp(8), 0, 0)
+ }
+ addView(captureStateText, matchWidth())
+ captureStatusText = TextView(context).apply {
+ textSize = 13f
+ setTextColor(getColor(R.color.text_secondary))
+ setPadding(0, dp(1), 0, dp(8))
+ }
+ addView(captureStatusText, matchWidth())
+ addView(TextView(context).apply {
+ text = getString(R.string.capture_target)
+ textSize = 12f
+ setTextColor(getColor(R.color.text_secondary))
+ setPadding(0, dp(2), 0, dp(3))
+ }, matchWidth())
+ packageNameInput = EditText(context).apply {
+ hint = getString(R.string.target_package_hint)
+ setSingleLine(true)
+ textSize = 13f
+ setText(TargetPackagePreferences.get(context))
+ }
+ addView(packageNameInput, LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ dp(48),
+ ))
+ addView(LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ prepareCaptureButton = Button(context).apply {
+ text = getString(R.string.prepare_capture)
+ useCaptureActionStyle()
+ allowCompactMultilineLabel()
+ setOnClickListener { requestVpnAndStart(captureOnce = false) }
+ }
+ addView(prepareCaptureButton, LinearLayout.LayoutParams(0, dp(48), 1f).apply {
+ marginEnd = dp(3)
+ })
+ captureOnceButton = Button(context).apply {
+ text = getString(R.string.capture_one_roster)
+ usePrimaryActionStyle()
+ allowCompactMultilineLabel()
+ setOnClickListener { requestVpnAndStart(captureOnce = true) }
+ }
+ addView(captureOnceButton, LinearLayout.LayoutParams(0, dp(48), 1f).apply {
+ marginStart = dp(3)
+ marginEnd = dp(3)
+ })
+ stopCaptureButton = Button(context).apply {
+ text = getString(R.string.stop_capture)
+ useDestructiveActionStyle()
+ setOnClickListener { stopCaptureService() }
+ }
+ addView(stopCaptureButton, LinearLayout.LayoutParams(0, dp(48), 1f).apply {
+ marginStart = dp(3)
+ })
+ }, LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ).apply { topMargin = dp(4) })
+ guidedCaptureText = TextView(context).apply {
+ visibility = View.GONE
+ textSize = 12f
+ setTextColor(getColor(R.color.text_secondary))
+ setPadding(0, dp(4), 0, 0)
+ }
+ addView(guidedCaptureText, matchWidth())
+ renderCaptureStatus()
+ }, LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ).apply { topMargin = dp(8) })
- 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.listRow(context, getString(R.string.import_platoon_csv), icon = R.drawable.ic_edit) {
+ selectPlatoonCsvFiles()
+ },
+ ModernUi.listRow(context, getString(R.string.export_platoon_backup), icon = R.drawable.ic_save) {
+ exportPlatoonBackup()
+ },
+ ModernUi.listRow(context, getString(R.string.undo_last_csv_import), icon = R.drawable.ic_arrow_back) {
+ confirmUndoLastCsvImport()
+ },
+ ModernUi.listRow(context, getString(R.string.import_platoon_backup), icon = R.drawable.ic_save) {
+ confirmImportPlatoonBackup()
+ },
+ ).forEach { row ->
+ addView(row, matchWidth())
+ }
+
+ 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)
- setOnClickListener { deleteSelectedHistory() }
- }, matchWidth())
- addView(Button(context).apply {
- text = getString(R.string.save_selected_history)
- setOnClickListener { saveSelectedHistory() }
- }, matchWidth())
+ historyActions = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ 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(historyActions, 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)
- setOnClickListener { deleteSelectedSavedHistory() }
- }, matchWidth())
- addView(Button(context).apply {
- text = getString(R.string.export_latest_platoon_csv)
- setOnClickListener { exportLatestPlatoonCsv() }
- }, matchWidth())
+ savedHistoryActions = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ 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())
+ }
+ addView(savedHistoryActions, matchWidth())
}
- return ScrollView(this).apply { addView(container, matchWidth()) }
+ val scroll = ScrollView(this).apply { addView(container, matchWidth()) }
+ return PrimaryNavigation.wrap(this, scroll, PrimaryNavigation.Destination.HOME)
+ }
+
+ 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() }
+ }
+
+ private fun renderCaptureStatus() {
+ if (!::captureStateText.isInitialized || !::captureStatusText.isInitialized) return
+ val starting = CaptureStatus.isStarting
+ val running = CaptureStatus.isRunning
+ captureStateText.text = getString(
+ when {
+ starting -> R.string.status_preparing
+ running -> R.string.capture_running
+ else -> R.string.capture_ready
+ },
+ )
+ val status = CaptureStatus.read()
+ captureStatusText.text = when (status) {
+ "Capture is stopped" -> getString(R.string.capture_stopped_detail)
+ "Preparing capture" -> getString(R.string.status_preparing)
+ else -> status
+ }
+ captureStateText.contentDescription = captureStateText.text
+ captureStatusText.contentDescription = captureStatusText.text
+ val busy = starting || running
+ if (::packageNameInput.isInitialized) packageNameInput.isEnabled = !busy
+ if (::prepareCaptureButton.isInitialized) prepareCaptureButton.isEnabled = !busy
+ if (::captureOnceButton.isInitialized) captureOnceButton.isEnabled = !busy
+ if (::stopCaptureButton.isInitialized) stopCaptureButton.isEnabled = busy
}
// Function Name: renderGuidedCaptureProgress
@@ -366,13 +496,14 @@ class MainActivity : LocalizedActivity() {
}
private fun startCaptureService() {
+ CaptureStatus.markStarting()
+ renderCaptureStatus()
val intent = Intent(this, CaptureVpnService::class.java)
.setAction(CaptureVpnService.ACTION_START)
.putExtra(CaptureVpnService.EXTRA_TARGET_PACKAGE, packageNameInput.text.toString().trim())
.putExtra(CaptureVpnService.EXTRA_CAPTURE_ONCE, captureOnceRequested)
startForegroundService(intent)
captureOnceRequested = false
- captureStatusText.text = getString(R.string.status_preparing)
}
private fun stopCaptureService() {
@@ -380,7 +511,7 @@ class MainActivity : LocalizedActivity() {
.setAction(CaptureVpnService.ACTION_STOP)
startService(intent)
CaptureStatus.markStopped()
- captureStatusText.text = CaptureStatus.read()
+ renderCaptureStatus()
}
private fun requestNotificationPermissionIfNeeded() {
@@ -668,20 +799,25 @@ class MainActivity : LocalizedActivity() {
}
private fun refreshHistory() {
+ val recentEntries = historyStore.list()
renderHistoryEntries(
container = historyContainer,
- entries = historyStore.list(),
+ entries = recentEntries,
selectedIds = selectedHistoryIds,
saved = false,
emptyMessage = R.string.no_parsed_packets,
)
+ historyActions.visibility = if (recentEntries.isEmpty()) View.GONE else View.VISIBLE
+
+ val savedEntries = savedHistoryStore.list()
renderHistoryEntries(
container = savedHistoryContainer,
- entries = savedHistoryStore.list(),
+ entries = savedEntries,
selectedIds = selectedSavedHistoryIds,
saved = true,
emptyMessage = R.string.no_saved_packets,
)
+ savedHistoryActions.visibility = if (savedEntries.isEmpty()) View.GONE else View.VISIBLE
}
private fun renderHistoryEntries(
@@ -718,6 +854,7 @@ class MainActivity : LocalizedActivity() {
addView(Button(context).apply {
text = entry.title
isAllCaps = false
+ useNavigationActionStyle()
setOnClickListener {
startActivity(
Intent(this@MainActivity, PacketHistoryActivity::class.java)
@@ -731,13 +868,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..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(
@@ -199,7 +211,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..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(Color.rgb(90, 94, 101))
+ 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
new file mode 100644
index 0000000..92ed90e
--- /dev/null
+++ b/app/src/main/java/dev/gf2log/app/ModernUi.kt
@@ -0,0 +1,491 @@
+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.RadioGroup
+import android.widget.ScrollView
+import android.widget.TextView
+import androidx.annotation.DrawableRes
+
+/** 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)
+ applyControls(root)
+ }
+
+ fun panelBackground(context: Context, emphasized: Boolean = false): GradientDrawable =
+ GradientDrawable().apply {
+ setColor(context.getColor(if (emphasized) R.color.accent_surface else R.color.surface))
+ cornerRadius = context.dp(16).toFloat()
+ }
+
+ /** Flat list navigation used by dashboard utilities and grouped Settings rows. */
+ fun listRow(
+ context: Context,
+ title: CharSequence,
+ detail: CharSequence? = null,
+ @DrawableRes icon: Int? = null,
+ onClick: () -> Unit,
+ ): View = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ addView(AccessibleActionRow(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ minimumHeight = context.dp(if (detail.isNullOrBlank()) 52 else 60)
+ setPadding(context.dp(8), context.dp(5), context.dp(6), context.dp(5))
+ background = statefulSurface(
+ context = context,
+ normalColor = R.color.app_background,
+ pressedColor = R.color.surface_pressed,
+ disabledColor = R.color.app_background,
+ normalStroke = android.R.color.transparent,
+ pressedStroke = android.R.color.transparent,
+ radiusDp = 2,
+ )
+ contentDescription = listOfNotNull(title, detail?.takeIf(CharSequence::isNotBlank))
+ .joinToString(". ")
+ setOnClickListener { onClick() }
+ icon?.let { drawable ->
+ addView(ImageView(context).apply {
+ setImageResource(drawable)
+ imageTintList = ColorStateList.valueOf(context.getColor(R.color.text_secondary))
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+ }, LinearLayout.LayoutParams(context.dp(24), context.dp(24)).apply {
+ marginEnd = context.dp(12)
+ })
+ }
+ addView(LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+ addView(TextView(context).apply {
+ text = title
+ textSize = 14f
+ setTextColor(context.getColor(R.color.text_primary))
+ typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
+ maxLines = 2
+ 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 = 12f
+ setTextColor(context.getColor(R.color.text_secondary))
+ maxLines = 2
+ ellipsize = TextUtils.TruncateAt.END
+ }, 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)
+ imageTintList = ColorStateList.valueOf(context.getColor(R.color.text_secondary))
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+ }, LinearLayout.LayoutParams(context.dp(22), context.dp(22)).apply {
+ marginStart = context.dp(8)
+ })
+ }, ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ))
+ addView(View(context).apply {
+ setBackgroundColor(context.getColor(R.color.outline))
+ }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, context.dp(1)).apply {
+ marginStart = context.dp(if (icon == null) 8 else 44)
+ })
+ }
+
+ /**
+ * 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 = RadioGroup(context).apply {
+ orientation = RadioGroup.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(RadioButton(context).apply {
+ id = View.generateViewId()
+ text = label
+ buttonDrawable = null
+ isChecked = value == selectedValue
+ setTag(
+ R.id.gf2_ui_role,
+ if (value == selectedValue) ControlRole.SEGMENT_SELECTED else ControlRole.TERTIARY,
+ )
+ styleButton(this)
+ setOnClickListener { onSelected(value) }
+ }, RadioGroup.LayoutParams(0, context.dp(48), 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 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 Button -> styleButton(view)
+ is EditText -> {
+ view.elevation = 0f
+ view.typeface = Typeface.create("sans-serif", Typeface.NORMAL)
+ }
+ 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
+ screen.setBackgroundColor(screen.context.getColor(R.color.app_background))
+ if (screen is ScrollView) {
+ screen.isFillViewport = true
+ screen.clipToPadding = false
+ }
+ }
+
+ 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),
+ intArrayOf(android.R.attr.state_checked),
+ intArrayOf(),
+ ),
+ intArrayOf(
+ 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(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
new file mode 100644
index 0000000..1721118
--- /dev/null
+++ b/app/src/main/java/dev/gf2log/app/OnboardingActivity.kt
@@ -0,0 +1,418 @@
+package dev.gf2log.app
+
+import android.animation.ValueAnimator
+import android.content.Context
+import android.content.Intent
+import android.content.res.ColorStateList
+import android.graphics.Typeface
+import android.graphics.drawable.GradientDrawable
+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.RadioButton
+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
+
+ /** Onboarding keeps the approved editorial dark surface in every device theme. */
+ override fun preferredTheme(context: Context): String = ThemePreferences.DARK
+
+ 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)
+ }
+
+ /** Builds the stable header, content host, and bottom navigation frame. */
+ private fun buildContent(): View = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(20), dp(8), dp(20), dp(12))
+
+ addView(LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ addView(
+ OnboardingLanguageToggle(context, LanguagePreferences.get(context)) { language ->
+ if (LanguagePreferences.get(this@OnboardingActivity) != language) {
+ LanguagePreferences.set(this@OnboardingActivity, language)
+ recreate()
+ }
+ },
+ LinearLayout.LayoutParams(dp(120), dp(54)),
+ )
+ addView(View(context), LinearLayout.LayoutParams(0, dp(1), 1f))
+ skipButton = Button(context).apply {
+ text = getString(R.string.onboarding_skip)
+ contentDescription = getString(R.string.onboarding_skip)
+ useTertiaryActionStyle()
+ setOnClickListener { finishOnboarding() }
+ }
+ addView(skipButton, LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ dp(48),
+ ))
+ }, matchWidth())
+
+ pageHost = FrameLayout(context)
+ addView(pageHost, LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ 0,
+ 1f,
+ ))
+
+ addView(LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ backButton = Button(context).apply {
+ text = getString(R.string.onboarding_back)
+ useSecondaryActionStyle()
+ setOnClickListener {
+ if (pageIndex > 0) {
+ pageIndex--
+ renderPage(animate = true, direction = -1)
+ }
+ }
+ }
+ addView(backButton, LinearLayout.LayoutParams(0, dp(48), 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, dp(48), 1f).apply {
+ marginStart = dp(6)
+ })
+ }, matchWidth())
+
+ stepLabel = TextView(context).apply {
+ gravity = Gravity.CENTER
+ textSize = 12f
+ setTextColor(getColor(R.color.text_secondary))
+ setPadding(0, dp(5), 0, 0)
+ }
+ addView(stepLabel, matchWidth())
+ }
+
+ /** Replaces one page while keeping progress and action state synchronized. */
+ 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 && ValueAnimator.areAnimatorsEnabled()) {
+ content.alpha = 0f
+ content.translationX = (direction * dp(12)).toFloat()
+ content.animate().alpha(1f).translationX(0f).setDuration(140L).start()
+ }
+ stepLabel.text = getString(R.string.onboarding_step, pageIndex + 1, PAGES.size)
+ stepLabel.contentDescription = stepLabel.text
+ backButton.isEnabled = pageIndex > 0
+ nextButton.text = getString(
+ if (pageIndex == PAGES.lastIndex) R.string.onboarding_finish else R.string.onboarding_next,
+ )
+ nextButton.isEnabled = true
+ nextButton.usePrimaryActionStyle()
+ skipButton.visibility = View.VISIBLE
+ }
+
+ /** Builds one scroll-safe page from localized product copy and authored icon roles. */
+ 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(0, dp(12), 0, dp(8))
+
+ addView(FrameLayout(context).apply {
+ background = GradientDrawable().apply {
+ shape = GradientDrawable.OVAL
+ setColor(getColor(R.color.app_background))
+ setStroke(dp(1), getColor(R.color.outline_strong))
+ }
+ addView(ImageView(context).apply {
+ setImageResource(page.icon)
+ imageTintList = if (page.icon == R.mipmap.ic_launcher) null else
+ ColorStateList.valueOf(getColor(R.color.text_primary))
+ scaleType = ImageView.ScaleType.CENTER_INSIDE
+ setPadding(dp(16), dp(16), dp(16), dp(16))
+ }, FrameLayout.LayoutParams(dp(92), dp(92), Gravity.CENTER))
+ }, LinearLayout.LayoutParams(dp(112), dp(112)).apply { bottomMargin = dp(16) })
+
+ if (pageIndex == 0) {
+ addView(TextView(context).apply {
+ text = getString(R.string.onboarding_welcome_prefix)
+ gravity = Gravity.CENTER
+ textSize = 15f
+ setTextColor(getColor(R.color.accent))
+ setTypeface(typeface, Typeface.BOLD)
+ }, matchWidth())
+ }
+ addView(TextView(context).apply {
+ text = if (pageIndex == 0) getString(R.string.app_name) else getString(page.title)
+ gravity = Gravity.CENTER
+ textSize = 28f
+ setTypeface(typeface, Typeface.BOLD)
+ }, matchWidth())
+ addView(TextView(context).apply {
+ text = getString(page.description)
+ gravity = Gravity.CENTER
+ textSize = 14f
+ setTextColor(getColor(R.color.text_secondary))
+ setPadding(dp(12), dp(7), dp(12), dp(14))
+ }, matchWidth())
+ addView(progressView(), LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ dp(24),
+ ).apply { bottomMargin = dp(8) })
+ page.features.forEachIndexed { index, feature ->
+ addView(featureRow(feature, page.featureIcons[index]), matchWidth())
+ }
+ page.warning?.let { warning ->
+ addView(TextView(context).apply {
+ text = getString(warning)
+ textSize = 13f
+ setTextColor(getColor(R.color.warning_text))
+ setPadding(dp(12), dp(9), dp(12), dp(9))
+ background = ModernUi.panelBackground(context, emphasized = true).apply {
+ setColor(getColor(R.color.warning_surface))
+ cornerRadius = dp(8).toFloat()
+ }
+ }, LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ).apply { topMargin = dp(10) })
+ }
+ }, matchWidth())
+ }
+
+ private fun progressView(): View = LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER
+ PAGES.indices.forEach { index ->
+ addView(View(context).apply {
+ background = GradientDrawable().apply {
+ cornerRadius = dp(2).toFloat()
+ setColor(getColor(if (index == pageIndex) R.color.accent else R.color.outline_strong))
+ }
+ }, LinearLayout.LayoutParams(dp(if (index == pageIndex) 24 else 18), dp(4)).apply {
+ marginStart = dp(4)
+ marginEnd = dp(4)
+ })
+ }
+ }
+
+ private fun featureRow(@StringRes feature: Int, @DrawableRes icon: Int): View =
+ LinearLayout(this).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ minimumHeight = dp(48)
+ setPadding(dp(8), dp(4), dp(8), dp(4))
+ addView(ImageView(context).apply {
+ setImageResource(icon)
+ imageTintList = ColorStateList.valueOf(getColor(R.color.text_secondary))
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+ }, LinearLayout.LayoutParams(dp(26), dp(26)))
+ addView(TextView(context).apply {
+ text = getString(feature)
+ textSize = 14f
+ setTextColor(getColor(R.color.text_primary))
+ setPadding(dp(12), 0, 0, 0)
+ }, 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,
+ val featureIcons: 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,
+ ),
+ listOf(R.drawable.ic_home, R.drawable.ic_group, R.drawable.ic_calendar),
+ 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,
+ ),
+ listOf(R.drawable.ic_settings, R.drawable.ic_save, R.drawable.ic_discord),
+ 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,
+ ),
+ listOf(R.drawable.ic_group, R.drawable.ic_edit, R.drawable.ic_save),
+ R.drawable.ic_group,
+ ),
+ 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,
+ ),
+ listOf(R.drawable.ic_calendar, R.drawable.ic_edit, R.drawable.ic_share),
+ R.drawable.ic_calendar,
+ ),
+ 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,
+ ),
+ listOf(R.drawable.ic_save, R.drawable.ic_discord, R.drawable.ic_calendar),
+ 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 var selectedLanguage = initialLanguage
+ private val english = label("English", LanguagePreferences.DEFAULT_LANGUAGE)
+ private val korean = label("한국어", LanguagePreferences.KOREAN)
+
+ init {
+ setPadding(dp(3), dp(3), dp(3), dp(3))
+ background = ModernUi.panelBackground(context).apply { cornerRadius = dp(12).toFloat() }
+ thumb.background = ModernUi.panelBackground(context, emphasized = true).apply {
+ cornerRadius = dp(9).toFloat()
+ }
+ addView(thumb)
+ addView(english)
+ addView(korean)
+ post { select(selectedLanguage, false) }
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ val width = resolveSize(dp(120), widthMeasureSpec)
+ val height = resolveSize(dp(54), 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, language: String) = RadioButton(context).apply {
+ text = textValue
+ buttonDrawable = null
+ gravity = Gravity.CENTER
+ textSize = 12f
+ setTypeface(typeface, Typeface.BOLD)
+ minimumHeight = dp(48)
+ isChecked = selectedLanguage == language
+ setOnClickListener { select(language, true) }
+ }
+
+ 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
+ english.isChecked = language == LanguagePreferences.DEFAULT_LANGUAGE
+ korean.isChecked = language == LanguagePreferences.KOREAN
+ if (animate && ValueAnimator.areAnimatorsEnabled()) {
+ thumb.animate().cancel()
+ thumb.animate()
+ .translationX(target)
+ .setDuration(140L)
+ .withEndAction { onLanguageChanged(language) }
+ .start()
+ } else {
+ thumb.translationX = target
+ if (animate) onLanguageChanged(language)
+ }
+ 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)
+ }
+
+ 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..bc114fc 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
@@ -14,12 +13,12 @@ import android.os.Looper
import android.provider.OpenableColumns
import android.text.InputType
import android.view.ViewGroup
+import android.view.Gravity
import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText
+import android.widget.ImageButton
import android.widget.LinearLayout
-import android.widget.RadioButton
-import android.widget.RadioGroup
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
@@ -91,68 +90,100 @@ class OptionsActivity : LocalizedActivity() {
val preferences = PayloadHistoryPreferences(this)
val container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
- setPadding(spacing, spacing, spacing, spacing)
+ setPadding(spacing, dp(8), spacing, spacing)
+
+ addView(LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ addView(ImageButton(context).apply {
+ setImageResource(R.drawable.ic_arrow_back)
+ contentDescription = getString(R.string.back)
+ useModernIconStyle()
+ setPadding(dp(11), dp(11), dp(11), dp(11))
+ setOnClickListener { finish() }
+ }, LinearLayout.LayoutParams(dp(48), dp(48)).apply { marginEnd = dp(4) })
+ addView(TextView(context).apply {
+ text = getString(R.string.payload_options)
+ textSize = 22f
+ setTypeface(typeface, Typeface.BOLD)
+ }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
+ }, matchWidth())
addView(TextView(context).apply {
- text = getString(R.string.payload_options)
- textSize = 26f
+ text = getString(R.string.language)
+ textSize = 15f
setTypeface(typeface, Typeface.BOLD)
+ setPadding(0, dp(10), 0, dp(2))
}, matchWidth())
addView(TextView(context).apply {
- text = getString(R.string.payload_options_description)
- textSize = 15f
- setPadding(0, dp(8), 0, spacing)
+ text = getString(R.string.settings_language_detail)
+ textSize = 12f
+ setTextColor(getColor(R.color.text_secondary))
+ setPadding(0, 0, 0, dp(6))
}, matchWidth())
+ addView(ModernUi.segmentedControl(
+ context = context,
+ options = listOf(
+ LanguagePreferences.KOREAN to getString(R.string.language_korean_native),
+ LanguagePreferences.DEFAULT_LANGUAGE to getString(R.string.language_english),
+ ),
+ selectedValue = LanguagePreferences.get(context),
+ onSelected = ::changeLanguage,
+ ), matchWidth())
addView(TextView(context).apply {
- text = getString(R.string.language)
- textSize = 20f
+ text = getString(R.string.appearance)
+ textSize = 15f
setTypeface(typeface, Typeface.BOLD)
- setPadding(0, 0, 0, dp(4))
+ setPadding(0, spacing, 0, dp(2))
}, 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) }
- })
+ addView(TextView(context).apply {
+ text = getString(R.string.settings_appearance_detail)
+ textSize = 12f
+ setTextColor(getColor(R.color.text_secondary))
+ setPadding(0, 0, 0, dp(6))
}, 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 = 15f
setTypeface(typeface, Typeface.BOLD)
- setPadding(0, spacing, 0, dp(4))
- }, matchWidth())
- addView(Button(context).apply {
- text = getString(R.string.restore_full_backup)
- setOnClickListener { confirmFullRestore() }
+ setPadding(0, spacing, 0, dp(2))
}, 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)
+ text = getString(R.string.settings_backup_detail)
+ textSize = 12f
+ setTextColor(getColor(R.color.text_secondary))
+ setPadding(0, 0, 0, dp(3))
}, matchWidth())
+ addView(ModernUi.listRow(
+ context,
+ getString(R.string.restore_full_backup),
+ getString(R.string.restore_full_backup_description),
+ R.drawable.ic_arrow_back,
+ ::confirmFullRestore,
+ ), matchWidth())
+ addView(ModernUi.listRow(
+ context,
+ getString(R.string.back_up_all_information),
+ getString(R.string.back_up_all_information_description),
+ R.drawable.ic_save,
+ ::exportFullBackup,
+ ), matchWidth())
addView(TextView(context).apply {
text = getString(R.string.discord_webhook)
- textSize = 20f
+ textSize = 15f
setTypeface(typeface, Typeface.BOLD)
val discordIcon = context.getDrawable(R.drawable.ic_discord)?.mutate()
discordIcon?.setTint(currentTextColor)
@@ -164,7 +195,7 @@ class OptionsActivity : LocalizedActivity() {
}, matchWidth())
addView(TextView(context).apply {
text = getString(R.string.discord_webhook_description)
- textSize = 14f
+ textSize = 13f
setPadding(0, 0, 0, dp(8))
}, matchWidth())
val webhookStore = DiscordWebhookSecretStore(context)
@@ -189,16 +220,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())
@@ -210,6 +235,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())
@@ -228,6 +254,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
@@ -242,7 +269,7 @@ class OptionsActivity : LocalizedActivity() {
addView(TextView(context).apply {
text = getString(R.string.payload_history)
- textSize = 20f
+ textSize = 15f
setTypeface(typeface, Typeface.BOLD)
setPadding(0, spacing, 0, dp(4))
}, matchWidth())
@@ -268,13 +295,13 @@ class OptionsActivity : LocalizedActivity() {
payloadName(category.payloadType),
category.payloadType,
)
- textSize = 17f
+ textSize = 14f
isChecked = preferences.isEnabled(category.payloadType)
isEnabled = !category.isRequired
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 ->
@@ -297,7 +324,7 @@ class OptionsActivity : LocalizedActivity() {
addView(TextView(context).apply {
text = getString(R.string.last_capture_diagnostics)
- textSize = 20f
+ textSize = 15f
setTypeface(typeface, Typeface.BOLD)
setPadding(0, spacing, 0, dp(4))
}, matchWidth())
@@ -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..a5e4a38 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
@@ -70,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
@@ -237,14 +245,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/PlatoonActivity.kt b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt
index df4a8c7..0b945d0 100644
--- a/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt
+++ b/app/src/main/java/dev/gf2log/app/PlatoonActivity.kt
@@ -47,7 +47,13 @@ class PlatoonActivity : LocalizedActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
repository = PlatoonRepository(this)
- setContentView(buildContentView())
+ setContentView(
+ PrimaryNavigation.wrap(
+ this,
+ buildContentView(),
+ PrimaryNavigation.Destination.PLATOON,
+ ),
+ )
}
override fun onResume() {
@@ -91,23 +97,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 +124,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 +238,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/PrimaryNavigation.kt b/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt
new file mode 100644
index 0000000..4931d62
--- /dev/null
+++ b/app/src/main/java/dev/gf2log/app/PrimaryNavigation.kt
@@ -0,0 +1,129 @@
+package dev.gf2log.app
+
+import android.app.Activity
+import android.content.Intent
+import android.content.res.ColorStateList
+import android.graphics.Typeface
+import android.graphics.drawable.GradientDrawable
+import android.util.TypedValue
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+
+/** Shared Home, Platoon, and Weekly navigation shell for the three primary destinations. */
+internal object PrimaryNavigation {
+ enum class Destination {
+ HOME,
+ PLATOON,
+ WEEKLY,
+ }
+
+ fun wrap(activity: Activity, content: View, selected: Destination): View =
+ LinearLayout(activity).apply {
+ orientation = LinearLayout.VERTICAL
+ setBackgroundColor(activity.getColor(R.color.app_background))
+ addView(content, LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ 0,
+ 1f,
+ ))
+ addView(View(activity).apply {
+ setBackgroundColor(activity.getColor(R.color.outline))
+ }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, activity.dp(1)))
+ addView(navigationBar(activity, selected), LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ activity.dp(64),
+ ))
+ }
+
+ private fun navigationBar(activity: Activity, selected: Destination): View =
+ LinearLayout(activity).apply {
+ orientation = LinearLayout.HORIZONTAL
+ setBackgroundColor(activity.getColor(R.color.surface))
+ addDestination(
+ activity,
+ Destination.HOME,
+ selected,
+ R.drawable.ic_home,
+ R.string.nav_home,
+ MainActivity::class.java,
+ )
+ addDestination(
+ activity,
+ Destination.PLATOON,
+ selected,
+ R.drawable.ic_group,
+ R.string.nav_platoon,
+ PlatoonActivity::class.java,
+ )
+ addDestination(
+ activity,
+ Destination.WEEKLY,
+ selected,
+ R.drawable.ic_calendar,
+ R.string.nav_weekly,
+ WeeklyReportActivity::class.java,
+ )
+ }
+
+ private fun LinearLayout.addDestination(
+ activity: Activity,
+ destination: Destination,
+ selected: Destination,
+ @DrawableRes icon: Int,
+ @StringRes label: Int,
+ activityClass: Class,
+ ) {
+ val active = destination == selected
+ val color = activity.getColor(if (active) R.color.accent else R.color.text_secondary)
+ addView(LinearLayout(activity).apply {
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER
+ isClickable = !active
+ isFocusable = true
+ isSelected = active
+ contentDescription = activity.getString(label)
+ background = activity.selectableBackground()
+ setPadding(0, activity.dp(6), 0, activity.dp(4))
+ addView(ImageView(activity).apply {
+ setImageResource(icon)
+ imageTintList = ColorStateList.valueOf(color)
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+ }, LinearLayout.LayoutParams(activity.dp(24), activity.dp(24)))
+ addView(TextView(activity).apply {
+ text = activity.getString(label)
+ textSize = 11f
+ setTextColor(color)
+ typeface = Typeface.create(
+ "sans-serif-medium",
+ if (active) Typeface.BOLD else Typeface.NORMAL,
+ )
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+ }, LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ).apply { topMargin = activity.dp(2) })
+ if (!active) {
+ setOnClickListener {
+ activity.startActivity(
+ Intent(activity, activityClass).addFlags(
+ Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP,
+ ),
+ )
+ }
+ }
+ }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f))
+ }
+
+ private fun Activity.selectableBackground() = TypedValue().let { value ->
+ theme.resolveAttribute(android.R.attr.selectableItemBackground, value, true)
+ getDrawable(value.resourceId) ?: GradientDrawable().apply {
+ setColor(getColor(R.color.surface))
+ }
+ }
+}
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/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..a550519 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
@@ -132,7 +133,13 @@ class WeeklyReportActivity : LocalizedActivity() {
orientation = LinearLayout.VERTICAL
setPadding(dp(16), dp(16), dp(16), dp(16))
}
- setContentView(ScrollView(this).apply { addView(body, matchWidth()) })
+ setContentView(
+ PrimaryNavigation.wrap(
+ this,
+ ScrollView(this).apply { addView(body, matchWidth()) },
+ PrimaryNavigation.Destination.WEEKLY,
+ ),
+ )
}
override fun onResume() {
@@ -268,12 +275,12 @@ 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 {
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 +295,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 +306,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
@@ -318,26 +325,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 +354,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 +373,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))
@@ -970,14 +983,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 {
@@ -989,12 +1002,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,17 +1033,30 @@ class WeeklyReportActivity : LocalizedActivity() {
health.directLoginDays,
health.directPatrolDays,
health.closingBoundaries,
- ),
- )
- }
- textSize = 14f
- setTextColor(if (health.isComplete) Color.rgb(35, 105, 62) else WARNING_COLOR)
- 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()
- }
- }, 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(
@@ -1083,7 +1125,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,
@@ -1107,20 +1149,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)
@@ -1669,13 +1711,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,12 +1734,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 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)
- 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/CaptureStatus.kt b/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt
index 16dc0a2..63bb654 100644
--- a/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt
+++ b/app/src/main/java/dev/gf2log/app/capture/CaptureStatus.kt
@@ -10,6 +10,10 @@ object CaptureStatus {
var isRunning: Boolean = false
private set
+ @Volatile
+ var isStarting: Boolean = false
+ private set
+
@Volatile
private var guidedProgress: GuidedProgress? = null
@@ -23,12 +27,20 @@ object CaptureStatus {
fun markRunning(message: String, guided: Boolean = guidedProgress != null) {
isRunning = true
+ isStarting = false
this.message = message
if (guided && guidedProgress == null) guidedProgress = GuidedProgress()
}
+ fun markStarting(message: String = "Preparing capture") {
+ isRunning = false
+ isStarting = true
+ this.message = message
+ }
+
fun markStopped(message: String = DEFAULT_MESSAGE) {
isRunning = false
+ isStarting = false
this.message = message
}
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/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..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,9 +8,12 @@ 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"
+ 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 +50,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)
@@ -109,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"
}
@@ -120,6 +151,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 +194,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 +269,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..d6c6066
--- /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..8d3b534
--- /dev/null
+++ b/app/src/main/res/drawable/bg_edit_text.xml
@@ -0,0 +1,17 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml
new file mode 100644
index 0000000..b13cd0b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_arrow_back.xml
@@ -0,0 +1,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/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml
new file mode 100644
index 0000000..3b02228
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 8b2aef9..8f80ee7 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -1,15 +1,26 @@
GF2logger
+ 캡처 워크스페이스
+ 데이터 도구
+ 캡처 상태
+ 준비됨
+ 캡처 중
+ 캡처가 중지되었습니다
+ 대상
+ 홈
+ 서클
+ 주간
+ 전체 보기
+ 멤버와 가입 이력
+ 검토, 편집, 공유
선택한 게임 앱 하나의 프로토콜을 기기에서 캡처합니다. 원본 트래픽은 저장하지 않습니다.
설치된 게임 패키지 이름
캡처 준비
- 안내형 캡처 (자동 중지)
- 유용한 페이로드 수집 상태를 보여 주며 명단, 활동, 가입/탈퇴 정보가 모두 도착하면 중지합니다.
+ 한 번만 캡처
%1$s 서클 명단\n%2$s 활동 근거\n%3$s 가입/탈퇴 변경
중지
설정 열기
- 서클 관리 열기
서클 관리 백업 내보내기
서클 관리 백업 가져오기
현재의 구조화된 서클 기록, 가입·탈퇴 시각, 비고를 대체합니다. 캡처를 중지한 상태로 유지하세요.
@@ -21,6 +32,45 @@
언어
English
한국어
+ 한국어
+ 화면 테마
+ 시스템 설정
+ 라이트
+ 다크
+ 기기 설정을 따르거나 GF2logger를 라이트 또는 다크 모드로 고정합니다.
+ 건너뛰기
+ 이전
+ 다음
+ 시작하기
+ %1$d/%2$d
+ 온보딩 언어 변경
+ 환영합니다
+ 메인 화면
+ 소녀전선2: 망명 클라이언트의 통신 패킷을 캡처하고, 패킷 수집 현황을 한눈에 확인합니다.
+ 캡처 준비는 사용자가 중지할 때까지 계속 실행됩니다.
+ 한 번만 캡처는 멤버, 활동, 동향을 확인한 뒤 자동으로 중지합니다.
+ 최근 패킷 및 저장한 패킷 기록은 로컬에 보관됩니다.
+ VPN 동의 화면이 표시됩니다. GF2logger는 소녀전선2: 망명 앱에 대해서만 통신 패킷을 관찰하며, TLS를 해독하거나 원본 네트워크 패킷을 저장하지 않습니다.
+ 설정
+ 화면에 표시되는 정보를 조정하고, 기기에 저장하거나 외부로 보내는 정보를 관리합니다.
+ 언어와 테마를 선택합니다.
+ 전체 백업을 만들거나 복원합니다.
+ Discord 웹훅을 설정하고 필요한 패킷 종류를 선택합니다.
+ 서클 관리
+ 캡처하거나 불러온 멤버 데이터가 멤버 정보 및 주간 표에 반영됩니다.
+ 현재 가입 중이거나 과거에 탈퇴한 멤버의 정보와 가입·탈퇴 이력을 검색하고 관리합니다.
+ CSV를 적용하기 전에 변경 내용을 미리 보고, 자동 체크포인트로 최근 가져오기를 되돌릴 수 있습니다.
+ 스냅샷을 비교하거나 선택한 멤버만 내보낼 수 있습니다.
+ 주간 테이블
+ 일반 주간과 흙먼지 주간이 구분된 주간 공적표를 확인합니다.
+ 각 셀을 눌러 해당 값의 근거와 사용된 패킷 정보를 확인할 수 있습니다.
+ 수동으로 공적치나 흙먼지 점수 등을 수정할 수 있으며, 공유 버튼을 통해 주간 테이블을 저장하거나 공유할 수 있습니다.
+ 커트라인 설정은 선택한 기준보다 낮거나 낮을 가능성이 있는 셀을 강조합니다.
+ 파싱된 패킷 화면
+ 기록을 열어 표 또는 디코딩된 CSV 전체 내용을 확인하거나 내보냅니다.
+ 원본 CSV 보기는 디코딩된 전체 내용을 표시하고, 복사는 클립보드에 저장합니다.
+ 원본 CSV 전송은 설정된 Discord 웹훅으로 전송됩니다.
+ 패킷에 담겨 있는 여러 정보를 통해 멤버 및 주간 테이블을 완성해 나갈 수 있습니다.
백업
백업 불러오기
전체 설정, 서클 관리와 멤버별 가입 기록을 포함한 상세 정보, 모든 주간 과업표를 전체 백업 파일에서 불러옵니다.
@@ -73,6 +123,7 @@
저장할 수 없습니다
가입 이력
가입 이력 추가
+ 가입 이력 추가
멤버 삭제
멤버를 삭제하면 저장된 가입 이력, 비고와 주간 과업표 수정사항이 영구적으로 삭제됩니다. 이후 새 명단 패킷에서 멤버가 다시 추가될 수 있지만 삭제된 정보는 복구되지 않습니다.
멤버를 삭제했습니다
@@ -92,6 +143,8 @@
지우기
중복되지 않는 양의 UID와 닉네임을 입력하고 가입·탈퇴 날짜를 선택한 뒤 탈퇴를 가입 이후로 설정하세요.
가입 이력 %1$d\n가입: %2$s\n탈퇴: %3$s
+ 가입 이력 %1$d
+ %1$s → %2$s
가입 이력 삭제
가입/탈퇴 이력을 삭제합니다. 한 번 삭제하면 되돌릴 수 없습니다. 계속하시겠습니까?
가입 이력을 삭제했습니다
@@ -139,6 +192,10 @@
탈퇴
설정
앞으로 기록할 디코딩 페이로드를 선택합니다. 기존 기록에는 영향을 주지 않습니다.
+ 패킷과 보고서의 표기 언어에도 함께 적용됩니다.
+ 기기 설정을 따르거나 라이트·다크 모드로 고정합니다.
+ 사용자가 직접 선택한 백업 파일로만 전체 앱 데이터를 이동합니다.
+ 뒤로
%1$s (payloadType = %2$d)
필수 항목으로 항상 활성화됩니다. %1$s
%1$s, 페이로드 유형 %2$s
@@ -256,6 +313,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 546afd4..74dce0f 100644
--- a/app/src/main/res/values-night/colors.xml
+++ b/app/src/main/res/values-night/colors.xml
@@ -1,14 +1,39 @@
- #86AFFF
- #111214
- #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 3e579ca..4cd7146 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-v27/styles.xml b/app/src/main/res/values-v27/styles.xml
new file mode 100644
index 0000000..8006a5a
--- /dev/null
+++ b/app/src/main/res/values-v27/styles.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 25f34c4..44fda29 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,15 +1,40 @@
#111214
- #315DA8
- #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 a500594..342a076 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,15 +1,26 @@
GF2logger
+ Capture workspace
+ Data tools
+ Capture status
+ Ready
+ Capturing
+ Capture is stopped
+ Target
+ Home
+ Platoon
+ Weekly
+ View all
+ 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.
@@ -21,6 +32,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/%2$d
+ Change onboarding language
+ Welcome to
+ Main page
+ Capture communications from the GIRLS\' FRONTLINE 2: EXILIUM client and see packet collection progress at a glance.
+ Prepare capture keeps running until you stop it.
+ One-time Capture checks Members, Activity, and Updates, then stops automatically.
+ Recent and saved packet history is stored locally.
+ Android will show its VPN consent screen. GF2logger observes communications only for GIRLS\' FRONTLINE 2: EXILIUM, does not decrypt TLS, and never stores raw network packets.
+ Settings
+ Adjust what appears on screen and manage information stored on the device or sent outside it.
+ Choose the language and theme.
+ Create or restore a complete backup.
+ Configure a Discord webhook and select the packet types you need.
+ Platoon management
+ Captured or imported member data is reflected in member records and the weekly table.
+ Search and manage current or withdrawn members and their join and withdrawal 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 weekly merit tables separated into Standard and Gunsmoke weeks.
+ Tap any cell to see the basis for its value and the packet evidence used.
+ Manually correct merit or Gunsmoke values, and save or share the weekly table with the Share button.
+ Cut-off settings highlight cells that are or may remain below your selected targets.
+ Parsed packet pages
+ Open a history entry to inspect or export its table or complete decoded CSV text.
+ Show raw CSV reveals the original decoded content; Copy places it on the clipboard.
+ Send original CSV sends it through the configured Discord webhook.
+ Use the information in each packet to complete member records and the weekly table over time.
Backup
Restore backup
Restore all app settings, Platoon management and member details including membership history, and every weekly table from a complete backup.
@@ -73,6 +123,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
@@ -92,6 +143,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
@@ -139,6 +192,10 @@
Withdraw
Settings
Choose which decoded payloads are added to future packet history. Existing history is unchanged.
+ This choice also applies to packet and report labels.
+ Follow the device, or keep GF2logger in Light or Dark mode.
+ Move complete app data only through a backup you explicitly choose.
+ Back
%1$s (payloadType = %2$d)
Required and always enabled. %1$s
%1$s, payload type %2$s
@@ -260,6 +317,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 76cc987..e9e29c0 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -1,10 +1,53 @@
-
+
+
+
+
+
+
diff --git a/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt b/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt
index 357359e..6f00d6d 100644
--- a/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt
+++ b/app/src/test/java/dev/gf2log/app/capture/CaptureStatusTest.kt
@@ -12,15 +12,23 @@ class CaptureStatusTest {
fun tracksOnlyTheCurrentApplicationProcess() {
CaptureStatus.markStopped()
assertFalse(CaptureStatus.isRunning)
+ assertFalse(CaptureStatus.isStarting)
assertEquals("Capture is stopped", CaptureStatus.read())
+ CaptureStatus.markStarting()
+ assertFalse(CaptureStatus.isRunning)
+ assertTrue(CaptureStatus.isStarting)
+ assertEquals("Preparing capture", CaptureStatus.read())
+
CaptureStatus.markRunning("Capturing selected package")
CaptureStatus.update("Decoded Platoon members")
assertTrue(CaptureStatus.isRunning)
+ assertFalse(CaptureStatus.isStarting)
assertEquals("Decoded Platoon members", CaptureStatus.read())
CaptureStatus.markStopped("Capture failed")
assertFalse(CaptureStatus.isRunning)
+ assertFalse(CaptureStatus.isStarting)
assertEquals("Capture failed", CaptureStatus.read())
}
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..6df953a 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,3 +1,15 @@
+// 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 {
+ repositories {
+ mavenCentral()
+ }
+ dependencies {
+ classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.20-Beta1'
+ }
+}
+
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..7b98753 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -163,6 +163,36 @@ 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.
+
+`PrimaryNavigation` is the presentation-only shell shared by Home, Platoon, and
+Weekly. It launches existing Activities and owns no data or domain state.
+Onboarding overrides only the resolved presentation theme so the approved dark
+walkthrough remains stable; its selected language still persists through the
+same user-settings store as the rest of the app.
+
+The concrete presentation roles, warm light/dark palette, compact touch-target
+contract, and accessibility review gates are documented in
+[`UI_DESIGN_SYSTEM.md`](UI_DESIGN_SYSTEM.md). Activities select semantic roles;
+they do not define one-off button palettes or treat compound controls as generic
+buttons.
+
+`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
diff --git a/docs/UI_DESIGN_SYSTEM.md b/docs/UI_DESIGN_SYSTEM.md
new file mode 100644
index 0000000..8ca6a08
--- /dev/null
+++ b/docs/UI_DESIGN_SYSTEM.md
@@ -0,0 +1,76 @@
+# UI design system
+
+GF2logger uses a warm, minimal Android presentation layer for dense operational
+data. Domain behavior remains outside the UI: Activities compose views and call
+repository or policy APIs, while `ModernUi` owns reusable presentation roles.
+
+## Visual language
+
+- The dominant colors are warm off-white and charcoal, never absolute white or
+ black. Light and dark resources are designed as a pair rather than inverted.
+- Soft orange is the sole brand accent. It identifies the primary action and
+ key routes such as Platoon management and the weekly table.
+- Green communicates capture/readiness or confirmed success. Red is reserved
+ for destructive or failed states. Every state also has text or a symbol, so
+ color is never the only signal.
+- Surfaces are flat and tonal, with zero elevation and restrained 12-16dp
+ corner radii only where they communicate a real group or touch target.
+ Shadows, gradients, decorative blur, nested cards, and borders around every
+ row are not part of the system.
+- Bold type is limited to screen, section, and item headings. Body copy uses
+ the platform sans-serif; table-like data may use monospaced figures.
+
+## Control roles
+
+`ModernUi.ControlRole` is the source of truth for action hierarchy:
+
+| Role | Use |
+| --- | --- |
+| Primary | The single commitment action for the current screen |
+| Capture | Start or prepare continuous capture; green state treatment |
+| Feature | Key navigation to Platoon management or the weekly table; soft orange surface |
+| Segment selected | Current language or theme choice inside a compact segmented control |
+| Secondary | Ordinary bounded action on the current screen |
+| Tertiary | Low-emphasis action such as Back, Skip, or a week arrow |
+| Navigation | Neutral full-row navigation or selection |
+| Destructive | Confirmed destructive operation |
+| Destructive text | Less prominent destructive option separated from primary work |
+
+Buttons keep a 48dp touch target but use an inset 40dp visible surface. A
+multi-line destination uses `ModernUi.actionRow`: a 56dp row with a title,
+optional detail, and chevron instead of a tall button containing paragraphs.
+Do not encode `CheckBox` or `RadioButton` as generic buttons.
+
+## Layout and accessibility
+
+- Spacing follows a 4dp/8dp rhythm. Neighboring touch targets retain a 6-8dp gap.
+- The approved v2.3.0 visual contract is recorded in `PRODUCT.md` and
+ `.interface-design/system.md`. Home is status-led, Settings is a grouped flat
+ list, and onboarding is a five-step editorial dark surface.
+- Screens use three interaction levels: a bounded primary workspace, distinctive
+ feature shortcuts, and quiet utility rows. Do not render every action as the
+ same full-width button.
+- Home, Platoon management, and the weekly table share a persistent three-item
+ bottom navigation bar. Settings remains a secondary screen with a normal back
+ action; deeper detail and edit screens continue to use Android Back.
+- Compact controls may look smaller, but their hit area remains at least 48dp.
+- Text wraps when it carries meaning; only short button labels may ellipsize.
+- Icon-only controls require an accessible name. Decorative icons beside text
+ are hidden from the accessibility tree.
+- Pressed and disabled states are explicit. Destructive operations use a
+ confirmation dialog, and async work disables duplicate submission.
+- Motion is limited to short transform/opacity transitions and is skipped when
+ Android animations are disabled.
+- Light and dark screenshots must include status/navigation bars, narrow-screen
+ label checks, and the dense packet, weekly, and membership views.
+
+## Review checklist
+
+Before adding or changing a screen:
+
+1. Identify the one primary action and assign every other control a lower role.
+2. Use semantic resource colors; do not add raw RGB values in Activities.
+3. Verify English and Korean labels at the supported system font scales.
+4. Check a dark and light physical-device render, including system bar contrast.
+5. Confirm that presentation code does not acquire SQL, parser, inference, or
+ capture-policy responsibilities.
diff --git a/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt b/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt
index 9da9ef8..491f0c6 100644
--- a/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt
+++ b/protocol/src/main/java/dev/gf2log/protocol/GuildMembersCsv.kt
@@ -91,8 +91,9 @@ object GuildMembersCsv {
lastLogin = row[7].trim().toUIntOrNull() ?: return null,
)
}
- if (!isValidRoster(members) || logTime == null) return null
- return Snapshot(logTime = logTime!!, members = members)
+ if (!isValidRoster(members)) return null
+ val snapshotTime = logTime ?: return null
+ return Snapshot(logTime = snapshotTime, members = members)
}
private fun parseRecords(content: String): List>? {