Skip to content

feat(multi-language): Add in-keyboard language switching#135

Open
kiensh wants to merge 6 commits into
cl445:developfrom
kiensh:feature/support-multi-language
Open

feat(multi-language): Add in-keyboard language switching#135
kiensh wants to merge 6 commits into
cl445:developfrom
kiensh:feature/support-multi-language

Conversation

@kiensh
Copy link
Copy Markdown
Contributor

@kiensh kiensh commented Apr 5, 2026

Closes #91

Summary

Enable users to select multiple keyboard languages in settings and cycle through them by swiping right on the globe key, without leaving the keyboard.

  • Add enabledLanguageIds to shared UserDefaults for multi-language persistence
  • Rewrite LanguageSettings with enabled list, toggle, cycling, and migration from single-language
  • Rewrite LanguageSelectionView as multi-select UI with active/enabled distinction
  • Add switchToNextLanguage() to KeyboardViewModel, triggered by globe swipe-right
  • Show current language abbreviation (e.g. "EN", "RU") on the globe key when multiple languages are enabled
  • Fully data-driven: adding a new LanguageConfig requires zero changes elsewhere
  • Seamless migration for existing single-language users

Test plan

  • 19 new unit tests covering: enable/disable, cycling (2, 3, N languages), cannot-disable-last, migration, stale ID filtering, persistence round-trip, label generation, edge cases
  • All existing tests pass (full suite on iPhone 16 Plus simulator)
  • swiftformat --lint passes
  • swiftlint --strict passes

Summary by CodeRabbit

  • New Features

    • Multi-language support: enable/disable multiple keyboard languages, persist enabled list and default (pinned) language, and cycle to the next enabled language via globe swipe (right).
  • UI/UX Improvements

    • Languages list redesigned into per-language rows with toggle and tap actions, shows “Default” status, no longer auto-dismisses on selection, and prevents disabling the last language with an alert.
    • Settings subtitle now summarizes enabled languages and indicates the default; globe key overlay can show the current language label.
  • Tests

    • Added comprehensive tests for multi-language behavior, pinning, persistence, toggling, and edge cases.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 5, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Introduces persisted multi-language support: ordered enabled languages, optional pinned default, UI to enable/disable and set default per language, globe-key cycling across enabled languages, and tests for migration, persistence, toggling, pinning, and cycling behavior.

Changes

Cohort / File(s) Summary
Language selection UI
wurstfinger/LanguageSelectionView.swift, wurstfinger/SettingsView.swift
Replaced per-language selection buttons with LanguageRow list rows that handle tap-to-pin and per-row enable/disable toggles; removed automatic dismiss on select; updated navigation title to “Languages” and added "Cannot Disable" alert.
Keyboard overlay & root view
wurstfingerKeyboard/KeyHintOverlay.swift, wurstfingerKeyboard/KeyboardRootView.swift
Added languageLabel and showLanguageLabel inputs to globe overlay and pass current language label / availability from view model; adjusted globe swipe semantics so right-swipe cycles to next language.
Settings keys
wurstfingerKeyboard/KeyboardSettings.swift
Added SettingsKey cases for enabledLanguageIds and pinnedLanguageId.
ViewModel & runtime state
wurstfingerKeyboard/KeyboardViewModel.swift, wurstfingerKeyboard/LanguageSettings.swift
Added persisted enabledLanguageIds and pinnedLanguageId; migration from legacy selected-only storage; APIs for pin/toggle/next-language; KeyboardViewModel tracks enabled IDs, adds switchToNextLanguage(), applyLanguage(_:), and exposes currentLanguageLabel / hasMultipleLanguages; changed globe-swipe handling to invoke next-language cycling.
Tests
wurstfingerTests/LanguageSettingsTests.swift, wurstfingerTests/AutoCapitalizationTests.swift
Added comprehensive tests for multi-language behavior (migration, persistence, toggling, pin/unpin, startup resolution, cycling) and adjusted a capitalization test to clear pinned setting before asserting reload behavior.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant KeyboardRootView
    participant KeyboardViewModel
    participant LanguageSettings
    participant UserDefaults

    User->>KeyboardRootView: Swipe right on globe key
    activate KeyboardRootView
    KeyboardRootView->>KeyboardViewModel: switchToNextLanguage()
    deactivate KeyboardRootView

    activate KeyboardViewModel
    KeyboardViewModel->>LanguageSettings: nextLanguageId(after: currentId)
    activate LanguageSettings
    LanguageSettings-->>KeyboardViewModel: next enabled language ID
    deactivate LanguageSettings

    KeyboardViewModel->>UserDefaults: Persist selectedLanguageId
    KeyboardViewModel->>KeyboardViewModel: applyLanguage(_:) — rebuild layout, update locale, reset shift
    KeyboardViewModel-->>User: Render updated keyboard
    deactivate KeyboardViewModel
Loading
sequenceDiagram
    participant User
    participant LanguageSelectionView
    participant LanguageSettings
    participant UserDefaults

    User->>LanguageSelectionView: Tap language row / Toggle enable switch
    activate LanguageSelectionView
    LanguageSelectionView->>LanguageSettings: selectLanguage(...) or toggleLanguage(...)
    deactivate LanguageSelectionView

    activate LanguageSettings
    alt Attempt to disable last enabled language
        LanguageSettings-->>LanguageSelectionView: return false / reject
        LanguageSelectionView-->>User: Present "Cannot Disable" alert
    else Valid change
        LanguageSettings->>UserDefaults: Persist enabledLanguageIds (JSON) and selected/pinned IDs
        LanguageSettings-->>LanguageSelectionView: Published updates
    end
    deactivate LanguageSettings
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: adding in-keyboard language switching for a multi-language feature.
Linked Issues check ✅ Passed The PR implementation addresses issue #91 by enabling multiple keyboard languages, providing enable/disable toggles, and allowing seamless switching via globe swipe-right, directly fulfilling the multilanguage support requirement.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing multilanguage support: enabling multiple languages, persistence, UI for language selection, keyboard switching, and associated tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wurstfingerKeyboard/KeyboardViewModel.swift`:
- Around line 177-183: The init uses LanguageSettings.shared.selectedLanguage
for layout/locale but reads enabledLanguageIds from the injected UserDefaults
via LanguageSettings.loadEnabledLanguageIds(from: defaults), causing a mismatch;
replace LanguageSettings.shared.selectedLanguage with a selectedLanguage loaded
from the injected store (e.g. call LanguageSettings.loadSelectedLanguage(from:
defaults) or equivalent), then compute self.layout = KeyboardLayout.layout(for:
selectedLanguage, numpadStyle: numpadStyle) and locale = selectedLanguage.locale
so both layout/locale and enabledLanguageIds consistently use the passed-in
UserDefaults rather than LanguageSettings.shared.
- Line 96: enabledLanguageIds is being updated in reloadLanguage() without
emitting objectWillChange, so views depending on hasMultipleLanguages don't
update; modify reloadLanguage() to compare the new enabledLanguageIds array with
the existing enabledLanguageIds and, if they differ and applyLanguage(_:) was
not called, call objectWillChange.send() (or mark enabledLanguageIds `@Published`)
so that changes to enabledLanguageIds trigger view updates used by
hasMultipleLanguages and KeyboardRootView.

In `@wurstfingerKeyboard/LanguageSettings.swift`:
- Around line 21-25: The public setter on enabledLanguageIds allows callers to
bypass invariants; change the declaration of `@Published` var enabledLanguageIds
to be read-only from outside (use private(set)) and ensure all updates go
through the existing mutation helpers that enforce "non-empty + selected
language is enabled" (e.g., the add/remove/toggle methods and
saveEnabledLanguages()). Also remove any external direct assignments to
enabledLanguageIds and replace them with calls to the mutation helper methods so
duplicates, empty lists, or stale IDs cannot be written.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 65c2063f-655a-4490-a9e0-8f7108d675f4

📥 Commits

Reviewing files that changed from the base of the PR and between 99c941f and 244610b.

📒 Files selected for processing (8)
  • wurstfinger/LanguageSelectionView.swift
  • wurstfinger/SettingsView.swift
  • wurstfingerKeyboard/KeyHintOverlay.swift
  • wurstfingerKeyboard/KeyboardRootView.swift
  • wurstfingerKeyboard/KeyboardSettings.swift
  • wurstfingerKeyboard/KeyboardViewModel.swift
  • wurstfingerKeyboard/LanguageSettings.swift
  • wurstfingerTests/LanguageSettingsTests.swift

Comment thread wurstfinger/LanguageSelectionView.swift Outdated
Comment thread wurstfingerKeyboard/KeyboardViewModel.swift
Comment thread wurstfingerKeyboard/KeyboardViewModel.swift Outdated
Comment thread wurstfingerKeyboard/LanguageSettings.swift Outdated
Copy link
Copy Markdown
Owner

@cl445 cl445 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great feature — clean implementation with thorough tests (19 new tests covering all edge cases). A few issues to address:

1. Nested Button in LanguageRow (Major)

LanguageRow has a Button wrapping the whole row and another Button for the toggle icon. Nested buttons in SwiftUI are unreliable — tapping the toggle can fire both onToggle and onTap, immediately reversing the disable action. Use contentShape(Rectangle()).onTapGesture() for the row tap and keep only the inner Button for the toggle.

2. enabledLanguageIds not triggering view updates in ViewModel (Major)

enabledLanguageIds is a plain private var in KeyboardViewModel. When languages are enabled/disabled in Settings without changing the active language, objectWillChange is never emitted — the globe key label stays stale. Fix: make it @Published private(set) or manually call objectWillChange.send() in reloadLanguage() when the array changes.

3. Inconsistent UserDefaults in init (Major)

KeyboardViewModel.init reads layout/locale from LanguageSettings.shared (backed by SharedDefaults.store) but reads enabledLanguageIds from the injected defaults parameter. This can cause a mismatch. Both should read from the same source.

4. Public setter on enabledLanguageIds (Minor)

In LanguageSettings, @Published var enabledLanguageIds should be @Published private(set) var to prevent callers from bypassing the invariants enforced by toggleLanguage() / selectLanguage() (non-empty list, selected language always in enabled set).

5. Singleton pattern broken

LanguageSettings.init() was intentionally made private in v1.2.0 (singleton pattern). This PR makes it public for testability. Consider making the init(userDefaults:) overload internal (visible to tests) while keeping the default init() private, so production code still goes through .shared.

6. Duplicate cycling logic

nextLanguageId(after:) in LanguageSettings and switchToNextLanguage() in KeyboardViewModel implement the same cycling logic independently. The ViewModel should call LanguageSettings.nextLanguageId(after:) instead of reimplementing the index arithmetic.

7. JSON serialization for String array (Minor)

saveEnabledLanguageIds uses JSONEncoder/JSONDecoder for a [String]. UserDefaults natively supports string arrays via set(_:forKey:) and stringArray(forKey:), including cross-process — the JSON round-trip adds unnecessary complexity.

kiensh added a commit to kiensh/wurstfinger that referenced this pull request Apr 9, 2026
- Fix nested Button in LanguageRow: use contentShape + onTapGesture
  for row tap, keep only inner Button for toggle (cl445 cl445#1)
- Make enabledLanguageIds @published in KeyboardViewModel so globe
  key label updates when enabled list changes (cl445 cl445#2)
- Use injected UserDefaults consistently in KeyboardViewModel init
  instead of mixing .shared and raw defaults (cl445 cl445#3)
- Make enabledLanguageIds private(set) in LanguageSettings to enforce
  mutation through toggleLanguage/selectLanguage (cl445 cl445#4)
- Deduplicate cycling logic: extract static nextLanguageId(after:in:)
  on LanguageSettings, call from both instance method and ViewModel (cl445 cl445#6)
- Replace JSON encode/decode with native UserDefaults stringArray
  for enabled language persistence (cl445 cl445#7)

Made-with: Cursor
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
wurstfingerKeyboard/LanguageSettings.swift (1)

10-25: ⚠️ Potential issue | 🔴 Critical

Import Combine for ObservableObject and @Published.

This file only imports Foundation, but ObservableObject and @Published are defined in the Combine framework. The code will not compile without importing Combine.

🛠️ Suggested fix
 import Foundation
+import Combine
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wurstfingerKeyboard/LanguageSettings.swift` around lines 10 - 25, Add the
missing Combine import at the top of the file so ObservableObject and `@Published`
are available: update the imports for the LanguageSettings class (which declares
ObservableObject conformance and uses `@Published` properties like
selectedLanguageId and enabledLanguageIds) to include "import Combine" alongside
existing Foundation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wurstfingerKeyboard/KeyboardViewModel.swift`:
- Around line 229-239: reloadLanguage currently reads the raw array from
sharedDefaults and assigns it directly to enabledLanguageIds, bypassing
LanguageSettings' normalization; change reloadLanguage to use
LanguageSettings.loadEnabledLanguageIds(from: sharedDefaults) (or its
initializer/normalize path) to repopulate enabledLanguageIds so stale/empty IDs
are filtered and a non-empty set is guaranteed before calling applyLanguage or
allowing switchToNextLanguage to run; ensure you still read the selected
languageId from sharedDefaults and only call applyLanguage(_: ) if the
normalized enabledLanguageIds contain that id.

In `@wurstfingerKeyboard/LanguageSettings.swift`:
- Around line 113-134: selectLanguage(_:) and the removal branch of
toggleLanguage(_:) can transiently persist a selectedLanguageId that is not
present in enabledLanguageIds; fix by always mutating the
enabledLanguageIds/enabled set in the correct order so the selectedLanguageId is
never outside the enabled set: in selectLanguage(_:) ensure you append
language.id to enabledLanguageIds before assigning selectedLanguageId, and in
toggleLanguage(_:) when removing the currently selected language assign
selectedLanguageId to the replacement (e.g. enabledLanguageIds.first(where: !=
language.id) or the first remaining id) before removing language.id from
enabledLanguageIds so that at persistence time selectedLanguageId is always one
of enabledLanguageIds.
- Around line 107-111: Change the user-visible language label to use
locale-aware casing and centralize the formatting logic in LanguageSettings:
replace the plain uppercased() in the currentLanguageLabel computed property
(which currently derives lang via
selectedLanguage.locale.language.languageCode?.identifier ?? selectedLanguageId)
with uppercased(using the appropriate Locale) — e.g. uppercased(with:
selectedLanguage.locale) — and extract that logic into a single public formatter
(a computed property or a static method on LanguageSettings) so
KeyboardViewModel can call the same formatter instead of duplicating the
identifier + uppercasing logic.

---

Outside diff comments:
In `@wurstfingerKeyboard/LanguageSettings.swift`:
- Around line 10-25: Add the missing Combine import at the top of the file so
ObservableObject and `@Published` are available: update the imports for the
LanguageSettings class (which declares ObservableObject conformance and uses
`@Published` properties like selectedLanguageId and enabledLanguageIds) to include
"import Combine" alongside existing Foundation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 97210d0c-419b-4f97-aad2-854762a17db4

📥 Commits

Reviewing files that changed from the base of the PR and between 244610b and 13c6aac.

📒 Files selected for processing (3)
  • wurstfinger/LanguageSelectionView.swift
  • wurstfingerKeyboard/KeyboardViewModel.swift
  • wurstfingerKeyboard/LanguageSettings.swift

Comment thread wurstfingerKeyboard/KeyboardViewModel.swift Outdated
Comment thread wurstfingerKeyboard/Settings/LanguageSettings.swift
Comment thread wurstfingerKeyboard/Settings/LanguageSettings.swift
@cl445
Copy link
Copy Markdown
Owner

cl445 commented Apr 10, 2026

Hi @kiensh, thanks a lot for this contribution — in-keyboard language switching is a great addition and I really appreciate the effort you put into it! 🙏

Before I can take another pass at merging, could you please address the remaining unresolved CodeRabbit findings on this PR? There are currently three open ones that still apply to the latest commit (13c6aac):

  1. KeyboardViewModel.reloadLanguage() — still reads stringArray directly and bypasses the normalization guarantees from LanguageSettings.init. If shared defaults ever contain stale or empty IDs, switchToNextLanguage() can cycle to an unknown ID and the globe swipe appears dead. Easiest fix: re-instantiate LanguageSettings(userDefaults: sharedDefaults) and read the normalized values from it.
  2. LanguageSettings.currentLanguageLabel — uses plain uppercased() instead of locale-aware uppercased(with: selectedLanguage.locale). The same duplicated logic in KeyboardViewModel should be updated too (or, better, centralized here).
  3. LanguageSettings.selectLanguage / toggleLanguage — the write order can transiently persist a selectedLanguageId that is not in enabledLanguageIds, which the keyboard extension can observe via shared UserDefaults. Please make sure the selected ID is always inside the enabled set at every persistence step (append before selecting; switch the selected ID before removing).

Once those are addressed I'll re-review and we can get this merged. Thanks again!

kiensh added a commit to kiensh/wurstfinger that referenced this pull request Apr 11, 2026
- Fix nested Button in LanguageRow: use contentShape + onTapGesture
  for row tap, keep only inner Button for toggle (cl445 cl445#1)
- Make enabledLanguageIds @published in KeyboardViewModel so globe
  key label updates when enabled list changes (cl445 cl445#2)
- Use injected UserDefaults consistently in KeyboardViewModel init
  instead of mixing .shared and raw defaults (cl445 cl445#3)
- Make enabledLanguageIds private(set) in LanguageSettings to enforce
  mutation through toggleLanguage/selectLanguage (cl445 cl445#4)
- Deduplicate cycling logic: extract static nextLanguageId(after:in:)
  on LanguageSettings, call from both instance method and ViewModel (cl445 cl445#6)
- Replace JSON encode/decode with native UserDefaults stringArray
  for enabled language persistence (cl445 cl445#7)

Made-with: Cursor
@kiensh kiensh force-pushed the feature/support-multi-language branch from 13c6aac to 6902e2a Compare April 11, 2026 16:30
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
wurstfingerKeyboard/KeyboardViewModel.swift (1)

267-289: ⚠️ Potential issue | 🟠 Major

Normalize the reloaded enabled-language list before using it.

If shared defaults contain [] or only stale IDs, this branch leaves enabledLanguageIds unchanged. switchToNextLanguage() then keeps cycling the old in-memory list, which is the same dead-globe failure mode from the earlier review.

Proposed fix
     private func reloadLanguage() {
-        if let raw = LanguageSettings.loadEnabledLanguageIds(from: sharedDefaults) {
-            let valid = raw.filter { LanguageConfig.language(withId: $0) != nil }
-            if !valid.isEmpty {
-                enabledLanguageIds = valid
-            }
-        }
+        let storedSelected = sharedDefaults.string(forKey: SettingsKey.selectedLanguageId.rawValue)
+            ?? LanguageSettings.detectSystemLanguage()
+        let resolvedSelected = LanguageConfig.language(withId: storedSelected)?.id
+            ?? LanguageConfig.english.id
+
+        if let raw = LanguageSettings.loadEnabledLanguageIds(from: sharedDefaults) {
+            let valid = raw.filter { LanguageConfig.language(withId: $0) != nil }
+            enabledLanguageIds = valid.isEmpty ? [resolvedSelected] : valid
+        } else {
+            enabledLanguageIds = [resolvedSelected]
+        }
+
+        if !enabledLanguageIds.contains(resolvedSelected) {
+            enabledLanguageIds.insert(resolvedSelected, at: 0)
+        }
 
         // Pinned language overrides last-used on keyboard appear
         let pinned = sharedDefaults.string(forKey: SettingsKey.pinnedLanguageId.rawValue)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wurstfingerKeyboard/KeyboardViewModel.swift` around lines 267 - 289,
reloadLanguage() currently ignores an empty or all-invalid enabled-language list
and leaves enabledLanguageIds unchanged, causing switchToNextLanguage() to keep
cycling stale IDs; update reloadLanguage() to normalize the loaded list by
replacing enabledLanguageIds with the filtered valid list even when that list is
empty (or, if empty, fall back to a sensible default such as
LanguageSettings.detectSystemLanguage() or a single available
LanguageConfig.language(withId:) id), i.e. use
LanguageSettings.loadEnabledLanguageIds(from:) -> valid = raw.filter {
LanguageConfig.language(withId: $0) != nil } and then assign enabledLanguageIds
= valid (or enabledLanguageIds = [fallbackId] when valid.isEmpty) before
applying the pinned/selected logic so switchToNextLanguage() operates on a fresh
normalized list.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wurstfingerKeyboard/KeyboardViewModel.swift`:
- Around line 180-193: The initializer currently always uses
languageSettings.selectedLanguage; change it to prefer
languageSettings.pinnedLanguageId when present so startup honors the pinned
language. Concretely: when computing selectedLanguage (used by
KeyboardLayout.layout(for:...) and locale), check if
languageSettings.pinnedLanguageId exists and resolve that to the Language
instance (via the existing lookup method on LanguageSettings or Language),
otherwise fall back to selectedLanguage; then use that resolved language to
construct self.layout, set locale, and enabledLanguageIds. Update references to
selectedLanguage in this block (KeyboardLayout.layout(for:), locale assignment)
to use the resolved pinned-or-selected language.

In `@wurstfingerTests/LanguageSettingsTests.swift`:
- Around line 564-579: Current tests assert LanguageSettings.startupLanguageId
but not that KeyboardViewModel boots from the pin; add a ViewModel-level test
that uses createTestDefaults/createSettings to persist a pinnedId (e.g.
"ru_RU"), instantiate KeyboardViewModel(userDefaults: defaults) and assert the
view model's startup/current language property (e.g. KeyboardViewModel's
selectedLanguageId or startupLanguageId) equals the pinnedId; this will catch
regressions where KeyboardViewModel reads selectedId instead of
LanguageSettings.startupLanguageId.

---

Duplicate comments:
In `@wurstfingerKeyboard/KeyboardViewModel.swift`:
- Around line 267-289: reloadLanguage() currently ignores an empty or
all-invalid enabled-language list and leaves enabledLanguageIds unchanged,
causing switchToNextLanguage() to keep cycling stale IDs; update
reloadLanguage() to normalize the loaded list by replacing enabledLanguageIds
with the filtered valid list even when that list is empty (or, if empty, fall
back to a sensible default such as LanguageSettings.detectSystemLanguage() or a
single available LanguageConfig.language(withId:) id), i.e. use
LanguageSettings.loadEnabledLanguageIds(from:) -> valid = raw.filter {
LanguageConfig.language(withId: $0) != nil } and then assign enabledLanguageIds
= valid (or enabledLanguageIds = [fallbackId] when valid.isEmpty) before
applying the pinned/selected logic so switchToNextLanguage() operates on a fresh
normalized list.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 370a9301-3e20-4934-bff5-f3d8e5bd469c

📥 Commits

Reviewing files that changed from the base of the PR and between 6902e2a and 00ec089.

📒 Files selected for processing (6)
  • wurstfinger/LanguageSelectionView.swift
  • wurstfinger/SettingsView.swift
  • wurstfingerKeyboard/KeyboardSettings.swift
  • wurstfingerKeyboard/KeyboardViewModel.swift
  • wurstfingerKeyboard/LanguageSettings.swift
  • wurstfingerTests/LanguageSettingsTests.swift

Comment thread wurstfingerKeyboard/KeyboardViewModel.swift Outdated
Comment thread wurstfingerTests/LanguageSettingsTests.swift
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
wurstfingerTests/AutoCapitalizationTests.swift (1)

223-247: ⚠️ Potential issue | 🟠 Major

Avoid mutating SharedDefaults.store in this test.

The new removeObject(forKey: pinnedLanguageId) makes this test permanently alter one more global key, and it still never snapshots/restores enabledLanguageIds. That makes the assertion order-dependent on whatever prior tests left in shared defaults. Since KeyboardViewModel already accepts injected UserDefaults, use a per-test suite here instead.

Safer pattern
 `@Test` func testReloadLanguageResetsShiftState() {
-    let viewModel = KeyboardViewModel(shouldPersistSettings: false)
+    let suiteName = "test.reloadLanguageResetsShiftState.\(UUID().uuidString)"
+    let defaults = UserDefaults(suiteName: suiteName)!
+    defer { defaults.removePersistentDomain(forName: suiteName) }
+    defaults.set("en_US", forKey: SettingsKey.selectedLanguageId.rawValue)
+    LanguageSettings.saveEnabledLanguageIds(["en_US", "de_DE"], to: defaults)
+
+    let viewModel = KeyboardViewModel(userDefaults: defaults, shouldPersistSettings: false)
     viewModel.bindActionHandler { _ in }
@@
-    let store = SharedDefaults.store
+    let store = defaults
     let currentId = viewModel.currentLocale().identifier
     let newId = (currentId == "en_US") ? "de_DE" : "en_US"
     store.set(newId, forKey: SettingsKey.selectedLanguageId.rawValue)
     store.removeObject(forKey: SettingsKey.pinnedLanguageId.rawValue)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wurstfingerTests/AutoCapitalizationTests.swift` around lines 223 - 247, This
test must stop mutating the global SharedDefaults.store; instead create a
per-test UserDefaults suite (e.g., a temporary suiteName or an in-memory
UserDefaults) and seed it with the necessary keys
(SettingsKey.selectedLanguageId and any enabledLanguageIds/pinnedLanguageId
state you need) then construct KeyboardViewModel with that injected UserDefaults
(use the KeyboardViewModel initializer that accepts injected UserDefaults rather
than SharedDefaults.store), call viewModel.reloadSettings(), and assert; finally
clean up the temporary suite (remove persistent domain) so no global state is
changed.
♻️ Duplicate comments (1)
wurstfingerKeyboard/KeyboardViewModel.swift (1)

271-293: ⚠️ Potential issue | 🟠 Major

reloadLanguage() should not reapply startup pin logic on every settings refresh.

This path runs for generic UserDefaults reloads, not just keyboard startup. Recomputing languageId from pinnedLanguageId here means a user who temporarily switched away from the pinned language can get snapped back to the pin when any unrelated setting changes (for example toggleUtilityColumnPosition()). It also still bypasses the full normalization in LanguageSettings(userDefaults:), so stale/empty enabled lists are only partially repaired here.

Suggested direction
-private func reloadLanguage() {
-    if let raw = LanguageSettings.loadEnabledLanguageIds(from: sharedDefaults) {
-        let valid = raw.filter { LanguageConfig.language(withId: $0) != nil }
-        if !valid.isEmpty {
-            enabledLanguageIds = valid
-        }
-    }
-
-    // Pinned language overrides last-used on keyboard appear
-    let pinned = sharedDefaults.string(forKey: SettingsKey.pinnedLanguageId.rawValue)
-    let languageId = if let pinned, enabledLanguageIds.contains(pinned),
-                        LanguageConfig.language(withId: pinned) != nil {
-        pinned
-    } else {
-        sharedDefaults.string(forKey: SettingsKey.selectedLanguageId.rawValue)
-            ?? LanguageSettings.detectSystemLanguage()
-    }
+private func reloadLanguage(applyStartupOverride: Bool = false) {
+    let settings = LanguageSettings(userDefaults: sharedDefaults)
+    enabledLanguageIds = settings.enabledLanguageIds
+    let languageId = applyStartupOverride ? settings.startupLanguageId : settings.selectedLanguageId
 
     if languageId != locale.identifier {
         applyLanguage(languageId)
         if shouldPersistSettings {
             sharedDefaults.set(languageId, forKey: SettingsKey.selectedLanguageId.rawValue)
         }
     }
 }

Then call reloadLanguage(applyStartupOverride: true) only from the actual startup/appear flow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wurstfingerKeyboard/KeyboardViewModel.swift` around lines 271 - 293,
reloadLanguage currently reapplies the startup "pinned" override on every
UserDefaults refresh which can snap users back to a pinned language; change
reloadLanguage to accept a Bool parameter (e.g., applyStartupOverride: Bool =
false) and only consult SettingsKey.pinnedLanguageId and apply the pinned
override when applyStartupOverride is true; keep the existing enabledLanguageIds
normalization by continuing to use
LanguageSettings.loadEnabledLanguageIds(from:) (or construct
LanguageSettings(userDefaults:) to get full normalization) and only fall back to
selectedLanguageId/LanguageSettings.detectSystemLanguage() when not applying the
startup override; update the keyboard startup/appear flow to call
reloadLanguage(applyStartupOverride: true) while leaving other UserDefaults
reload triggers to call reloadLanguage() (default false); relevant symbols:
reloadLanguage, LanguageSettings.loadEnabledLanguageIds /
LanguageSettings(userDefaults:), sharedDefaults, SettingsKey.pinnedLanguageId,
SettingsKey.selectedLanguageId, applyLanguage, shouldPersistSettings.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@wurstfingerKeyboard/KeyboardViewModel.swift`:
- Around line 524-528: The computed property currentLanguageLabel duplicates
locale-sensitive uppercasing logic and should delegate to the centralized
implementation in LanguageSettings instead of recomputing from locale; replace
the body of currentLanguageLabel to return the shared/appropriate
LanguageSettings property or helper (e.g., LanguageSettings.shared.shortLabel or
LanguageSettings.shortLanguageLabel(for:)) that already performs locale-aware
uppercasing (handles de_DE and other locales) and remove the manual
uppercased(with: locale) logic here.
- Around line 317-319: The switchToNextLanguage() branch currently writes nextId
into sharedDefaults unconditionally; change it to respect the
shouldPersistSettings flag by only calling sharedDefaults.set(nextId, forKey:
SettingsKey.selectedLanguageId.rawValue) when shouldPersistSettings is true,
while still calling applyLanguage(nextId) when nextId != currentId. Locate the
conditional around nextId/currentId in switchToNextLanguage() and add the guard
around the sharedDefaults write (leave the comparison and applyLanguage call
intact).

---

Outside diff comments:
In `@wurstfingerTests/AutoCapitalizationTests.swift`:
- Around line 223-247: This test must stop mutating the global
SharedDefaults.store; instead create a per-test UserDefaults suite (e.g., a
temporary suiteName or an in-memory UserDefaults) and seed it with the necessary
keys (SettingsKey.selectedLanguageId and any enabledLanguageIds/pinnedLanguageId
state you need) then construct KeyboardViewModel with that injected UserDefaults
(use the KeyboardViewModel initializer that accepts injected UserDefaults rather
than SharedDefaults.store), call viewModel.reloadSettings(), and assert; finally
clean up the temporary suite (remove persistent domain) so no global state is
changed.

---

Duplicate comments:
In `@wurstfingerKeyboard/KeyboardViewModel.swift`:
- Around line 271-293: reloadLanguage currently reapplies the startup "pinned"
override on every UserDefaults refresh which can snap users back to a pinned
language; change reloadLanguage to accept a Bool parameter (e.g.,
applyStartupOverride: Bool = false) and only consult
SettingsKey.pinnedLanguageId and apply the pinned override when
applyStartupOverride is true; keep the existing enabledLanguageIds normalization
by continuing to use LanguageSettings.loadEnabledLanguageIds(from:) (or
construct LanguageSettings(userDefaults:) to get full normalization) and only
fall back to selectedLanguageId/LanguageSettings.detectSystemLanguage() when not
applying the startup override; update the keyboard startup/appear flow to call
reloadLanguage(applyStartupOverride: true) while leaving other UserDefaults
reload triggers to call reloadLanguage() (default false); relevant symbols:
reloadLanguage, LanguageSettings.loadEnabledLanguageIds /
LanguageSettings(userDefaults:), sharedDefaults, SettingsKey.pinnedLanguageId,
SettingsKey.selectedLanguageId, applyLanguage, shouldPersistSettings.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8d7a149a-757e-4b9c-b576-b3de7095c3cc

📥 Commits

Reviewing files that changed from the base of the PR and between 00ec089 and caf65ab.

📒 Files selected for processing (3)
  • wurstfingerKeyboard/KeyboardViewModel.swift
  • wurstfingerTests/AutoCapitalizationTests.swift
  • wurstfingerTests/LanguageSettingsTests.swift

Comment on lines +317 to +319
if nextId != currentId {
sharedDefaults.set(nextId, forKey: SettingsKey.selectedLanguageId.rawValue)
applyLanguage(nextId)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Honor shouldPersistSettings when cycling languages.

The init/reload paths already suppress selectedLanguageId writes for non-persisting view models, but switchToNextLanguage() still writes unconditionally. That leaks cycled state into shared defaults from tests and defeats the isolation this flag is meant to provide.

Small fix
         if nextId != currentId {
-            sharedDefaults.set(nextId, forKey: SettingsKey.selectedLanguageId.rawValue)
+            if shouldPersistSettings {
+                sharedDefaults.set(nextId, forKey: SettingsKey.selectedLanguageId.rawValue)
+            }
             applyLanguage(nextId)
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wurstfingerKeyboard/KeyboardViewModel.swift` around lines 317 - 319, The
switchToNextLanguage() branch currently writes nextId into sharedDefaults
unconditionally; change it to respect the shouldPersistSettings flag by only
calling sharedDefaults.set(nextId, forKey:
SettingsKey.selectedLanguageId.rawValue) when shouldPersistSettings is true,
while still calling applyLanguage(nextId) when nextId != currentId. Locate the
conditional around nextId/currentId in switchToNextLanguage() and add the guard
around the sharedDefaults write (leave the comparison and applyLanguage call
intact).

Comment on lines +524 to +528
/// Short uppercase label for the current language, e.g. "EN", "RU"
var currentLanguageLabel: String {
let lang = locale.language.languageCode?.identifier ?? ""
return lang.uppercased(with: locale)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Centralize the language-label formatter.

LanguageSettings already exposes this derived label. Keeping a second locale-sensitive uppercase implementation here recreates the drift that caused the casing bug in the first place. Prefer a shared helper/property instead of recomputing it from locale.

As per coding guidelines, ensure locale-aware uppercasing (German: de_DE).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wurstfingerKeyboard/KeyboardViewModel.swift` around lines 524 - 528, The
computed property currentLanguageLabel duplicates locale-sensitive uppercasing
logic and should delegate to the centralized implementation in LanguageSettings
instead of recomputing from locale; replace the body of currentLanguageLabel to
return the shared/appropriate LanguageSettings property or helper (e.g.,
LanguageSettings.shared.shortLabel or LanguageSettings.shortLanguageLabel(for:))
that already performs locale-aware uppercasing (handles de_DE and other locales)
and remove the manual uppercased(with: locale) logic here.

kiensh added 6 commits April 22, 2026 21:24
- Enable users to select multiple keyboard languages in settings and cycle through them by swiping right on the globe key.
- The language indicator appears on the globe key when more than one language is enabled.
- Existing single-language users are seamlessly migrated.
- Fix nested Button in LanguageRow: use contentShape + onTapGesture
  for row tap, keep only inner Button for toggle (cl445 cl445#1)
- Make enabledLanguageIds @published in KeyboardViewModel so globe
  key label updates when enabled list changes (cl445 cl445#2)
- Use injected UserDefaults consistently in KeyboardViewModel init
  instead of mixing .shared and raw defaults (cl445 cl445#3)
- Make enabledLanguageIds private(set) in LanguageSettings to enforce
  mutation through toggleLanguage/selectLanguage (cl445 cl445#4)
- Deduplicate cycling logic: extract static nextLanguageId(after:in:)
  on LanguageSettings, call from both instance method and ViewModel (cl445 cl445#6)
- Replace JSON encode/decode with native UserDefaults stringArray
  for enabled language persistence (cl445 cl445#7)

Made-with: Cursor
- Add import Combine for ObservableObject/@published
- Fix mutation order in selectLanguage/toggleLanguage to prevent
  transient state where selectedLanguageId is outside enabledLanguageIds
- Use locale-aware uppercased(with:) in currentLanguageLabel
- Use LanguageSettings normalization in reloadLanguage to filter stale IDs

Made-with: Cursor
…rrides startup

When the keyboard opens with a pinned default language, selectedLanguageId
in UserDefaults still held the last-used language from the previous session.
This caused the first globe swipe-right to cycle back to the already-displayed
pinned language (a no-op), requiring a second swipe to actually switch.
The init was using selectedLanguage instead of startupLanguageId,
causing the keyboard to briefly boot in the wrong language when a
pin was set. Guard selectedLanguageId sync writes behind
shouldPersistSettings to avoid test interference. Add ViewModel-level
integration test for pinned startup.
… architecture

Adapt multi-language features (swipe-right on globe key to cycle languages,
language label overlay, enabledLanguageIds persistence) to work with the new
data-driven keyboard pipeline introduced on develop.
@kiensh kiensh force-pushed the feature/support-multi-language branch from caf65ab to 818a6ec Compare April 22, 2026 14:59
@kiensh kiensh requested a review from cl445 April 23, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multilanguage support

2 participants