diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..ae55ad7c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,25 @@ +# Shared formatting rules, used by ktlint (through Spotless) and by editors. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{kt,kts}] +ktlint_code_style = android_studio +# Existing Sidekit code does not enforce a wrap column; keep it that way. +max_line_length = off +# Sidekit imports MMS and WorkManager types wholesale in a few adapters. +ktlint_standard_no-wildcard-imports = disabled +# Screens are composables with PascalCase names, which is the Compose convention. +ktlint_function_naming_ignore_when_annotated_with = Composable + +[*.{yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/actions/android-setup/action.yml b/.github/actions/android-setup/action.yml new file mode 100644 index 00000000..2e7bbf1b --- /dev/null +++ b/.github/actions/android-setup/action.yml @@ -0,0 +1,21 @@ +name: Android build setup +description: Install the JDK, Android SDK platform and Gradle caching used by every CI job. + +runs: + using: composite + steps: + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + with: + # setup-android splits this on spaces, so a YAML block scalar arrives as one package name + # containing newlines and sdkmanager fails with "Failed to find package". + packages: platform-tools platforms;android-37.0 build-tools;37.0.0 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..bf667254 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,140 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + # Only supersede PR runs; every push to main keeps its own status. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + wrapper-validation: + name: Gradle wrapper validation + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: gradle/actions/wrapper-validation@v4 + + build: + name: Build (debug + R8 release) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + # versionName/versionCode come from `git describe` and the commit count, so a shallow + # clone would build a release that claims versionCode 1. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: ./.github/actions/android-setup + - name: Assemble debug and release + run: > + ./gradlew :app:assembleDebug :app:assembleRelease :sidekit:assembleDebug + --stacktrace + + unit-tests: + name: Unit tests + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/android-setup + # Robolectric downloads its Android runtime jars outside Gradle's cache. + - uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: robolectric-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }} + restore-keys: robolectric-${{ runner.os }}- + - name: Run JVM and Robolectric tests + run: ./gradlew testDebugUnitTest --stacktrace + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: unit-test-reports + path: | + **/build/reports/tests/testDebugUnitTest + **/build/test-results/testDebugUnitTest + retention-days: 7 + if-no-files-found: ignore + + android-lint: + name: Android Lint + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/android-setup + - name: Run lint + run: ./gradlew lintDebug --stacktrace + - name: Upload lint reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-lint-reports + path: | + **/build/reports/lint-results-debug.html + **/build/reports/lint-results-debug.txt + **/build/reports/lint-results-debug.xml + retention-days: 7 + if-no-files-found: ignore + - name: Upload lint results to code scanning + if: always() + continue-on-error: true + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: app/build/reports/lint-results-debug.sarif + category: android-lint + + kotlin-format: + name: Kotlin formatting + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + # Spotless ratchets against origin/main, so it needs the base ref. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: ./.github/actions/android-setup + - name: Check ktlint formatting of changed files + run: ./gradlew spotlessCheck --stacktrace + + secrets: + name: Secret scan + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + dependency-review: + name: Dependency review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..d923d133 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,40 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "23 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + security-events: write + packages: read + actions: read + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze Kotlin and Java + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/android-setup + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: java-kotlin + build-mode: manual + - name: Build for analysis + run: ./gradlew :app:assembleDebug :sidekit:assembleDebug --stacktrace + - name: Analyze + uses: github/codeql-action/analyze@v4 + with: + category: "/language:java-kotlin" diff --git a/.github/workflows/dependency-graph.yml b/.github/workflows/dependency-graph.yml new file mode 100644 index 00000000..4064e3e7 --- /dev/null +++ b/.github/workflows/dependency-graph.yml @@ -0,0 +1,24 @@ +name: Dependency graph + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: dependency-graph-${{ github.ref }} + cancel-in-progress: true + +jobs: + submit: + name: Submit Gradle dependencies + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/android-setup + # Populates GitHub's dependency graph, which the PR dependency-review job reads. + - uses: gradle/actions/dependency-submission@v4 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 08d6133f..0ddff4aa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,39 @@ plugins { alias(libs.plugins.kotlin.compose) } +// Version metadata is read from git instead of being hand-edited here, so a build always reports +// the code it was made from: a tagged commit is exactly "1.2.3", anything after it is +// "1.2.3-3-g", and uncommitted changes add "-dirty". `versionCode` counts commits, so it only +// ever grows and a newer build always installs over an older one — including over the 1.2.3 stable +// build, which was bumped by hand to 6. A checkout without git (exported source, a CI tarball) +// falls back to the last release; pass -PsidekitVersionName=… / -PsidekitVersionCode=… to override. +val fallbackVersionName = "1.2.3" +val fallbackVersionCode = 6 + +fun git(vararg args: String) = providers.exec { + commandLine("git", *args) + workingDir = rootDir + isIgnoreExitValue = true +}.standardOutput.asText.map(String::trim) + +// `--always` prints the bare sha when no release tag is reachable (a shallow clone, say), so such a +// build reads "0.0.0-g" rather than passing itself off as the last release. +val gitVersionName = git("describe", "--tags", "--match", "v[0-9]*", "--dirty", "--always") + .map { described -> + val name = described.removePrefix("v") + when { + name.isEmpty() -> fallbackVersionName + name.first().isDigit() -> name + else -> "0.0.0-g$name" + } + } +val gitVersionCode = git("rev-list", "--count", "HEAD") + .map { it.toIntOrNull() ?: fallbackVersionCode } + +// A machine without a `git` binary makes the exec itself throw, rather than exit non-zero. +fun versionCodeFromGit() = runCatching { gitVersionCode.get() }.getOrDefault(fallbackVersionCode) +fun versionNameFromGit() = runCatching { gitVersionName.get() }.getOrDefault(fallbackVersionName) + android { namespace = "hrln.interactive.sidekit" compileSdk { @@ -13,8 +46,11 @@ android { applicationId = "hrln.interactive.sidekit" minSdk = 31 targetSdk = 36 - versionCode = 2 - versionName = "1.1" + versionCode = providers.gradleProperty("sidekitVersionCode") + .map(String::toInt) + .getOrElse(versionCodeFromGit()) + versionName = providers.gradleProperty("sidekitVersionName") + .getOrElse(versionNameFromGit()) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -40,9 +76,11 @@ android { // Development build that installs alongside the stable SideKit as a separate app // (`hrln.interactive.sidekit.dev`), so the stable one can remain the daily driver while // this tracks the current branch/tip for testing. See app/src/dev/res for its distinct - // name. It derives from `profile` (R8 + debug signing, no debug Compose checks, baseline + // name, and for the DEV pill and About row that mark its UI (values/build.xml). It derives from `profile` (R8 + debug signing, no debug Compose checks, baseline // profile) so the on-device testing build doesn't stutter like a raw debug build — it // also falls back to `.sidekit` release. Use `debug` for breakpoint debugging instead. + // The version comes from git describe, so a dev build names the branch tip it was built + // from (e.g. 1.2.3-1-g91da863-dev) rather than a stale hand-written number. create("dev") { initWith(getByName("profile")) applicationIdSuffix = ".dev" diff --git a/app/screenshots/case_colour_sage_after.png b/app/screenshots/case_colour_sage_after.png index 9febd267..496d9b15 100644 Binary files a/app/screenshots/case_colour_sage_after.png and b/app/screenshots/case_colour_sage_after.png differ diff --git a/app/screenshots/case_colour_sage_before.png b/app/screenshots/case_colour_sage_before.png index 378a04f1..791f3aee 100644 Binary files a/app/screenshots/case_colour_sage_before.png and b/app/screenshots/case_colour_sage_before.png differ diff --git a/app/screenshots/keypad_mapping_automatic.png b/app/screenshots/keypad_mapping_automatic.png index bf1db407..071b5e30 100644 Binary files a/app/screenshots/keypad_mapping_automatic.png and b/app/screenshots/keypad_mapping_automatic.png differ diff --git a/app/screenshots/keypad_mapping_editor.png b/app/screenshots/keypad_mapping_editor.png index 97921079..24590ed3 100644 Binary files a/app/screenshots/keypad_mapping_editor.png and b/app/screenshots/keypad_mapping_editor.png differ diff --git a/app/screenshots/keypad_mapping_editor_keys.png b/app/screenshots/keypad_mapping_editor_keys.png index f625d2bc..4c745b13 100644 Binary files a/app/screenshots/keypad_mapping_editor_keys.png and b/app/screenshots/keypad_mapping_editor_keys.png differ diff --git a/app/screenshots/keypad_mapping_settings.png b/app/screenshots/keypad_mapping_settings.png index a98d9b71..03ad65b7 100644 Binary files a/app/screenshots/keypad_mapping_settings.png and b/app/screenshots/keypad_mapping_settings.png differ diff --git a/app/screenshots/keypad_mapping_tester.png b/app/screenshots/keypad_mapping_tester.png index a8ba2796..0b10a7d8 100644 Binary files a/app/screenshots/keypad_mapping_tester.png and b/app/screenshots/keypad_mapping_tester.png differ diff --git a/app/screenshots/launcher_grid_compact.png b/app/screenshots/launcher_grid_compact.png new file mode 100644 index 00000000..e97ee720 Binary files /dev/null and b/app/screenshots/launcher_grid_compact.png differ diff --git a/app/screenshots/launcher_list_36-2.png b/app/screenshots/launcher_list_36-2.png index 53e69f1a..92445565 100644 Binary files a/app/screenshots/launcher_list_36-2.png and b/app/screenshots/launcher_list_36-2.png differ diff --git a/app/screenshots/onboarding/01-home.png b/app/screenshots/onboarding/01-home.png index 95a041fb..7d972e11 100644 Binary files a/app/screenshots/onboarding/01-home.png and b/app/screenshots/onboarding/01-home.png differ diff --git a/app/screenshots/onboarding/02-colour.png b/app/screenshots/onboarding/02-colour.png index 16c9e682..e7f1c9d6 100644 Binary files a/app/screenshots/onboarding/02-colour.png and b/app/screenshots/onboarding/02-colour.png differ diff --git a/app/screenshots/onboarding/03-layout.png b/app/screenshots/onboarding/03-layout.png index c1c41909..b1a35e33 100644 Binary files a/app/screenshots/onboarding/03-layout.png and b/app/screenshots/onboarding/03-layout.png differ diff --git a/app/screenshots/onboarding/03b-hide-apps.png b/app/screenshots/onboarding/03b-hide-apps.png index 9dbb3c8b..14ef6047 100644 Binary files a/app/screenshots/onboarding/03b-hide-apps.png and b/app/screenshots/onboarding/03b-hide-apps.png differ diff --git a/app/screenshots/onboarding/04-permissions.png b/app/screenshots/onboarding/04-permissions.png index 69f021cb..6b289a8a 100644 Binary files a/app/screenshots/onboarding/04-permissions.png and b/app/screenshots/onboarding/04-permissions.png differ diff --git a/app/screenshots/onboarding/05-typing.png b/app/screenshots/onboarding/05-typing.png index f68610cb..afdf7a0b 100644 Binary files a/app/screenshots/onboarding/05-typing.png and b/app/screenshots/onboarding/05-typing.png differ diff --git a/app/screenshots/onboarding/06-t9.png b/app/screenshots/onboarding/06-t9.png index 666ef884..5c3b6907 100644 Binary files a/app/screenshots/onboarding/06-t9.png and b/app/screenshots/onboarding/06-t9.png differ diff --git a/app/screenshots/onboarding/07-qwerty.png b/app/screenshots/onboarding/07-qwerty.png index 32c67be7..114847f2 100644 Binary files a/app/screenshots/onboarding/07-qwerty.png and b/app/screenshots/onboarding/07-qwerty.png differ diff --git a/app/screenshots/onboarding/08-sundial.png b/app/screenshots/onboarding/08-sundial.png index 344c35b1..ff7e7eeb 100644 Binary files a/app/screenshots/onboarding/08-sundial.png and b/app/screenshots/onboarding/08-sundial.png differ diff --git a/app/screenshots/onboarding/09-controller.png b/app/screenshots/onboarding/09-controller.png index 2781e0c1..5475fe98 100644 Binary files a/app/screenshots/onboarding/09-controller.png and b/app/screenshots/onboarding/09-controller.png differ diff --git a/app/screenshots/onboarding/10-search-t9.png b/app/screenshots/onboarding/10-search-t9.png index 6647e03f..dd2800e3 100644 Binary files a/app/screenshots/onboarding/10-search-t9.png and b/app/screenshots/onboarding/10-search-t9.png differ diff --git a/app/screenshots/onboarding/11-search-qwerty.png b/app/screenshots/onboarding/11-search-qwerty.png index 6647e03f..dd2800e3 100644 Binary files a/app/screenshots/onboarding/11-search-qwerty.png and b/app/screenshots/onboarding/11-search-qwerty.png differ diff --git a/app/screenshots/onboarding/13-hide-apps-settings.png b/app/screenshots/onboarding/13-hide-apps-settings.png index 75b3a9a0..933a1b4c 100644 Binary files a/app/screenshots/onboarding/13-hide-apps-settings.png and b/app/screenshots/onboarding/13-hide-apps-settings.png differ diff --git a/app/screenshots/settings_96-891.png b/app/screenshots/settings_96-891.png index ca3c13ae..fe8ab61b 100644 Binary files a/app/screenshots/settings_96-891.png and b/app/screenshots/settings_96-891.png differ diff --git a/app/screenshots/settings_compact.png b/app/screenshots/settings_compact.png new file mode 100644 index 00000000..8e162b6b Binary files /dev/null and b/app/screenshots/settings_compact.png differ diff --git a/app/screenshots/settings_system_home.png b/app/screenshots/settings_system_home.png index c237814e..3bf4cb96 100644 Binary files a/app/screenshots/settings_system_home.png and b/app/screenshots/settings_system_home.png differ diff --git a/app/src/dev/res/values/build.xml b/app/src/dev/res/values/build.xml new file mode 100644 index 00000000..b1573714 --- /dev/null +++ b/app/src/dev/res/values/build.xml @@ -0,0 +1,10 @@ + + + + DEV + true + diff --git a/app/src/dev/res/values/strings.xml b/app/src/dev/res/values/strings.xml index 5a5a0cfe..745ea27d 100644 --- a/app/src/dev/res/values/strings.xml +++ b/app/src/dev/res/values/strings.xml @@ -2,7 +2,9 @@ SideKit Dev diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 278e3369..53fd9dfb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -80,7 +80,7 @@ android:theme="@style/Theme.Sidekit"> + + + + + Development build + + false + diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/apps/AppsViewModelTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/apps/AppsViewModelTest.kt index 1ef32b14..76d42c84 100644 --- a/app/src/test/java/hrln/interactive/sidekit/apps/apps/AppsViewModelTest.kt +++ b/app/src/test/java/hrln/interactive/sidekit/apps/apps/AppsViewModelTest.kt @@ -16,11 +16,21 @@ import org.robolectric.annotation.Config @Config(sdk = [31]) @OptIn(ExperimentalCoroutinesApi::class) class AppsViewModelTest { + @Test fun `Sidekit cannot launch itself, but can launch its dev build`() { + val app = { pkg: String -> LaunchableApp(ComponentName(pkg, "Main"), Process.myUserHandle(), pkg) } + val apps = listOf(app("hrln.interactive.sidekit"), app("hrln.interactive.sidekit.dev"), app("com.example.chat")) + assertEquals( + listOf("hrln.interactive.sidekit.dev", "com.example.chat"), + launchableApps(apps, selfPackage = "hrln.interactive.sidekit").map { it.component.packageName } + ) + } + @Test fun `search matches names and numeric keypad sequences`() { assertTrue(matchesApp("Firefox", "fire")) assertTrue(matchesApp("Firefox", "3473")) assertFalse(matchesApp("Firefox", "Maps")) } + @Test fun `visible collection gets package changes and stops upstream on leaving`() = runTest { val dispatcher = StandardTestDispatcher(testScheduler) Dispatchers.setMain(dispatcher) @@ -35,10 +45,19 @@ class AppsViewModelTest { } val vm = AppsViewModel(source) val collector = backgroundScope.launch { vm.state.collect {} } - runCurrent(); assertTrue(observing) - vm.query.value = "3473"; runCurrent(); assertEquals(1, vm.state.value.apps.size) - apps.value = emptyList(); runCurrent(); assertTrue(vm.state.value.apps.isEmpty()) - collector.cancel(); runCurrent(); assertFalse(observing) - } finally { Dispatchers.resetMain() } + runCurrent() + assertTrue(observing) + vm.query.value = "3473" + runCurrent() + assertEquals(1, vm.state.value.apps.size) + apps.value = emptyList() + runCurrent() + assertTrue(vm.state.value.apps.isEmpty()) + collector.cancel() + runCurrent() + assertFalse(observing) + } finally { + Dispatchers.resetMain() + } } } diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/everyday/EverydayScreenshotTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/everyday/EverydayScreenshotTest.kt index 8fadd3e0..3e1a1eaf 100644 --- a/app/src/test/java/hrln/interactive/sidekit/apps/everyday/EverydayScreenshotTest.kt +++ b/app/src/test/java/hrln/interactive/sidekit/apps/everyday/EverydayScreenshotTest.kt @@ -26,6 +26,7 @@ import hrln.interactive.sidekit.apps.tripplanning.TripPlanningScreen import hrln.interactive.sidekit.apps.webbrowser.WebBrowserSampleData import hrln.interactive.sidekit.apps.webbrowser.WebBrowserScreen import hrln.interactive.sidekit.ui.SidekitPreviewFrame +import java.time.LocalDate import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -42,7 +43,11 @@ class EverydayScreenshotTest { compose.onRoot().captureRoboImage("screenshots/$name.png") } - @Test fun calendar() = shoot("calendar_96-420") { CalendarScreen(CalendarUiState(), {}) } + // Pinned date: the month grid highlights "today", so a live clock would re-record this + // screenshot every midnight and keep the working tree permanently dirty. + @Test fun calendar() = shoot("calendar_96-420") { + CalendarScreen(CalendarUiState(date = LocalDate.of(2026, 11, 5)), {}) + } @Test fun mail() = shoot("mail_96-499") { MailScreen(MailSampleData.summary, MailSampleData.inbox) } @Test fun maps() = shoot("maps_96-563") { MapsScreen(MapsSampleData.destination) } @Test fun compass() = shoot("compass_96-629") { CompassScreen(CompassSampleData.reading) } diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/settings/SettingsUiTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/settings/SettingsUiTest.kt index 1a3a42bf..55cf1fc9 100644 --- a/app/src/test/java/hrln/interactive/sidekit/apps/settings/SettingsUiTest.kt +++ b/app/src/test/java/hrln/interactive/sidekit/apps/settings/SettingsUiTest.kt @@ -72,6 +72,34 @@ class SettingsUiTest { compose.onNodeWithText("List").assertIsDisplayed() } + @Test fun `compact layout row saves the choice and raises the body`() { + val prefs = SidekitPreferences(ApplicationProvider.getApplicationContext()) + prefs.compactLayout = false + val compact = androidx.compose.runtime.mutableStateOf(false) + compose.setContent { + // SidekitTheme directly, not SidekitPreviewFrame: the frame installs its own (standard) + // spacing and would override the one under test. + hrln.interactive.sidekit.design.theme.SidekitTheme( + spacing = if (compact.value) hrln.interactive.sidekit.design.theme.SidekitSpacing.Compact + else hrln.interactive.sidekit.design.theme.SidekitSpacing(), + ) { + hrln.interactive.sidekit.design.canvas.SidekitCanvas { SettingsHome({}, {}) } + } + } + compose.mainClock.advanceTimeBy(1000) + val bodyTop = compose.onNodeWithText("Launcher default").getUnclippedBoundsInRoot().top + compose.onNodeWithText("Compact layout").performClick() + compose.runOnIdle { assertTrue(SidekitPreferences(ApplicationProvider.getApplicationContext()).compactLayout) } + compose.onNodeWithText("On").assertIsDisplayed() + // The preference listener is what MainActivity follows: flip the state as it would. + compose.runOnIdle { compact.value = true } + compose.mainClock.advanceTimeBy(1000) + compose.onRoot().captureRoboImage("screenshots/settings_compact.png") + val compactTop = compose.onNodeWithText("Launcher default").getUnclippedBoundsInRoot().top + // Header 103 -> 75, so the body starts 28 dp higher (1 dp == 1 px on this canvas). + assertEquals(28f, (bodyTop - compactTop).value, 1f) + } + @Test fun `system settings live on their own page`() { var opened = 0 compose.setContent { SidekitPreviewFrame { SettingsHome({}, {}, onSystem = { opened++ }) } } diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/sideport/SideportAddTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/sideport/SideportAddTest.kt new file mode 100644 index 00000000..97cfedaa --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/apps/sideport/SideportAddTest.kt @@ -0,0 +1,66 @@ +package hrln.interactive.sidekit.apps.sideport + +import android.content.ComponentName +import android.content.Context +import android.os.Process +import androidx.test.core.app.ApplicationProvider +import hrln.interactive.sidekit.apps.apps.LaunchableApp +import hrln.interactive.sidekit.apps.sideport.registry.PortedApp +import hrln.interactive.sidekit.apps.sideport.registry.PortedApps +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Sideport's Add app picker and registry: Sidekit builds are never ported, and rows are unified. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [31]) +class SideportAddTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val prefs get() = context.getSharedPreferences("sideport-test", Context.MODE_PRIVATE) + + @Before fun cleanPrefs() { + prefs.edit().clear().commit() + } + + private fun app(pkg: String, label: String, cls: String = "Main") = LaunchableApp(ComponentName(pkg, cls), Process.myUserHandle(), label) + + @Test fun `candidates leave out every Sidekit build and apps already ported`() { + val installed = listOf( + app("hrln.interactive.sidekit", "Sidekit"), + app("hrln.interactive.sidekit.dev", "Sidekit dev"), + app("com.example.chat", "Example Chat"), + app("com.example.mail", "Example Mail") + ) + val ported = listOf(PortedApp("com.example.mail", "Example Mail")) + assertEquals(listOf("com.example.chat"), sideportCandidates(installed, ported, "").map { it.component.packageName }) + } + + @Test fun `candidates keep one row per package and match the query`() { + val installed = listOf( + app("com.example.chat", "Example Chat", "Main"), + app("com.example.chat", "Example Chat", "Share"), + app("com.example.mail", "Example Mail") + ) + val candidates = sideportCandidates(installed, emptyList(), "chat") + assertEquals(listOf("com.example.chat/Main"), candidates.map { "${it.component.packageName}/${it.component.className}" }) + } + + @Test fun `the registry refuses to port a Sidekit build`() { + val ported = PortedApps(prefs) + ported.add("hrln.interactive.sidekit", "Sidekit") + ported.add("hrln.interactive.sidekit.dev", "Sidekit dev") + ported.add("com.example.chat", "Example Chat") + assertEquals(listOf("com.example.chat"), ported.apps.value.map { it.packageName }) + } + + @Test fun `a Sidekit entry saved by an earlier version is dropped on read`() { + prefs.edit().putString( + "apps", + """[{"package":"hrln.interactive.sidekit.dev","label":"Sidekit dev","mode":"Projected","added":1}]""" + ).commit() + assertEquals(emptyList(), PortedApps(prefs).apps.value) + } +} diff --git a/app/src/test/java/hrln/interactive/sidekit/apps/sideweather/WeatherRepositoryTest.kt b/app/src/test/java/hrln/interactive/sidekit/apps/sideweather/WeatherRepositoryTest.kt index df82a946..7843176f 100644 --- a/app/src/test/java/hrln/interactive/sidekit/apps/sideweather/WeatherRepositoryTest.kt +++ b/app/src/test/java/hrln/interactive/sidekit/apps/sideweather/WeatherRepositoryTest.kt @@ -2,6 +2,7 @@ package hrln.interactive.sidekit.apps.sideweather import androidx.test.ext.junit.runners.AndroidJUnit4 import hrln.interactive.sidekit.apps.maps.Place +import hrln.interactive.sidekit.apps.maps.currentLocationName import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import org.json.JSONObject @@ -54,4 +55,20 @@ class WeatherRepositoryTest { assertTrue(job.isCompleted) } finally { scope.cancel() } } + @Test fun `current location keeps one entry and takes its city name`() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + try { + val repo = WeatherRepository(Files.createTempDirectory("weather-here").toFile(), scope, { payload() }) + val sydney = Place("Sydney", -33.8, 151.2) + repo.select(sydney) + repo.select(Place("Current location · Madrid", 40.4, -3.7)) + val fix = Place(currentLocationName(null), 41.4, 2.2) + repo.select(fix) + repo.select(sydney) + repo.rename(fix, currentLocationName("Barcelona")) + val settings = withTimeout(3_000) { repo.settings.state.first { s -> s?.places?.any { it.name.endsWith("Barcelona") } == true } }!! + assertEquals(listOf("Current location · Barcelona", "Sydney"), settings.places.map { it.name }) + assertEquals(sydney.key, settings.selected) + } finally { scope.cancel() } + } } diff --git a/app/src/test/java/hrln/interactive/sidekit/launcher/LauncherSearchTest.kt b/app/src/test/java/hrln/interactive/sidekit/launcher/LauncherSearchTest.kt index 780ea37c..196b1a29 100644 --- a/app/src/test/java/hrln/interactive/sidekit/launcher/LauncherSearchTest.kt +++ b/app/src/test/java/hrln/interactive/sidekit/launcher/LauncherSearchTest.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.input.InputMode import androidx.compose.ui.input.InputModeManager import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.test.core.app.ApplicationProvider @@ -153,6 +154,35 @@ class LauncherSearchTest { compose.onNodeWithTag(LauncherTags.SEARCH).assertTextEquals("T") } + private fun dockButton(label: String) = + compose.onNode(hasContentDescription(label) and !hasAnyAncestor(hasTestTag(LauncherTags.GRID))) + + @Test fun `keypad Up from the dock enters the grid`() { + launch(KeypadModule.Qwerty, LauncherLayout.Grid) + // Reached by touch, so there is no tile to go back to: Up still leaves the dock. + dockButton("Sidenotes").requestFocus() + compose.waitForIdle() + key(KeyEvent.KEYCODE_DPAD_UP) + dockButton("Sidenotes").assertIsNotFocused() + compose.onNode(isFocused() and hasAnyAncestor(hasTestTag(LauncherTags.GRID))).assertExists() + } + + @Test fun `keypad Down into the dock and Up again returns to the same tile`() { + launch(KeypadModule.Qwerty, LauncherLayout.Grid) + focusFirstTile() + val inGrid = isFocused() and hasAnyAncestor(hasTestTag(LauncherTags.GRID)) + // Down until focus leaves the grid for the dock, remembering the last tile it was on. + var last: String? = null + repeat(20) { + val focused = compose.onAllNodes(inGrid).fetchSemanticsNodes().singleOrNull() ?: return@repeat + last = focused.config[SemanticsProperties.ContentDescription].single() + key(KeyEvent.KEYCODE_DPAD_DOWN) + } + compose.onAllNodes(inGrid).assertCountEquals(0) + key(KeyEvent.KEYCODE_DPAD_UP) + tile(last!!).assertIsFocused() + } + @Test fun `clear remains square and rightmost in typing options`() { launch(KeypadModule.T9) key(KeyEvent.KEYCODE_6) diff --git a/app/src/test/java/hrln/interactive/sidekit/ui/DevBuildPillTest.kt b/app/src/test/java/hrln/interactive/sidekit/ui/DevBuildPillTest.kt new file mode 100644 index 00000000..8615eec7 --- /dev/null +++ b/app/src/test/java/hrln/interactive/sidekit/ui/DevBuildPillTest.kt @@ -0,0 +1,51 @@ +package hrln.interactive.sidekit.ui + +import android.content.Context +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import hrln.interactive.sidekit.R +import hrln.interactive.sidekit.design.theme.SidekitTheme +import hrln.interactive.sidekit.launcher.LauncherScreen +import hrln.interactive.sidekit.launcher.LauncherUiState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The dev marker is driven entirely by resources. Unit tests compile against `src/main`, i.e. + * what a stable install shows, so these defaults are the guarantee that a release build can't + * advertise itself as a dev build - and that the recorded screenshots keep their current frames. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [31]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class DevBuildPillTest { + @get:Rule val compose = createComposeRule() + + private val description get() = "Development build" + + @Test fun stableBuildsCarryNoMarker() { + val context = ApplicationProvider.getApplicationContext() + assertEquals("", context.getString(R.string.build_label)) + assertFalse(context.resources.getBoolean(R.bool.is_dev_build)) + } + + @Test fun thePillIsSpokenAsOneLabel() { + compose.setContent { SidekitTheme { DevBuildPill("DEV") } } + compose.onNodeWithContentDescription(description).assertIsDisplayed() + } + + @Test fun theLauncherHeaderShowsNoPillInAStableBuild() { + compose.setContent { SidekitPreviewFrame { LauncherScreen(LauncherUiState(), {}, {}) } } + compose.onAllNodesWithContentDescription(description).assertCountEquals(0) + } +} diff --git a/app/src/test/java/hrln/interactive/sidekit/ui/ScreenshotTest.kt b/app/src/test/java/hrln/interactive/sidekit/ui/ScreenshotTest.kt index d7535a4f..7400b69f 100644 --- a/app/src/test/java/hrln/interactive/sidekit/ui/ScreenshotTest.kt +++ b/app/src/test/java/hrln/interactive/sidekit/ui/ScreenshotTest.kt @@ -14,6 +14,7 @@ import hrln.interactive.sidekit.apps.sidenotes.SidenotesSampleData import hrln.interactive.sidekit.apps.sidenotes.SidenotesUiState import hrln.interactive.sidekit.apps.sideticks.SideticksScreen import hrln.interactive.sidekit.apps.sideticks.SideticksUiState +import hrln.interactive.sidekit.design.canvas.SidekitCanvas import hrln.interactive.sidekit.launcher.LauncherLayout import hrln.interactive.sidekit.launcher.LauncherScreen import hrln.interactive.sidekit.launcher.LauncherUiState @@ -49,6 +50,15 @@ class ScreenshotTest { @Test fun launcherGridAtSidephoneAvdDensity() = shoot("launcher_grid_avd_320dpi", launcher(LauncherUiState())) @Test fun launcherList() = shoot("launcher_list_36-2", launcher(LauncherUiState(layout = LauncherLayout.List))) + /** The same frame with Settings → Compact layout on: header 75 instead of 103, bar 100 instead of 112. */ + @Test fun launcherGridCompact() { + compose.setContent { + hrln.interactive.sidekit.design.theme.SidekitTheme( + spacing = hrln.interactive.sidekit.design.theme.SidekitSpacing.Compact, + ) { SidekitCanvas { LauncherScreen(LauncherUiState(), {}, {}) } } + } + compose.onRoot().captureRoboImage("screenshots/launcher_grid_compact.png") + } @Test fun sidenotes() = shoot("sidenotes_1-278") { SidenotesListScreen(SidenotesUiState(SidenotesSampleData.notes), {}, {}, {}, {}) } @Test fun sidenotesUndo() = shoot("sidenotes_undo") { SidenotesListScreen(SidenotesUiState(SidenotesSampleData.notes.drop(1), recentlyDeleted = SidenotesSampleData.notes.first()), {}, {}, {}, {}) diff --git a/build.gradle.kts b/build.gradle.kts index 476e7406..c05a847f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,43 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.spotless) apply false +} + +// Formatting is checked with ktlint through Spotless. `ratchetFrom` limits the check to files +// changed since main, so adopting it does not require reformatting the whole codebase at once. +// Run `./gradlew spotlessApply` before pushing, or `spotlessCheck` to see the diff. +// +// Spotless does not pick up .editorconfig on its own, and without it ktlint falls back to its +// defaults: 140-column wrapping and no Composable naming exemption, so every screen would fail. +// The ktlint rules from .editorconfig are therefore read here and handed over explicitly; the +// file stays the single source of truth for editors and for CI. +val ktlintRulesFromEditorConfig: Map = + providers.fileContents(rootProject.layout.projectDirectory.file(".editorconfig")) + .asText + .map { text -> + text.lines() + .map { it.substringBefore('#').trim() } + .filter { '=' in it } + .map { it.split('=', limit = 2).map(String::trim) } + .filter { (key, _) -> key == "max_line_length" || key.startsWith("ktlint_") } + .associate { (key, value) -> key to value } + } + .get() + +allprojects { + apply(plugin = "com.diffplug.spotless") + + configure { + // A fork clone or a checkout without that ref can point it elsewhere: -PspotlessRatchet=upstream/main + ratchetFrom(providers.gradleProperty("spotlessRatchet").getOrElse("origin/main")) + kotlin { + target("src/**/*.kt") + ktlint(libs.versions.ktlint.get()).editorConfigOverride(ktlintRulesFromEditorConfig) + } + kotlinGradle { + target("*.gradle.kts") + ktlint(libs.versions.ktlint.get()).editorConfigOverride(ktlintRulesFromEditorConfig) + } + } } diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 00000000..81f5e7e7 --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,87 @@ +# Continuous integration + +Sidekit runs its checks on GitHub Actions. All jobs are defined in +[`.github/workflows`](../.github/workflows) and share the JDK 21, Android SDK 37 and +Gradle cache setup in [`.github/actions/android-setup`](../.github/actions/android-setup). + +## What runs + +| Job | Command | Catches | +| --- | --- | --- | +| Gradle wrapper validation | `gradle/actions/wrapper-validation` | A modified or unverified `gradle-wrapper.jar` | +| Build | `./gradlew :app:assembleDebug :app:assembleRelease :sidekit:assembleDebug` | Compile errors and R8/keep-rule breakage that only release builds show | +| Unit tests | `./gradlew testDebugUnitTest` | JVM, Robolectric and Compose tests in both modules | +| Android Lint | `./gradlew lintDebug` | API-level, manifest and resource problems; uploads SARIF to code scanning | +| Kotlin formatting | `./gradlew spotlessCheck` | ktlint violations in files changed since `main` | +| Secret scan | `gitleaks/gitleaks-action` | Committed credentials, private keys and tokens | +| Dependency review | `actions/dependency-review-action` | High-severity vulnerable dependencies in a pull request | +| CodeQL | manual Gradle build + `codeql-action` | Security data-flow issues in Kotlin and Java (`codeql.yml`) | +| Dependency graph | `gradle/actions/dependency-submission` | Keeps the graph that dependency review reads up to date (`dependency-graph.yml`) | + +These checks gate pull requests. The repository does not run instrumented tests on +an emulator: the only `androidTest` source today is the generated example, and the +[PR template](../.github/pull_request_template.md) already requires physical-device +testing for app changes. Add a `connectedDebugAndroidTest` job once real +instrumentation tests exist. + +## Formatting + +Formatting is checked with ktlint through Spotless. It is *ratcheted* against +`origin/main`, so only files changed on a branch must be clean. This lets the +project adopt formatting without rewriting untouched code. + +```bash +./gradlew spotlessCheck # report violations +./gradlew spotlessApply # format the changed files +``` + +The rules live in [`.editorconfig`](../.editorconfig). `ktlint_code_style = +android_studio` matches Android Studio's defaults, `max_line_length = off` keeps the +existing long-line style, and `no-wildcard-imports` is disabled because a few MMS +and WorkManager adapters intentionally import whole packages. `@Composable` +functions are exempt from `function-naming`, because screens are PascalCase by +convention. + +Spotless does not load `.editorconfig` on its own: `build.gradle.kts` reads the +ktlint keys from it and passes them as `editorConfigOverride`. Keep editing the +`.editorconfig` file; the build picks up new keys. ktlint caches the parsed rules +inside the Gradle daemon, so after editing it run `./gradlew --stop` before the +next `spotlessApply` or `spotlessCheck`. + +CI checks out the full history so the `origin/main` ref that Spotless ratchets +against exists. Locally, fetch `main` first if the check complains about a missing +ref. + +## Running the pipeline locally + +```bash +./gradlew :app:assembleDebug :app:assembleRelease :sidekit:assembleDebug +./gradlew testDebugUnitTest +./gradlew lintDebug +./gradlew spotlessCheck +``` + +Reports are written to `app/build/reports/` and `sidekit/build/reports/`. + +## Branch protection + +Recommended settings for `main`: + +- Require a pull request before merging. +- Require the status checks `Gradle wrapper validation`, `Build (debug + R8 release)`, + `Unit tests`, `Android Lint`, `Kotlin formatting`, and `Secret scan`. +- Require branches to be up to date before merging. +- Do not allow bypassing the above settings. + +`Dependency review` only runs on pull requests, and `CodeQL` runs on pushes and +pull requests to `main`; keep them optional if they are still being tuned. + +## Things that are intentionally not in PR CI + +- **Network integration probes** (`scripts/verify_nsw_transit.py`) depend on a + third-party service and should run on a schedule or manually, not block a pull + request. +- **Emulator tests**: see above. +- **Release signing and publishing**: wire this up when there is a distribution + channel. The `release` build type is currently unsigned, so CI only verifies + that R8 succeeds. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c4fe9ebb..4ec1153c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,8 @@ lifecycle = "2.10.0" robolectric = "4.16.1" roborazzi = "1.77.0" coroutines = "1.9.0" +ktlint = "1.8.0" +spotless = "8.11.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -39,3 +41,4 @@ roborazzi-junit-rule = { group = "io.github.takahirom.roborazzi", name = "robora android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/sidekit/src/main/AndroidManifest.xml b/sidekit/src/main/AndroidManifest.xml new file mode 100644 index 00000000..01d48922 --- /dev/null +++ b/sidekit/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/action/HeaderHost.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/action/HeaderHost.kt index e5432baa..38b1e324 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/action/HeaderHost.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/action/HeaderHost.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -41,6 +42,14 @@ private class HeaderModel { val newScreen = top !== owner owner = top current = spec + // A screen without the shared title draws its own header in that place (the launcher's + // clock): the old title goes at once, as fading it there would draw it over that header. + if (spec == null) { + job?.cancel() + previous = null + progress = 1f + return + } // A screen updating its own title (the clock ticking, a note saved) changes in place; only a // new screen plays the swap. if (newScreen && old?.title != spec?.title && (old != null || spec != null)) { @@ -48,7 +57,7 @@ private class HeaderModel { progress = 0f job?.cancel() job = scope.launch { - animate(0f, 1f, animationSpec = SidekitMotion.content) { v, _ -> progress = v } + animate(0f, 1f, animationSpec = SidekitMotion.title) { v, _ -> progress = v } previous = null } } @@ -63,9 +72,16 @@ private class HeaderModel { internal fun HeaderHost(state: ChromeState, modifier: Modifier = Modifier) { val model = remember { HeaderModel() } val scope = rememberCoroutineScope() - val top = state.top - val spec = top?.header - SideEffect { if (top != null) model.sync(top, spec, scope) } + // Read here only to subscribe: HeaderHost recomposes whenever the top screen or its title changes. + state.top?.header + // Read at apply time, not from this composition: a screen attaching in the same frame has + // already synced through onHeaderChange, and syncing back to the stale `top` would undo it. + SideEffect { state.top?.let { model.sync(it, it.header, scope) } } + // Publishers notify in the frame a screen changes, so the title never trails the body by a frame. + DisposableEffect(state, model) { + state.onHeaderChange = { state.top?.let { model.sync(it, it.header, scope) } } + onDispose { state.onHeaderChange = null } + } val travel = with(LocalDensity.current) { TITLE_TRAVEL.toPx() } val current = model.current @@ -98,7 +114,8 @@ internal fun HeaderHost(state: ChromeState, modifier: Modifier = Modifier) { textAlign = current.align, modifier = Modifier.graphicsLayer { val p = progress() - alpha = ((p - HANDOFF) / (1f - HANDOFF)).coerceIn(0f, 1f) + // Composition hasn't caught up with a screen that has no shared title: hide it now. + alpha = if (model.current == null) 0f else ((p - HANDOFF) / (1f - HANDOFF)).coerceIn(0f, 1f) translationY = (1f - p) * travel compositingStrategy = CompositingStrategy.ModulateAlpha }, diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/action/SideAction.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/action/SideAction.kt index 0510245e..541cfa87 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/action/SideAction.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/action/SideAction.kt @@ -12,10 +12,12 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.referentialEqualityPolicy import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.text.style.TextAlign import hrln.interactive.sidekit.design.icons.SidekitIcon import hrln.interactive.sidekit.design.theme.SidekitColors @@ -154,6 +156,12 @@ class ChromeState { /** Temporary bars that take over from the screen's (T9 suggestions while typing). */ internal val overrides = mutableStateListOf() + /** Key handlers screens registered with [KeysFromActionBar]; the newest one is asked first. */ + internal val barKeys = mutableStateListOf<(KeyEvent) -> Boolean>() + + /** Offers a key pressed while focus is in the action bar to the screen that asked for it. */ + internal fun barKey(event: KeyEvent): Boolean = barKeys.lastOrNull()?.invoke(event) ?: false + internal val top: ChromePublisher? get() = publishers.lastOrNull() /** Whose options are showing in the bar, if any (see [toggleOptions]). */ @@ -213,6 +221,14 @@ class ChromeState { */ internal var onChange: (() -> Unit)? = null + /** Set by the title host, for the same reason as [onChange]. */ + internal var onHeaderChange: (() -> Unit)? = null + + internal fun changed() { + onChange?.invoke() + onHeaderChange?.invoke() + } + /** * False for the moment between one screen leaving and the next attaching. Hosts hold their * state through it rather than animating towards "nothing". @@ -263,6 +279,22 @@ internal class ChromePublisher { val LocalChrome = staticCompositionLocalOf { null } +/** + * Lets the current screen see key presses while focus is in the action bar. The bar is drawn by + * the canvas, outside the screen's body, so a key handler on the body never hears them: a screen + * that takes keypad digits anywhere (the contacts search) registers them here too. + */ +@Composable +fun KeysFromActionBar(onKey: (KeyEvent) -> Boolean) { + val chrome = LocalChrome.current ?: return + val latest by rememberUpdatedState(onKey) + DisposableEffect(chrome) { + val handler: (KeyEvent) -> Boolean = { latest(it) } + chrome.barKeys += handler + onDispose { chrome.barKeys -= handler } + } +} + /** * Publishes this screen's title and actions while it is composed. * @@ -303,15 +335,15 @@ private fun Publish(chrome: ChromeState, into: MutableList, upd // briefly read as "this screen has an empty bar" and start the wrong animation. update(publisher) into.add(publisher) - chrome.onChange?.invoke() + chrome.changed() onDispose { into.remove(publisher) chrome.detached(publisher) - chrome.onChange?.invoke() + chrome.changed() } } SideEffect { update(publisher) - chrome.onChange?.invoke() + chrome.changed() } } diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/canvas/SidekitCanvas.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/canvas/SidekitCanvas.kt index fac29327..ffad79b3 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/canvas/SidekitCanvas.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/canvas/SidekitCanvas.kt @@ -2,12 +2,14 @@ package hrln.interactive.sidekit.design.canvas import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -18,7 +20,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalView import android.view.ViewTreeObserver @@ -39,17 +46,20 @@ object SidephoneCanvas { const val HEIGHT = 640 /** - * Density that makes the 480 x 640 canvas fill [widthPx] x [heightPx] (letterboxed if the - * aspect ratio differs), so 1.dp in SideKit code is exactly 1 Figma px. + * Density that fits the 480 x 640 design inside [widthPx] x [heightPx], so 1.dp in SideKit code + * is exactly 1 Figma px. On a screen taller than 3:4 the canvas then grows down to fill it. */ fun density(widthPx: Int, heightPx: Int): Float = minOf(widthPx / WIDTH.toFloat(), heightPx / HEIGHT.toFloat()) } /** - * Lays [content] out on a fixed 480 x 640 canvas scaled to the screen. On the Sidephone this is - * a 1:1 mapping to physical pixels whatever density the system reports; on other screens the - * canvas is scaled to fit and centred. Font scale is pinned to 1 because the canvas is fixed. + * Lays [content] out on a canvas that fills the whole screen, scaled so the 480 x 640 design fits. + * On the Sidephone this is exactly 480 x 640 and a 1:1 mapping to physical pixels whatever density + * the system reports. The canvas is always 480 wide: on a taller screen it grows in height to use + * the rest of the display, and on a wider one (landscape, a tablet) it stays 640 tall and is + * centred between dark side bars, since rows and the action bar would only stretch sideways. + * Font scale is pinned to 1 because sizes follow the design, not the system font setting. * * The canvas also owns the system chrome every screen shares: the title ([HeaderHost]) and the * action bar ([ActionBarHost]), drawn above the screens so they morph between them instead of @@ -57,7 +67,7 @@ object SidephoneCanvas { * to text fields here. * * Overdraw: the activity drops its window background, so the canvas is the only full-screen - * fill. The black letterbox is only painted when there is a letterbox. + * fill. The dark side bars are only painted when there are side bars. */ @Composable fun SidekitCanvas( @@ -71,24 +81,31 @@ fun SidekitCanvas( contentAlignment = Alignment.Center, ) { val scale = SidephoneCanvas.density(constraints.maxWidth, constraints.maxHeight) - val letterboxed = constraints.maxWidth - SidephoneCanvas.WIDTH * scale >= 1f || - constraints.maxHeight - SidephoneCanvas.HEIGHT * scale >= 1f - if (letterboxed) Box(Modifier.fillMaxSize().background(SidekitTheme.colors.dark)) + if (constraints.maxWidth - SidephoneCanvas.WIDTH * scale >= 1f) Box(Modifier.fillMaxSize().background(SidekitTheme.colors.dark)) CompositionLocalProvider( LocalDensity provides Density(density = scale, fontScale = 1f), LocalChrome provides chrome, LocalKeypad provides keypad, ) { val hasFocus = revealFocusOnKeys() + val screenFocus = remember { FocusRequester() } Box( modifier = Modifier - .size(SidephoneCanvas.WIDTH.dp, SidephoneCanvas.HEIGHT.dp) + // Design width, full height: with this density the width is exactly 480 on any + // screen at least 3:4 tall, and the height is 640 or more. + .width(SidephoneCanvas.WIDTH.dp) + .fillMaxHeight() .onFocusChanged { hasFocus.value = it.hasFocus } // Screens slide during navigation; keep them inside the panel. .clipToBounds() .background(SidekitTheme.colors.surfaceA1), ) { - content() + // Screens run under the action bar, so D-pad search never finds them above it and + // Up from the bar went nowhere. The bar sends Up into this group instead: back to + // the item focus left from (lists restore their row too), or the bottom item. + Box(Modifier.fillMaxSize().focusRequester(screenFocus).focusRestorer().focusGroup()) { + content() + } // Back closes the options bar before it goes back a screen. BackHandler(enabled = chrome.optionsOpen) { chrome.closeOptions() } HeaderHost( @@ -101,6 +118,8 @@ fun SidekitCanvas( state = chrome, modifier = Modifier .align(Alignment.BottomCenter) + .focusProperties { up = screenFocus } + .onPreviewKeyEvent(chrome::barKey) .imePadding() .padding(SidekitTheme.spacing.pagePadding), ) diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/components/Scaffold.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/components/Scaffold.kt index b50a04b9..c09ff7ba 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/components/Scaffold.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/components/Scaffold.kt @@ -1,5 +1,7 @@ package hrln.interactive.sidekit.design.components +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -14,9 +16,14 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign @@ -30,7 +37,8 @@ import hrln.interactive.sidekit.design.theme.SidekitTheme /** * Header frame (62:898): 460 x 103, title inset 30/30. Drawn by the shared title host; screens - * use it directly only for a custom header. + * use it directly only for a custom header. The spacing token `headerPaddingTop` shortens it + * (with `headerHeight`) in compact mode. */ @Composable fun SidekitHeader( @@ -43,7 +51,7 @@ fun SidekitHeader( .fillMaxWidth() .height(SidekitTheme.spacing.headerHeight) .padding(horizontal = SidekitTheme.spacing.itemPadding) - .padding(top = SidekitTheme.spacing.itemPadding), + .padding(top = SidekitTheme.spacing.headerPaddingTop), ) { SidekitText( text = title, @@ -66,6 +74,11 @@ fun SidekitHeader( * [header] replaces the shared title with custom content drawn in the body (e.g. a search field). * * [content] receives padding that keeps its last item clear of the action bar. + * + * [focusParking] is somewhere to put focus when a text field is done with it: clearing focus + * instead would hand it straight back to the first field. It is an empty point at the top of the + * body that the D-pad never moves onto and moves on from as from any row; a whole-screen + * focusable would trap it. */ @Composable fun SidekitScaffold( @@ -77,6 +90,7 @@ fun SidekitScaffold( titleModifier: Modifier = Modifier, header: (@Composable () -> Unit)? = null, trailingAction: SideAction? = null, + focusParking: FocusRequester? = null, content: @Composable (PaddingValues) -> Unit, ) { val spacing = SidekitTheme.spacing @@ -114,6 +128,12 @@ fun SidekitScaffold( }, ) { val bottom = if (actions.isNotEmpty()) spacing.actionBarHeight + spacing.pageGap + spacing.pagePadding else spacing.pagePadding + // Only parked on purpose (requestFocus), never landed on by the D-pad. + if (focusParking != null) Box( + Modifier + .focusProperties { onEnter = { if (requestedFocusDirection != FocusDirection.Enter) cancelFocusChange() } } + .focusGroup(), + ) { Box(Modifier.focusRequester(focusParking).focusable().clearAndSetSemantics {}) } CompositionLocalProvider(LocalBodyEntrance provides entrance) { content(PaddingValues(bottom = bottom)) } diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SidekitList.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SidekitList.kt index 5d2d2b9d..d79cc9e9 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SidekitList.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SidekitList.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.graphics.CompositingStrategy @@ -103,7 +104,8 @@ fun SidekitList( } CompositionLocalProvider(LocalBringIntoViewSpec provides focusScroll) { LazyColumn( - modifier = modifier.fillMaxSize(), + // Coming back to the list (Up from the action bar) lands on the row focus left from. + modifier = modifier.fillMaxSize().focusRestorer(), state = state, contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(spacing.itemOuterGap), diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SidekitTextField.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SidekitTextField.kt index a4209b68..5f695774 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SidekitTextField.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/components/SidekitTextField.kt @@ -1,5 +1,11 @@ package hrln.interactive.sidekit.design.components +import android.Manifest +import android.content.pm.PackageManager +import android.speech.SpeechRecognizer +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions @@ -26,6 +32,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.onInterceptKeyBeforeSoftKeyboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.foundation.rememberScrollState @@ -39,7 +46,10 @@ import hrln.interactive.sidekit.design.action.ActionTone import hrln.interactive.sidekit.design.action.PublishActionOverride import hrln.interactive.sidekit.design.action.SideAction import hrln.interactive.sidekit.design.icons.SidekitIcon +import hrln.interactive.sidekit.design.input.Dictation import hrln.interactive.sidekit.design.input.LocalKeypad +import hrln.interactive.sidekit.design.input.insertDictated +import hrln.interactive.sidekit.design.input.rememberDictation import hrln.interactive.sidekit.design.input.T9Engine import hrln.interactive.sidekit.design.input.T9Mode import hrln.interactive.sidekit.design.input.T9Session @@ -53,11 +63,14 @@ import hrln.interactive.sidekit.design.theme.SidekitTheme * inline) and the action bar turns into word completions for the letters so far (tap one, or * D-pad left / right and OK, to finish the word). * - * When no word is being typed the bar shows the input mode (tap to switch letters / numbers) and - * Done, which ends typing and gives the screen its own actions back. + * When no word is being typed the bar shows the input mode (tap to switch letters / numbers), + * dictation (speak a phrase; it goes in at the cursor) and Done, which ends typing and gives the + * screen its own actions back. With the virtual keyboard, dictation is the keyboard's own mic. * * @param focusOnKeypad make this the screen's main field: typing on the keypad while nothing is * focused starts typing here. + * @param dictation offer the typing bar's mic. Off for fields that take numbers or a fixed format, + * for private text that shouldn't go to a speech service, and for screens with their own mic. */ @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -73,6 +86,7 @@ fun SidekitTextField( autoFocus: Boolean = false, trailingTypingAction: SideAction? = null, onKeypadFocus: (() -> Unit)? = null, + dictation: Boolean = true, ) { val keypad = LocalKeypad.current val scope = rememberCoroutineScope() @@ -105,12 +119,38 @@ fun SidekitTextField( lastT9 = t9 } + val context = LocalContext.current + val speech = rememberDictation() + // Checked up front so phones without a recogniser never show a mic that can only fail. + val canDictate = dictation && remember(context) { SpeechRecognizer.isRecognitionAvailable(context) } + // A word started on the keypad while listening is finished before the phrase goes in, so the + // phrase never lands inside it. + val dictate = { session?.finish(); speech.start { session?.finish(); state.insertDictated(it) } } + val microphone = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + // Focus can move while the permission dialog is up; then the phrase has nowhere to go. + if (granted) { if (focused) dictate() } + else Toast.makeText(context, "Allow the microphone to dictate", Toast.LENGTH_SHORT).show() + } + // Leaving the field ends dictation; a phrase still in flight would land in the wrong place. + LaunchedEffect(focused) { if (!focused) speech.release() } + if (focused && t9 && keypad != null && session != null) { PublishActionOverride( t9Actions( maxSuggestions = if (trailingTypingAction == null) MAX_SHOWN else MAX_SHOWN - 1, engine = keypad.engine, session = session, + dictation = speech.takeIf { canDictate }, + // With a screen's own (square) trailing button there's no room for Done's label: + // the other three share the rest of the bar equally, like the home dock. + compact = trailingTypingAction != null, + onDictate = { + when { + speech.listening -> speech.stop() + context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED -> dictate() + else -> microphone.launch(Manifest.permission.RECORD_AUDIO) + } + }, onDone = { session.finish() focusManager.clearFocus() @@ -227,6 +267,7 @@ fun SidekitTextField( singleLine: Boolean = false, onDone: (() -> Unit)? = null, autoFocus: Boolean = false, + dictation: Boolean = true, ) { val state = rememberTextFieldState(value) val latest by rememberUpdatedState(onValueChange) @@ -236,7 +277,7 @@ fun SidekitTextField( LaunchedEffect(value) { if (state.text.toString() != value) state.setTextAndPlaceCursorAtEnd(value) } - SidekitTextField(state, modifier, textStyle, placeholder, focusRequester, focusOnKeypad, singleLine, onDone, autoFocus) + SidekitTextField(state, modifier, textStyle, placeholder, focusRequester, focusOnKeypad, singleLine, onDone, autoFocus, dictation = dictation) } /** @@ -244,7 +285,7 @@ fun SidekitTextField( * are reused as the completions change: each key press swaps labels in place and the strip grows * or shrinks between one and three buttons, instead of rebuilding. */ -private fun t9Actions(engine: T9Engine, session: T9Session, maxSuggestions: Int, onDone: () -> Unit): List { +private fun t9Actions(engine: T9Engine, session: T9Session, dictation: Dictation?, compact: Boolean, maxSuggestions: Int, onDictate: () -> Unit, onDone: () -> Unit): List { if (engine.composing) { return engine.candidates.take(maxSuggestions).mapIndexed { i, word -> val chosen = i == engine.selected @@ -259,11 +300,26 @@ private fun t9Actions(engine: T9Engine, session: T9Session, maxSuggestions: Int, ) { session.pick(i) } } } - return listOf( - SideAction("t9.0", engine.modeLabel, tone = ActionTone.Muted, weight = 0.8f, dense = true, motion = ActionMotion.Up, contentDescription = "Input mode ${engine.modeLabel}") { + return listOfNotNull( + SideAction("t9.0", engine.modeLabel, tone = ActionTone.Muted, weight = if (compact) 1f else 0.8f, dense = true, motion = ActionMotion.Up, contentDescription = "Input mode ${engine.modeLabel}") { session.setMode(if (engine.mode == T9Mode.MultiTap) T9Mode.Numeric else T9Mode.MultiTap) }, - SideAction("t9.1", "Done", SidekitIcon.Check, iconAtEnd = true, tone = ActionTone.Secondary, motion = ActionMotion.Up, contentDescription = "Done typing", onClick = onDone), + // No mic for digits: a number is quicker to key than to say. + if (dictation != null && engine.mode != T9Mode.Numeric) { + SideAction( + "t9.1", + icon = if (dictation.listening) SidekitIcon.MicOff else SidekitIcon.Mic, + tone = if (dictation.listening) ActionTone.Primary else ActionTone.Muted, + // Crowded, the bar's buttons take equal shares, like the home dock. + square = !compact, + motion = ActionMotion.Up, + contentDescription = if (dictation.listening) "Stop dictating" else "Dictate", + onClick = onDictate, + ) + } else { + null + }, + SideAction("t9.2", if (compact) null else "Done", SidekitIcon.Check, iconAtEnd = !compact, tone = ActionTone.Secondary, motion = ActionMotion.Up, contentDescription = "Done typing", onClick = onDone), ) } diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/input/Dictation.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/input/Dictation.kt new file mode 100644 index 00000000..5b8fa99e --- /dev/null +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/input/Dictation.kt @@ -0,0 +1,124 @@ +package hrln.interactive.sidekit.design.input + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import android.widget.Toast +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextRange +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect + +/** + * Speech to text for a text field, through the platform recognizer (absent on some AOSP builds). + * Each [start] listens for one phrase and hands back what was heard; the recognizer is only held + * while listening. Problems are shown as a toast: a text field has nowhere of its own to say so. + */ +@Stable +class Dictation internal constructor(private val context: Context) { + /** True from [start] until a phrase or an error comes back. */ + var listening by mutableStateOf(false) + private set + private var recognizer: SpeechRecognizer? = null + private var onHeard: ((String) -> Unit)? = null + + fun start(onHeard: (String) -> Unit) { + release() + if (!SpeechRecognizer.isRecognitionAvailable(context)) { + tell("Speech recognition isn't installed on this phone") + return + } + this.onHeard = onHeard + try { + recognizer = SpeechRecognizer.createSpeechRecognizer(context).apply { + setRecognitionListener(Listener()) + startListening(Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1) + }) + } + listening = true + } catch (_: Exception) { + release() + tell("Couldn't start dictation") + } + } + + /** Stops listening; what was heard so far still arrives. */ + fun stop() { + try { recognizer?.stopListening() } catch (_: Exception) {} + } + + fun release() { + try { recognizer?.cancel() } catch (_: Exception) {} + recognizer?.destroy() + recognizer = null + onHeard = null + listening = false + } + + private fun tell(message: String) = Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + + private inner class Listener : RecognitionListener { + override fun onResults(results: Bundle) { + val text = results.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull().orEmpty() + val deliver = onHeard + release() + if (text.isNotBlank()) deliver?.invoke(text) + } + override fun onError(error: Int) { + release() + when (error) { + // Our own cancel() destroys the recogniser first, so this is a real start failure. + SpeechRecognizer.ERROR_CLIENT -> tell("Couldn't start dictation") + SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> tell("Dictation is busy. Try again.") + SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED, SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> tell("Dictation isn't available in this language") + SpeechRecognizer.ERROR_SPEECH_TIMEOUT, SpeechRecognizer.ERROR_NO_MATCH -> tell("Didn't hear anything") + SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> tell("Allow the microphone to dictate") + SpeechRecognizer.ERROR_NETWORK, SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> tell("Dictation needs a connection") + else -> tell("Didn't catch that. Try again.") + } + } + override fun onReadyForSpeech(params: Bundle?) = Unit + override fun onBeginningOfSpeech() = Unit + override fun onRmsChanged(rmsdB: Float) = Unit + override fun onBufferReceived(buffer: ByteArray?) = Unit + override fun onEndOfSpeech() = Unit + override fun onPartialResults(partialResults: Bundle?) = Unit + override fun onEvent(eventType: Int, params: Bundle?) = Unit + } +} + +/** A [Dictation] that stops when the screen leaves or the app goes to the background. */ +@Composable +fun rememberDictation(): Dictation { + val context = LocalContext.current + val dictation = remember(context) { Dictation(context) } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { dictation.release() } + DisposableEffect(dictation) { onDispose { dictation.release() } } + return dictation +} + +/** Puts [heard] at the cursor (replacing any selection), spaced from the word before it. */ +internal fun TextFieldState.insertDictated(heard: String) { + val text = heard.trim() + if (text.isEmpty()) return + edit { + val start = selection.min + val end = selection.max + val spaced = if (start > 0 && !asCharSequence()[start - 1].isWhitespace()) " $text" else text + replace(start, end, spaced) + selection = TextRange(start + spaced.length) + } +} diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/motion/SidekitMotion.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/motion/SidekitMotion.kt index 44ba728e..5327645f 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/motion/SidekitMotion.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/motion/SidekitMotion.kt @@ -19,6 +19,9 @@ object SidekitMotion { /** Label and icon swap inside a button. Under-damped so arrows overshoot toward their direction. */ val content = spring(dampingRatio = 0.68f, stiffness = 520f) + /** Screen title swap. Critically damped: the title rises into its fixed place and stops there. */ + val title = spring(dampingRatio = 1f, stiffness = 520f) + /** Bar sliding in from / out to the bottom edge when a screen gains or loses actions. */ val enter = spring(dampingRatio = 0.86f, stiffness = Spring.StiffnessMediumLow) diff --git a/sidekit/src/main/java/hrln/interactive/sidekit/design/theme/SidekitDimens.kt b/sidekit/src/main/java/hrln/interactive/sidekit/design/theme/SidekitDimens.kt index 73c17f6a..c177e3a8 100644 --- a/sidekit/src/main/java/hrln/interactive/sidekit/design/theme/SidekitDimens.kt +++ b/sidekit/src/main/java/hrln/interactive/sidekit/design/theme/SidekitDimens.kt @@ -24,12 +24,28 @@ data class SidekitSpacing( // Measured from components val itemPadding: Dp = 30.dp, val itemContentGap: Dp = 14.dp, + /** Space above the header title. The Figma frame uses [itemPadding]; compact mode trims it. */ + val headerPaddingTop: Dp = 30.dp, val headerHeight: Dp = 103.dp, val itemHeight: Dp = 104.dp, val actionBarHeight: Dp = 112.dp, val iconSize: Dp = 40.dp, val tileIconSize: Dp = 44.dp, -) +) { + companion object { + /** + * The measured Figma sizes, except the top and bottom chrome: the header title sits 14 + * above its line box (a 75 tall header, 28 less than the mock) and the action bar is 12 + * shorter. Slightly tighter insets buy body space on the 480 x 640 display without + * touching the type scale. Chosen from Settings → Compact layout. + */ + val Compact: SidekitSpacing = SidekitSpacing( + headerPaddingTop = 14.dp, + headerHeight = 75.dp, + actionBarHeight = 100.dp, + ) + } +} @Immutable data class SidekitShapes(