diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ec5575f..0ae2b95 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -130,7 +130,8 @@ dependencies {
androidTestImplementation(composeBom)
testImplementation(libs.junit)
- testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.json)
debugImplementation(libs.androidx.compose.ui.tooling)
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 18a8827..4cee6ce 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,14 @@
+
+
+
+
+
+
+
+
(null)
+ private val isIncognitoModeFlow = MutableStateFlow(false)
// Sub-Screen Reactive Data Flows
private val autoTextListFlow = MutableStateFlow>(emptyList())
@@ -135,6 +139,31 @@ class KeyboardIME : BaseKeyboardIME() {
checkAndCaptureClipboard()
}
+ // Ergonomics & Theming State Flows
+ private val isNumberRowEnabledFlow = MutableStateFlow(false)
+ private val oneHandedModeFlow = MutableStateFlow("OFF")
+ private val isDynamicThemeEnabledFlow = MutableStateFlow(false)
+ private val isSplitModeEnabledFlow = MutableStateFlow(false)
+ private val activeLanguageFlow = MutableStateFlow("ID")
+ private val textExpansionMatchFlow = MutableStateFlow(null)
+ private val mathCalculationResultFlow = MutableStateFlow(null)
+ private val isFloatingModeFlow = MutableStateFlow(false)
+ private val heightScaleFlow = MutableStateFlow("NORMAL")
+ private val bottomChinOffsetFlow = MutableStateFlow("NONE")
+ private val hasImeErrorFlow = MutableStateFlow(false)
+ private val imeErrorMessageFlow = MutableStateFlow(null)
+ private val undoStack = mutableListOf()
+ private val redoStack = mutableListOf()
+
+ // AI Assistant & Voice Typing State Flows (Tier 3)
+ private val aiAssistantInitialTextFlow = MutableStateFlow("")
+ private var voiceTypingHelper: VoiceTypingHelper? = null
+ private val isVoiceTypingActiveFlow = MutableStateFlow(false)
+ private val isVoiceListeningFlow = MutableStateFlow(false)
+ private val voiceAmplitudeFlow = MutableStateFlow(0f)
+ private val voiceStatusTextFlow = MutableStateFlow("")
+ private val voiceErrorMessageFlow = MutableStateFlow(null)
+
// Emoji State Flows
private val emojisFlow = MutableStateFlow>(emptyList())
private val selectedEmojiCategoryFlow = MutableStateFlow(EmojiCategoryType.GENERAL)
@@ -156,6 +185,11 @@ class KeyboardIME : BaseKeyboardIME() {
private val currentKeyboardFlow = MutableStateFlow(null)
private var keyConsumer: ((Int, Boolean) -> Boolean)? = null
+ // Gesture Glide Typing & Swipe Shortcuts State Flows
+ private val isGestureTypingEnabledFlow = MutableStateFlow(true)
+ private val isSwipeShortcutsEnabledFlow = MutableStateFlow(true)
+ private var lastGestureWord: String? = null
+
override fun onCreate() {
super.onCreate()
imeLifecycleOwner.onCreate()
@@ -169,6 +203,13 @@ class KeyboardIME : BaseKeyboardIME() {
applySoundAndHapticSettings()
setupTheme()
setupFeatureKeyboard()
+ isNumberRowEnabledFlow.value = keyboardUtil.isNumberRowEnabled()
+ oneHandedModeFlow.value = keyboardUtil.getOneHandedMode()
+ isDynamicThemeEnabledFlow.value = keyboardUtil.isDynamicThemeEnabled()
+ isSplitModeEnabledFlow.value = keyboardUtil.isSplitModeEnabled()
+ activeLanguageFlow.value = keyboardUtil.getActiveLanguage()
+ isGestureTypingEnabledFlow.value = keyboardUtil.isGestureTypingEnabled()
+ isSwipeShortcutsEnabledFlow.value = keyboardUtil.isGestureSwipeShortcutsEnabled()
loadAutoText()
loadProductRemote()
@@ -204,6 +245,7 @@ class KeyboardIME : BaseKeyboardIME() {
override fun onWindowHidden() {
super.onWindowHidden()
+ stopVoiceTyping()
imeLifecycleOwner.onPause()
imeLifecycleOwner.onStop()
productRemoteJob?.cancel()
@@ -215,7 +257,54 @@ class KeyboardIME : BaseKeyboardIME() {
} catch (_: Exception) {}
}
+ override fun onEvaluateFullscreenMode(): Boolean = false
+
+ override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) {
+ super.onStartInput(attribute, restarting)
+ updateIncognitoState(attribute)
+ }
+
+ override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
+ super.onStartInputView(info, restarting)
+ updateIncognitoState(info)
+ isFloatingModeFlow.value = keyboardUtil.isFloatingModeEnabled()
+ heightScaleFlow.value = keyboardUtil.getHeightScale()
+ bottomChinOffsetFlow.value = keyboardUtil.getBottomChinOffset()
+ enterKeyType = info?.imeOptions?.and(EditorInfo.IME_MASK_ACTION or EditorInfo.IME_FLAG_NO_ENTER_ACTION) ?: 0
+ keyboard?.mEnterKeyType = enterKeyType
+ val enterResourceId = when (enterKeyType) {
+ EditorInfo.IME_ACTION_SEARCH -> com.frogobox.libkeyboard.R.drawable.ic_keyboard_search
+ EditorInfo.IME_ACTION_NEXT, EditorInfo.IME_ACTION_GO -> com.frogobox.libkeyboard.R.drawable.ic_keyboard_arrow_right
+ EditorInfo.IME_ACTION_SEND -> com.frogobox.libkeyboard.R.drawable.ic_keyboard_send
+ EditorInfo.IME_ACTION_DONE -> com.frogobox.libkeyboard.R.drawable.ic_keyboard_done
+ else -> com.frogobox.libkeyboard.R.drawable.ic_keyboard_enter
+ }
+ keyboard?.mKeys?.firstOrNull { it.code == ItemMainKeyboard.KEYCODE_ENTER }?.let { enterKey ->
+ enterKey.icon = androidx.core.content.res.ResourcesCompat.getDrawable(resources, enterResourceId, theme)
+ invalidateAllKeys()
+ }
+ }
+
+ override fun onFinishInputView(finishingInput: Boolean) {
+ super.onFinishInputView(finishingInput)
+ isIncognitoModeFlow.value = false
+ stopVoiceTyping()
+ }
+
+ private fun updateIncognitoState(info: EditorInfo?) {
+ val isIncognito = KeyboardPrivacyHelper.isIncognitoOrPassword(info)
+ isIncognitoModeFlow.value = isIncognito
+ if (isIncognito) {
+ suggestionJob?.cancel()
+ suggestionResultFlow.value = SuggestionResult.EMPTY
+ isSuggestionVisibleFlow.value = false
+ recentClipFlow.value = null
+ }
+ }
+
override fun onDestroy() {
+ voiceTypingHelper?.destroy()
+ voiceTypingHelper = null
super.onDestroy()
productRemoteJob?.cancel()
autoTextJob?.cancel()
@@ -268,6 +357,28 @@ class KeyboardIME : BaseKeyboardIME() {
val recentClip by recentClipFlow.collectAsState()
val isSelectionMode by isSelectionModeFlow.collectAsState()
val alwaysShowFeature by alwaysShowFeatureFlow.collectAsState()
+ val isIncognitoMode by isIncognitoModeFlow.collectAsState()
+ val isNumberRowEnabled by isNumberRowEnabledFlow.collectAsState()
+ val currentOneHandedMode by oneHandedModeFlow.collectAsState()
+ val isDynamicTheming by isDynamicThemeEnabledFlow.collectAsState()
+
+ val aiInitialText by aiAssistantInitialTextFlow.collectAsState()
+ val isVoiceActive by isVoiceTypingActiveFlow.collectAsState()
+ val isVoiceListening by isVoiceListeningFlow.collectAsState()
+ val voiceAmplitude by voiceAmplitudeFlow.collectAsState()
+ val voiceStatusText by voiceStatusTextFlow.collectAsState()
+ val voiceErrorMessage by voiceErrorMessageFlow.collectAsState()
+
+ val isSplitMode by isSplitModeEnabledFlow.collectAsState()
+ val activeLanguage by activeLanguageFlow.collectAsState()
+ val textExpansionMatch by textExpansionMatchFlow.collectAsState()
+ val mathResult by mathCalculationResultFlow.collectAsState()
+ val isFloating by isFloatingModeFlow.collectAsState()
+ val chinOffset by bottomChinOffsetFlow.collectAsState()
+ val hasError by hasImeErrorFlow.collectAsState()
+ val errorMsg by imeErrorMessageFlow.collectAsState()
+ val isGestureTypingEnabled by isGestureTypingEnabledFlow.collectAsState()
+ val isSwipeShortcutsEnabled by isSwipeShortcutsEnabledFlow.collectAsState()
KeyboardImeRootScreen(
activePanelState = activePanel,
@@ -278,6 +389,8 @@ class KeyboardIME : BaseKeyboardIME() {
isSuggestionVisible = isSuggestionVisible,
suggestionResult = suggestionResult,
onCandidateSelected = { word, _ -> handleCandidateSelected(word) },
+ isGestureTypingEnabled = isGestureTypingEnabled,
+ isSwipeShortcutsEnabled = isSwipeShortcutsEnabled,
onSwitchSuggestionMenu = {
isSuggestionVisibleFlow.value = false
},
@@ -290,6 +403,8 @@ class KeyboardIME : BaseKeyboardIME() {
onKeyboardActionListener = this@KeyboardIME,
onMainKeyboardInit = { view ->
mainKeyboardInstance = view
+ view.isGestureTypingEnabled = keyboardUtil.isGestureTypingEnabled()
+ view.isSwipeShortcutsEnabled = keyboardUtil.isGestureSwipeShortcutsEnabled()
currentKeyboard?.let { view.setKeyboard(it) }
},
autoTextList = autoTextList,
@@ -362,6 +477,87 @@ class KeyboardIME : BaseKeyboardIME() {
},
onDeleteEmoji = {
onKey(ItemMainKeyboard.KEYCODE_DELETE)
+ },
+ isIncognitoMode = isIncognitoMode,
+ isNumberRowEnabled = isNumberRowEnabled,
+ onToggleNumberRow = {
+ val newState = !isNumberRowEnabledFlow.value
+ isNumberRowEnabledFlow.value = newState
+ keyboardUtil.setNumberRowEnabled(newState)
+ },
+ oneHandedMode = currentOneHandedMode,
+ onChangeOneHandedMode = { newMode ->
+ oneHandedModeFlow.value = newMode
+ keyboardUtil.setOneHandedMode(newMode)
+ },
+ isDynamicThemeEnabled = isDynamicTheming,
+ onNumberRowClick = { code ->
+ onKey(code)
+ },
+ aiAssistantInitialText = aiInitialText,
+ onAiApplyText = { transformed ->
+ val ic = getActiveInputConnection()
+ if (ic != null) {
+ val selectedText = ic.getSelectedText(0)?.toString().orEmpty()
+ if (selectedText.isNotEmpty()) {
+ ic.commitText(transformed, 1)
+ } else {
+ val before = aiAssistantInitialTextFlow.value
+ if (before.isNotEmpty()) {
+ ic.deleteSurroundingText(before.length, 0)
+ }
+ ic.commitText(transformed, 1)
+ }
+ }
+ showMainKeyboard()
+ },
+ onAiCopyText = { transformed ->
+ val clip = ClipData.newPlainText("AI Transformed Text", transformed)
+ clipboardManager?.setPrimaryClip(clip)
+ },
+ isVoiceTypingActive = isVoiceActive,
+ isVoiceListening = isVoiceListening,
+ voiceAmplitude = voiceAmplitude,
+ voiceStatusText = voiceStatusText,
+ voiceErrorMessage = voiceErrorMessage,
+ onStopVoiceTyping = {
+ stopVoiceTyping()
+ },
+ onCloseVoiceTyping = {
+ stopVoiceTyping()
+ },
+ textExpansionMatch = textExpansionMatch,
+ onExpansionSelected = { match ->
+ handleTextExpansionSelected(match)
+ },
+ isSplitModeEnabled = isSplitMode,
+ onToggleSplitMode = {
+ val next = !isSplitModeEnabledFlow.value
+ isSplitModeEnabledFlow.value = next
+ keyboardUtil.setSplitModeEnabled(next)
+ },
+ activeLanguage = activeLanguage,
+ onToggleLanguage = {
+ val nextLang = if (activeLanguageFlow.value == "ID") "EN" else "ID"
+ activeLanguageFlow.value = nextLang
+ keyboardUtil.setActiveLanguage(nextLang)
+ },
+ mathCalculationResult = mathResult,
+ onMathResultSelected = { match ->
+ handleMathResultSelected(match)
+ },
+ isFloatingMode = isFloating,
+ onDockFloatingKeyboard = {
+ keyboardUtil.setFloatingModeEnabled(false)
+ isFloatingModeFlow.value = false
+ },
+ bottomChinOffsetDp = com.frogobox.appkeyboard.util.KeyboardHeightHelper.ChinOffset.fromString(chinOffset).offsetDp,
+ hasImeError = hasError,
+ imeErrorMessage = errorMsg,
+ onResetImeError = {
+ hasImeErrorFlow.value = false
+ imeErrorMessageFlow.value = null
+ activePanelStateFlow.value = KeyboardPanelState.MAIN
}
)
}
@@ -380,7 +576,13 @@ class KeyboardIME : BaseKeyboardIME() {
override fun initialSetupKeyboard() {
applySoundAndHapticSettings()
currentKeyboardFlow.value = keyboard
+ val gestureTyping = keyboardUtil.isGestureTypingEnabled()
+ val swipeShortcuts = keyboardUtil.isGestureSwipeShortcutsEnabled()
+ isGestureTypingEnabledFlow.value = gestureTyping
+ isSwipeShortcutsEnabledFlow.value = swipeShortcuts
mainKeyboardInstance?.let { view ->
+ view.isGestureTypingEnabled = gestureTyping
+ view.isSwipeShortcutsEnabled = swipeShortcuts
keyboard?.let { view.setKeyboard(it) }
}
}
@@ -470,6 +672,7 @@ class KeyboardIME : BaseKeyboardIME() {
override fun initView() {
suggestionEngine.loadDictionaryFromAsset(this)
+ com.frogobox.libkeyboard.util.GestureTypingHelper.addVocabulary(suggestionEngine.getAllWords())
setupTheme()
setupFeatureKeyboard()
loadAutoText()
@@ -498,6 +701,29 @@ class KeyboardIME : BaseKeyboardIME() {
val ic = getActiveInputConnection() ?: return
+ // 0. Single-Tap Backspace Undo for Gesture Glide Typing (Gboard Parity)
+ if (code == ItemMainKeyboard.KEYCODE_DELETE) {
+ val gestureWord = lastGestureWord
+ if (gestureWord != null) {
+ val expectedWithSpace = "$gestureWord "
+ val textBefore = ic.getTextBeforeCursor(expectedWithSpace.length, 0)?.toString() ?: ""
+ if (textBefore == expectedWithSpace) {
+ ic.deleteSurroundingText(expectedWithSpace.length, 0)
+ lastGestureWord = null
+ suggestionResultFlow.value = SuggestionResult.EMPTY
+ isSuggestionVisibleFlow.value = false
+ return
+ } else if (textBefore.endsWith(gestureWord)) {
+ ic.deleteSurroundingText(gestureWord.length, 0)
+ lastGestureWord = null
+ suggestionResultFlow.value = SuggestionResult.EMPTY
+ isSuggestionVisibleFlow.value = false
+ return
+ }
+ lastGestureWord = null
+ }
+ }
+
// 1. Single-Tap Backspace Undo for Auto-Correction (Gboard Parity)
if (code == ItemMainKeyboard.KEYCODE_DELETE) {
val record = lastAutoCorrection
@@ -508,7 +734,7 @@ class KeyboardIME : BaseKeyboardIME() {
ic.deleteSurroundingText(expectedSuffix.length, 0)
ic.commitText(record.original, 1)
lastAutoCorrection = null
- if (keyboardUtil.isSuggestionEnabled()) {
+ if (keyboardUtil.isSuggestionEnabled() && !isIncognitoModeFlow.value) {
suggestionJob?.cancel()
suggestionJob = serviceScope.launch(Dispatchers.Default) {
val suggestions = suggestionEngine.getSuggestions(record.original)
@@ -524,13 +750,14 @@ class KeyboardIME : BaseKeyboardIME() {
lastAutoCorrection = null
}
- // 2. Spacebar Auto-Correction (Gboard Parity)
+ // 2. Spacebar Auto-Correction & Smart Punctuation (Double-Space Period)
if (code == ItemMainKeyboard.KEYCODE_SPACE) {
val editorInfo = currentInputEditorInfo
val inputType = editorInfo?.inputType ?: 0
val inputClass = inputType and android.text.InputType.TYPE_MASK_CLASS
val variation = inputType and android.text.InputType.TYPE_MASK_VARIATION
- val isSensitiveOrNumeric = (inputClass == android.text.InputType.TYPE_CLASS_NUMBER) ||
+ val isSensitiveOrNumeric = isIncognitoModeFlow.value ||
+ (inputClass == android.text.InputType.TYPE_CLASS_NUMBER) ||
(inputClass == android.text.InputType.TYPE_CLASS_PHONE) ||
(inputClass == android.text.InputType.TYPE_CLASS_DATETIME) ||
(inputClass == android.text.InputType.TYPE_CLASS_TEXT && (
@@ -541,7 +768,37 @@ class KeyboardIME : BaseKeyboardIME() {
variation == android.text.InputType.TYPE_TEXT_VARIATION_URI
))
+ // Smart Punctuation: Double-Space Period
+ if (keyboardUtil.isSmartPunctuationEnabled() && !isSensitiveOrNumeric) {
+ val textBefore = ic.getTextBeforeCursor(3, 0)
+ val doubleSpaceResult = com.frogobox.appkeyboard.util.SmartPunctuationHelper.checkDoubleSpacePeriod(
+ lastSpaceTime = lastSpacePressTS,
+ currentTime = System.currentTimeMillis(),
+ textBeforeCursor = textBefore
+ )
+ if (doubleSpaceResult.isHandled) {
+ ic.deleteSurroundingText(doubleSpaceResult.textToDeleteLength, 0)
+ ic.commitText(doubleSpaceResult.replacementText, 1)
+ lastSpacePressTS = 0L
+ if (doubleSpaceResult.shouldAutoShift && kb != null && kb.mShiftState == ItemMainKeyboard.SHIFT_OFF) {
+ kb.mShiftState = ItemMainKeyboard.SHIFT_ON_ONE_CHAR
+ invalidateAllKeys()
+ }
+ updateShiftKeyState()
+ return
+ }
+ }
+
val wordBefore = getWordBeforeCursor(ic)
+ if (keyboardUtil.isUserDictionaryLearningEnabled() && !isSensitiveOrNumeric && wordBefore.isNotEmpty()) {
+ com.frogobox.appkeyboard.util.UserDictionaryHelper.learnWord(
+ this@KeyboardIME,
+ wordBefore,
+ isIncognito = isIncognitoModeFlow.value,
+ isPassword = isSensitiveOrNumeric
+ )
+ }
+
if (keyboardUtil.isSuggestionEnabled() && !isSensitiveOrNumeric && wordBefore.length >= 2) {
val replacement = suggestionEngine.getAutoCorrectReplacement(wordBefore)
if (replacement != null && !replacement.equals(wordBefore, ignoreCase = true)) {
@@ -549,6 +806,7 @@ class KeyboardIME : BaseKeyboardIME() {
ic.commitText("$replacement ", 1)
lastAutoCorrection = AutoCorrectionRecord(wordBefore, replacement, System.currentTimeMillis())
suggestionResultFlow.value = SuggestionResult.EMPTY
+ textExpansionMatchFlow.value = null
isSuggestionVisibleFlow.value = false
updateShiftKeyState()
return
@@ -557,22 +815,63 @@ class KeyboardIME : BaseKeyboardIME() {
lastAutoCorrection = null
} else if (code != ItemMainKeyboard.KEYCODE_DELETE) {
lastAutoCorrection = null
+ lastGestureWord = null
+ }
+
+ val currentBeforeCursor = ic.getTextBeforeCursor(80, 0)?.toString().orEmpty()
+ if (currentBeforeCursor.isNotEmpty() && code != ItemMainKeyboard.KEYCODE_SHIFT) {
+ undoStack.add(currentBeforeCursor)
+ if (undoStack.size > 25) undoStack.removeAt(0)
+ redoStack.clear()
}
onKeyExt(code, ic)
- if (keyboardUtil.isSuggestionEnabled()) {
+ // Auto-Capitalization after sentence boundary
+ if (keyboardUtil.isSmartPunctuationEnabled() && kb != null && kb.mShiftState == ItemMainKeyboard.SHIFT_OFF) {
+ val textBefore = ic.getTextBeforeCursor(20, 0)
+ if (com.frogobox.appkeyboard.util.SmartPunctuationHelper.shouldAutoCapitalize(textBefore)) {
+ kb.mShiftState = ItemMainKeyboard.SHIFT_ON_ONE_CHAR
+ invalidateAllKeys()
+ }
+ }
+
+ if (keyboardUtil.isSuggestionEnabled() && !isIncognitoModeFlow.value) {
+ // Check Smart Math Calculator
+ if (keyboardUtil.isSmartCalculatorEnabled()) {
+ val textBeforeCursor = ic.getTextBeforeCursor(40, 0)?.toString()
+ val mathResult = com.frogobox.appkeyboard.util.SmartCalculatorHelper.evaluateMath(textBeforeCursor)
+ mathCalculationResultFlow.value = mathResult
+ } else {
+ mathCalculationResultFlow.value = null
+ }
+
val word = getWordBeforeCursor(ic)
if (word.isNotEmpty()) {
+ val expansion = com.frogobox.appkeyboard.util.InlineTextExpanderHelper.findExpansion(word, autoTextListFlow.value)
+ textExpansionMatchFlow.value = expansion
+
suggestionJob?.cancel()
suggestionJob = serviceScope.launch(Dispatchers.Default) {
- val suggestions = suggestionEngine.getSuggestions(word)
+ val baseSuggestions = suggestionEngine.getSuggestions(word)
+ val userSuggestions = if (keyboardUtil.isUserDictionaryLearningEnabled()) {
+ com.frogobox.appkeyboard.util.UserDictionaryHelper.getSuggestions(this@KeyboardIME, word, limit = 2)
+ } else emptyList()
+
+ val mergedPredicted = if (userSuggestions.isNotEmpty()) {
+ userSuggestions.first()
+ } else {
+ baseSuggestions.predictedWord
+ }
+
+ val finalResult = baseSuggestions.copy(predictedWord = mergedPredicted)
withContext(Dispatchers.Main) {
- suggestionResultFlow.value = suggestions
+ suggestionResultFlow.value = finalResult
isSuggestionVisibleFlow.value = true
}
}
} else {
+ textExpansionMatchFlow.value = null
suggestionJob?.cancel()
suggestionResultFlow.value = SuggestionResult.EMPTY
if (code == ItemMainKeyboard.KEYCODE_SPACE ||
@@ -581,6 +880,9 @@ class KeyboardIME : BaseKeyboardIME() {
isSuggestionVisibleFlow.value = false
}
}
+ } else {
+ textExpansionMatchFlow.value = null
+ mathCalculationResultFlow.value = null
}
}
@@ -616,6 +918,78 @@ class KeyboardIME : BaseKeyboardIME() {
}
}
+ override fun onGestureMatch(word: String, suggestions: List) {
+ val ic = getActiveInputConnection() ?: return
+ val kb = keyboard
+ val formattedWord = when (kb?.mShiftState) {
+ ItemMainKeyboard.SHIFT_ON_PERMANENT -> word.uppercase()
+ ItemMainKeyboard.SHIFT_ON_ONE_CHAR -> {
+ keyboard?.setShifted(ItemMainKeyboard.SHIFT_OFF)
+ invalidateAllKeys()
+ word.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
+ }
+ else -> word
+ }
+
+ ic.commitText("$formattedWord ", 1)
+ lastGestureWord = formattedWord
+
+ // Populate alternative candidate suggestions into the suggestion strip
+ if (keyboardUtil.isSuggestionEnabled() && suggestions.isNotEmpty()) {
+ val primary = suggestions.firstOrNull() ?: word
+ val secondary = suggestions.getOrNull(1) ?: ""
+ val tertiary = suggestions.getOrNull(2) ?: ""
+ suggestionResultFlow.value = SuggestionResult(
+ userWord = primary,
+ predictedWord = secondary,
+ autoCorrectWord = tertiary
+ )
+ isSuggestionVisibleFlow.value = true
+ }
+
+ // Learn the committed word if user dictionary learning is enabled
+ if (keyboardUtil.isUserDictionaryLearningEnabled() && !isIncognitoModeFlow.value) {
+ com.frogobox.appkeyboard.util.UserDictionaryHelper.learnWord(
+ this@KeyboardIME,
+ word,
+ isIncognito = false,
+ isPassword = false
+ )
+ com.frogobox.libkeyboard.util.GestureTypingHelper.registerWord(word)
+ }
+ }
+
+ override fun onSwipeDown() {
+ requestHideSelf(0)
+ }
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
+ if (com.frogobox.appkeyboard.util.HardwareKeyboardHelper.handleHardwareKeyEvent(event, getActiveInputConnection())) {
+ return true
+ }
+ return super.onKeyDown(keyCode, event)
+ }
+
+ private fun handleTextExpansionSelected(match: com.frogobox.appkeyboard.util.InlineTextExpanderHelper.TextExpansionMatch) {
+ val ic = getActiveInputConnection() ?: return
+ ic.deleteSurroundingText(match.trigger.length, 0)
+ ic.commitText("${match.expandedText} ", 1)
+ textExpansionMatchFlow.value = null
+ suggestionResultFlow.value = SuggestionResult.EMPTY
+ isSuggestionVisibleFlow.value = false
+ updateShiftKeyState()
+ }
+
+ private fun handleMathResultSelected(result: com.frogobox.appkeyboard.util.SmartCalculatorHelper.MathResult) {
+ val ic = getActiveInputConnection() ?: return
+ ic.deleteSurroundingText(result.expression.length, 0)
+ ic.commitText("${result.evaluatedValue} ", 1)
+ mathCalculationResultFlow.value = null
+ suggestionResultFlow.value = SuggestionResult.EMPTY
+ isSuggestionVisibleFlow.value = false
+ updateShiftKeyState()
+ }
+
override fun getKeyboardLayoutXML(): Int {
return pref.getPrefInt(
KeyboardUtil.KEYBOARD_TYPE, com.frogobox.libkeyboard.R.xml.keys_letters_qwerty
@@ -645,9 +1019,26 @@ class KeyboardIME : BaseKeyboardIME() {
private fun handleCandidateSelected(selectedWord: String) {
val ic = getActiveInputConnection()
if (ic != null) {
- val currentWord = getWordBeforeCursor(ic)
- if (currentWord.isNotEmpty()) {
- ic.deleteSurroundingText(currentWord.length, 0)
+ val gestureWord = lastGestureWord
+ if (gestureWord != null) {
+ val expectedWithSpace = "$gestureWord "
+ val textBefore = ic.getTextBeforeCursor(expectedWithSpace.length, 0)?.toString() ?: ""
+ if (textBefore == expectedWithSpace) {
+ ic.deleteSurroundingText(expectedWithSpace.length, 0)
+ } else if (textBefore.endsWith(gestureWord)) {
+ ic.deleteSurroundingText(gestureWord.length, 0)
+ } else {
+ val currentWord = getWordBeforeCursor(ic)
+ if (currentWord.isNotEmpty()) {
+ ic.deleteSurroundingText(currentWord.length, 0)
+ }
+ }
+ lastGestureWord = null
+ } else {
+ val currentWord = getWordBeforeCursor(ic)
+ if (currentWord.isNotEmpty()) {
+ ic.deleteSurroundingText(currentWord.length, 0)
+ }
}
ic.commitText("$selectedWord ", 1)
suggestionResultFlow.value = SuggestionResult.EMPTY
@@ -705,12 +1096,65 @@ class KeyboardIME : BaseKeyboardIME() {
activePanelStateFlow.value = KeyboardPanelState.TEXT_EDIT
}
+ KeyboardFeatureType.AI_ASSISTANT -> {
+ val ic = getActiveInputConnection()
+ val selectedText = ic?.getSelectedText(0)?.toString().orEmpty()
+ val beforeCursor = ic?.getTextBeforeCursor(200, 0)?.toString().orEmpty()
+ val initialText = when {
+ selectedText.isNotBlank() -> selectedText
+ beforeCursor.isNotBlank() -> beforeCursor.trim()
+ else -> recentClipFlow.value.orEmpty()
+ }
+ aiAssistantInitialTextFlow.value = initialText
+ activePanelStateFlow.value = KeyboardPanelState.AI_ASSISTANT
+ }
+
+ KeyboardFeatureType.VOICE_TYPING -> {
+ if (isVoiceTypingActiveFlow.value && isVoiceListeningFlow.value) {
+ stopVoiceTyping()
+ } else {
+ startVoiceTyping()
+ }
+ }
+
KeyboardFeatureType.SUGGESTION -> {
isSuggestionVisibleFlow.value = !isSuggestionVisibleFlow.value
}
+ KeyboardFeatureType.NUMBER_ROW -> {
+ val newState = !isNumberRowEnabledFlow.value
+ isNumberRowEnabledFlow.value = newState
+ keyboardUtil.setNumberRowEnabled(newState)
+ }
+
+ KeyboardFeatureType.ONE_HANDED -> {
+ val nextMode = when (oneHandedModeFlow.value) {
+ "OFF" -> "RIGHT"
+ "RIGHT" -> "LEFT"
+ else -> "OFF"
+ }
+ oneHandedModeFlow.value = nextMode
+ keyboardUtil.setOneHandedMode(nextMode)
+ }
+
KeyboardFeatureType.CHANGE_KEYBOARD -> {
- (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker()
+ (getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager)?.showInputMethodPicker()
+ }
+
+ KeyboardFeatureType.GESTURE_SWIPE -> {
+ val current = isGestureTypingEnabledFlow.value
+ val next = !current
+ keyboardUtil.setGestureTypingEnabled(next)
+ keyboardUtil.setGestureSwipeShortcutsEnabled(next)
+ isGestureTypingEnabledFlow.value = next
+ isSwipeShortcutsEnabledFlow.value = next
+ mainKeyboardInstance?.isGestureTypingEnabled = next
+ mainKeyboardInstance?.isSwipeShortcutsEnabled = next
+ android.widget.Toast.makeText(
+ this,
+ if (next) "Gesture Swipe: Aktif" else "Gesture Swipe: Nonaktif",
+ android.widget.Toast.LENGTH_SHORT
+ ).show()
}
KeyboardFeatureType.SETTING -> {
@@ -721,6 +1165,59 @@ class KeyboardIME : BaseKeyboardIME() {
}
}
+ private fun initVoiceTypingHelper() {
+ if (voiceTypingHelper == null) {
+ voiceTypingHelper = VoiceTypingHelper(this).apply {
+ onTextReceived = { text, isFinal ->
+ voiceStatusTextFlow.value = text
+ if (isFinal) {
+ getActiveInputConnection()?.commitText("$text ", 1)
+ isVoiceListeningFlow.value = false
+ isVoiceTypingActiveFlow.value = false
+ }
+ }
+ onRmsChanged = { amplitude ->
+ voiceAmplitudeFlow.value = amplitude
+ }
+ onStateChanged = { state ->
+ isVoiceListeningFlow.value = (state == VoiceTypingHelper.State.LISTENING)
+ if (state == VoiceTypingHelper.State.IDLE) {
+ isVoiceTypingActiveFlow.value = false
+ }
+ }
+ onErrorOccurred = { errorMsg ->
+ voiceErrorMessageFlow.value = errorMsg
+ isVoiceListeningFlow.value = false
+ }
+ }
+ }
+ }
+
+ private fun startVoiceTyping() {
+ if (isIncognitoModeFlow.value) {
+ voiceErrorMessageFlow.value = "Pengetikan suara dinonaktifkan dalam mode privat."
+ isVoiceTypingActiveFlow.value = true
+ return
+ }
+ initVoiceTypingHelper()
+ voiceErrorMessageFlow.value = null
+ voiceStatusTextFlow.value = "Mendengarkan..."
+ isVoiceTypingActiveFlow.value = true
+ val started = voiceTypingHelper?.startListening() ?: false
+ if (!started && voiceErrorMessageFlow.value == null) {
+ voiceErrorMessageFlow.value = "Gagal memulai pengetikan suara."
+ }
+ }
+
+ private fun stopVoiceTyping() {
+ voiceTypingHelper?.stopListening()
+ isVoiceListeningFlow.value = false
+ isVoiceTypingActiveFlow.value = false
+ voiceAmplitudeFlow.value = 0f
+ voiceErrorMessageFlow.value = null
+ voiceStatusTextFlow.value = ""
+ }
+
private fun loadAutoText() {
autoTextJob?.cancel()
autoTextJob = serviceScope.launch {
@@ -846,7 +1343,7 @@ class KeyboardIME : BaseKeyboardIME() {
}
private fun checkAndCaptureClipboard() {
- if (!keyboardUtil.isClipboardEnabled()) {
+ if (!keyboardUtil.isClipboardEnabled() || isIncognitoModeFlow.value) {
recentClipFlow.value = null
return
}
@@ -874,7 +1371,9 @@ class KeyboardIME : BaseKeyboardIME() {
clipboardJob?.cancel()
clipboardJob = serviceScope.launch {
clipboardRepository.getClipboardItems().collect { items ->
- clipboardItemsFlow.value = items
+ val now = System.currentTimeMillis()
+ val cleanItems = com.frogobox.appkeyboard.util.ClipboardSecurityGuard.filterExpiredClips(items, now)
+ clipboardItemsFlow.value = cleanItems
}
}
}
@@ -977,7 +1476,9 @@ class KeyboardIME : BaseKeyboardIME() {
clipboardManager?.setPrimaryClip(ClipData.newPlainText("Cut", selectedText))
} catch (_: Exception) {}
ic.commitText("", 1)
- serviceScope.launch { clipboardRepository.addClip(selectedText) }
+ if (!isIncognitoModeFlow.value) {
+ serviceScope.launch { clipboardRepository.addClip(selectedText) }
+ }
isSelectionModeFlow.value = false
selectionAnchor = -1
}
@@ -989,7 +1490,9 @@ class KeyboardIME : BaseKeyboardIME() {
try {
clipboardManager?.setPrimaryClip(ClipData.newPlainText("Copy", selectedText))
} catch (_: Exception) {}
- serviceScope.launch { clipboardRepository.addClip(selectedText) }
+ if (!isIncognitoModeFlow.value) {
+ serviceScope.launch { clipboardRepository.addClip(selectedText) }
+ }
}
}
@@ -1021,6 +1524,32 @@ class KeyboardIME : BaseKeyboardIME() {
ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER))
ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER))
}
+
+ TextEditAction.UNDO -> {
+ try {
+ ic.performContextMenuAction(android.R.id.undo)
+ } catch (_: Exception) {}
+ if (undoStack.isNotEmpty()) {
+ val prev = undoStack.removeAt(undoStack.size - 1)
+ val curr = ic.getTextBeforeCursor(80, 0)?.toString() ?: ""
+ redoStack.add(curr)
+ ic.deleteSurroundingText(curr.length, 0)
+ ic.commitText(prev, 1)
+ }
+ }
+
+ TextEditAction.REDO -> {
+ try {
+ ic.performContextMenuAction(android.R.id.redo)
+ } catch (_: Exception) {}
+ if (redoStack.isNotEmpty()) {
+ val next = redoStack.removeAt(redoStack.size - 1)
+ val curr = ic.getTextBeforeCursor(80, 0)?.toString() ?: ""
+ undoStack.add(curr)
+ ic.deleteSurroundingText(curr.length, 0)
+ ic.commitText(next, 1)
+ }
+ }
}
}
@@ -1079,11 +1608,26 @@ class KeyboardIME : BaseKeyboardIME() {
)
val soundVolumeInt = pref.getPrefInt(MechanicalSoundManager.PREF_KEYBOARD_SOUND_VOLUME, 80)
val vibrateEnabled = pref.getPrefBoolean(MechanicalSoundManager.PREF_KEYBOARD_VIBRATE_ENABLED, true)
+ val hapticIntensity = keyboardUtil.getHapticIntensity()
ItemMainKeyboard.SOUND_ON_KEYPRESS = soundEnabled
ItemMainKeyboard.MECHANICAL_SOUND_TYPE = soundType
ItemMainKeyboard.SOUND_VOLUME = (soundVolumeInt / 100f).coerceIn(0.05f, 1.0f)
- ItemMainKeyboard.VIBRATE_ON_KEYPRESS = vibrateEnabled
+ ItemMainKeyboard.VIBRATE_ON_KEYPRESS = vibrateEnabled && hapticIntensity != "OFF"
+ }
+
+ /**
+ * Commits rich media content (GIF, Sticker, Animated WebP) using Android InputConnectionCompat.
+ */
+ fun commitRichMedia(uri: android.net.Uri, mimeType: String, description: String = "Rich Content"): Boolean {
+ return com.frogobox.appkeyboard.util.KeyboardRichMediaHelper.commitRichContent(
+ context = this,
+ inputConnection = currentInputConnection,
+ editorInfo = currentInputEditorInfo,
+ contentUri = uri,
+ mimeType = mimeType,
+ label = description
+ )
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardUtil.kt b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardUtil.kt
index a98c660..69a1dec 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardUtil.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardUtil.kt
@@ -25,9 +25,31 @@ class KeyboardUtil @Inject constructor(
const val KEYBOARD_SUGGESTION_ENABLED = "KEYBOARD_SUGGESTION_ENABLED"
const val KEY_ALWAYS_SHOW_FEATURE = "KEY_ALWAYS_SHOW_FEATURE"
const val KEY_FEATURE_ORDER = "KEY_FEATURE_ORDER"
+ const val KEY_NUMBER_ROW_ENABLED = "KEY_NUMBER_ROW_ENABLED"
+ const val KEY_ONE_HANDED_MODE = "KEY_ONE_HANDED_MODE"
+ const val KEY_SYMBOL_HINTS_ENABLED = "KEY_SYMBOL_HINTS_ENABLED"
+ const val KEY_DYNAMIC_THEME_ENABLED = "KEY_DYNAMIC_THEME_ENABLED"
+ const val KEY_SPLIT_MODE_ENABLED = "KEY_SPLIT_MODE_ENABLED"
+ const val KEY_HAPTIC_INTENSITY = "KEY_HAPTIC_INTENSITY"
+ const val KEY_SMART_PUNCTUATION_ENABLED = "KEY_SMART_PUNCTUATION_ENABLED"
+ const val KEY_ACTIVE_LANGUAGE = "KEY_ACTIVE_LANGUAGE"
+ const val KEY_HEIGHT_SCALE = "KEY_HEIGHT_SCALE"
+ const val KEY_BOTTOM_CHIN_OFFSET = "KEY_BOTTOM_CHIN_OFFSET"
+ const val KEY_FLOATING_MODE_ENABLED = "KEY_FLOATING_MODE_ENABLED"
+ const val KEY_HAPTIC_DURATION_MS = "KEY_HAPTIC_DURATION_MS"
+ const val KEY_SOUND_VOLUME_PERCENT = "KEY_SOUND_VOLUME_PERCENT"
+ const val KEY_GESTURE_TYPING_ENABLED = "KEY_GESTURE_TYPING_ENABLED"
+ const val KEY_GESTURE_SWIPE_SHORTCUTS_ENABLED = "KEY_GESTURE_SWIPE_SHORTCUTS_ENABLED"
+ const val KEY_USER_DICTIONARY_LEARNING_ENABLED = "KEY_USER_DICTIONARY_LEARNING_ENABLED"
+ const val KEY_SMART_CALCULATOR_ENABLED = "KEY_SMART_CALCULATOR_ENABLED"
val DEFAULT_FEATURE_TYPES = listOf(
KeyboardFeatureType.SUGGESTION,
+ KeyboardFeatureType.NUMBER_ROW,
+ KeyboardFeatureType.ONE_HANDED,
+ KeyboardFeatureType.AI_ASSISTANT,
+ KeyboardFeatureType.VOICE_TYPING,
+ KeyboardFeatureType.GESTURE_SWIPE,
KeyboardFeatureType.AUTO_TEXT,
KeyboardFeatureType.PRODUCT_REMOTE,
KeyboardFeatureType.CLIPBOARD,
@@ -45,6 +67,142 @@ class KeyboardUtil @Inject constructor(
return pref.getPrefBoolean(key, true)
}
+ fun isNumberRowEnabled(): Boolean {
+ return pref.getPrefBoolean(KEY_NUMBER_ROW_ENABLED, false)
+ }
+
+ fun setNumberRowEnabled(enabled: Boolean) {
+ pref.savePrefBoolean(KEY_NUMBER_ROW_ENABLED, enabled)
+ }
+
+ fun getOneHandedMode(): String {
+ return pref.getPrefString(KEY_ONE_HANDED_MODE, "OFF")
+ }
+
+ fun setOneHandedMode(mode: String) {
+ pref.savePrefString(KEY_ONE_HANDED_MODE, mode)
+ }
+
+ fun isSymbolHintsEnabled(): Boolean {
+ return pref.getPrefBoolean(KEY_SYMBOL_HINTS_ENABLED, true)
+ }
+
+ fun setSymbolHintsEnabled(enabled: Boolean) {
+ pref.savePrefBoolean(KEY_SYMBOL_HINTS_ENABLED, enabled)
+ }
+
+ fun isDynamicThemeEnabled(): Boolean {
+ return pref.getPrefBoolean(KEY_DYNAMIC_THEME_ENABLED, false)
+ }
+
+ fun setDynamicThemeEnabled(enabled: Boolean) {
+ pref.savePrefBoolean(KEY_DYNAMIC_THEME_ENABLED, enabled)
+ }
+
+ fun isSplitModeEnabled(): Boolean {
+ return pref.getPrefBoolean(KEY_SPLIT_MODE_ENABLED, false)
+ }
+
+ fun setSplitModeEnabled(enabled: Boolean) {
+ pref.savePrefBoolean(KEY_SPLIT_MODE_ENABLED, enabled)
+ }
+
+ fun getHapticIntensity(): String {
+ return pref.getPrefString(KEY_HAPTIC_INTENSITY, "MEDIUM")
+ }
+
+ fun setHapticIntensity(intensity: String) {
+ pref.savePrefString(KEY_HAPTIC_INTENSITY, intensity)
+ }
+
+ fun isSmartPunctuationEnabled(): Boolean {
+ return pref.getPrefBoolean(KEY_SMART_PUNCTUATION_ENABLED, true)
+ }
+
+ fun setSmartPunctuationEnabled(enabled: Boolean) {
+ pref.savePrefBoolean(KEY_SMART_PUNCTUATION_ENABLED, enabled)
+ }
+
+ fun getActiveLanguage(): String {
+ return pref.getPrefString(KEY_ACTIVE_LANGUAGE, "ID")
+ }
+
+ fun setActiveLanguage(lang: String) {
+ pref.savePrefString(KEY_ACTIVE_LANGUAGE, lang)
+ }
+
+ fun getHeightScale(): String {
+ return pref.getPrefString(KEY_HEIGHT_SCALE, "NORMAL")
+ }
+
+ fun setHeightScale(scale: String) {
+ pref.savePrefString(KEY_HEIGHT_SCALE, scale)
+ }
+
+ fun getBottomChinOffset(): String {
+ return pref.getPrefString(KEY_BOTTOM_CHIN_OFFSET, "NONE")
+ }
+
+ fun setBottomChinOffset(offset: String) {
+ pref.savePrefString(KEY_BOTTOM_CHIN_OFFSET, offset)
+ }
+
+ fun isFloatingModeEnabled(): Boolean {
+ return pref.getPrefBoolean(KEY_FLOATING_MODE_ENABLED, false)
+ }
+
+ fun setFloatingModeEnabled(enabled: Boolean) {
+ pref.savePrefBoolean(KEY_FLOATING_MODE_ENABLED, enabled)
+ }
+
+ fun getHapticDurationMs(): Long {
+ return pref.getPrefString(KEY_HAPTIC_DURATION_MS, "20").toLongOrNull() ?: 20L
+ }
+
+ fun setHapticDurationMs(durationMs: Long) {
+ pref.savePrefString(KEY_HAPTIC_DURATION_MS, durationMs.toString())
+ }
+
+ fun getSoundVolumePercent(): Int {
+ return pref.getPrefString(KEY_SOUND_VOLUME_PERCENT, "100").toIntOrNull() ?: 100
+ }
+
+ fun setSoundVolumePercent(volume: Int) {
+ pref.savePrefString(KEY_SOUND_VOLUME_PERCENT, volume.toString())
+ }
+
+ fun isGestureTypingEnabled(): Boolean {
+ return pref.getPrefBoolean(KEY_GESTURE_TYPING_ENABLED, true)
+ }
+
+ fun setGestureTypingEnabled(enabled: Boolean) {
+ pref.savePrefBoolean(KEY_GESTURE_TYPING_ENABLED, enabled)
+ }
+
+ fun isGestureSwipeShortcutsEnabled(): Boolean {
+ return pref.getPrefBoolean(KEY_GESTURE_SWIPE_SHORTCUTS_ENABLED, true)
+ }
+
+ fun setGestureSwipeShortcutsEnabled(enabled: Boolean) {
+ pref.savePrefBoolean(KEY_GESTURE_SWIPE_SHORTCUTS_ENABLED, enabled)
+ }
+
+ fun isUserDictionaryLearningEnabled(): Boolean {
+ return pref.getPrefBoolean(KEY_USER_DICTIONARY_LEARNING_ENABLED, true)
+ }
+
+ fun setUserDictionaryLearningEnabled(enabled: Boolean) {
+ pref.savePrefBoolean(KEY_USER_DICTIONARY_LEARNING_ENABLED, enabled)
+ }
+
+ fun isSmartCalculatorEnabled(): Boolean {
+ return pref.getPrefBoolean(KEY_SMART_CALCULATOR_ENABLED, true)
+ }
+
+ fun setSmartCalculatorEnabled(enabled: Boolean) {
+ pref.savePrefBoolean(KEY_SMART_CALCULATOR_ENABLED, enabled)
+ }
+
fun isSuggestionEnabled(): Boolean {
return getStateToggle(KeyboardFeatureType.SUGGESTION.id)
}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/suggestion/WordSuggestionEngine.kt b/app/src/main/java/com/frogobox/appkeyboard/suggestion/WordSuggestionEngine.kt
index 85f815d..c9cc08f 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/suggestion/WordSuggestionEngine.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/suggestion/WordSuggestionEngine.kt
@@ -182,6 +182,13 @@ class WordSuggestionEngine @Inject constructor() {
}
}
+ /**
+ * Returns full list of vocabulary words for gesture typing and prediction engines.
+ */
+ fun getAllWords(): List {
+ return dictionaryList.toList()
+ }
+
/**
* Core suggestion generation returning the 3 required sections:
* - Section 1: User's verbatim word
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/ai/KeyboardAiAssistantScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/ai/KeyboardAiAssistantScreen.kt
new file mode 100644
index 0000000..9e9d944
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/ai/KeyboardAiAssistantScreen.kt
@@ -0,0 +1,377 @@
+package com.frogobox.appkeyboard.ui.keyboard.ai
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.frogobox.appkeyboard.R
+import com.frogobox.appkeyboard.util.AiTextTransformer
+
+/**
+ * AI Writing & Tone Assistant canvas.
+ * Allows instant on-device text refinement, tone switching, and smart summarization.
+ */
+@Composable
+fun KeyboardAiAssistantScreen(
+ initialText: String,
+ onApplyText: (String) -> Unit,
+ onCopyText: (String) -> Unit,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ isIncognito: Boolean = false
+) {
+ var sourceText by remember(initialText) {
+ mutableStateOf(initialText.trim())
+ }
+ var selectedTone by remember {
+ mutableStateOf(AiTextTransformer.AiToneStyle.POLISH)
+ }
+
+ val transformedText = remember(sourceText, selectedTone) {
+ if (sourceText.isBlank()) "" else AiTextTransformer.transform(sourceText, selectedTone)
+ }
+
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(270.dp),
+ color = MaterialTheme.colorScheme.surface
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 8.dp, vertical = 4.dp)
+ ) {
+ // Header Bar
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(40.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(
+ onClick = onBackClick,
+ modifier = Modifier.size(36.dp)
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Kembali ke Keyboard",
+ tint = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.width(4.dp))
+
+ Icon(
+ painter = painterResource(id = R.drawable.ic_menu_ai_assistant),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(18.dp)
+ )
+
+ Spacer(modifier = Modifier.width(6.dp))
+
+ Text(
+ text = "Asisten Penulisan AI",
+ fontSize = 13.5.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f)
+ )
+
+ if (isIncognito) {
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
+ modifier = Modifier.padding(end = 4.dp)
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Lock,
+ contentDescription = "Mode Privat",
+ tint = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.size(11.dp)
+ )
+ Spacer(modifier = Modifier.width(3.dp))
+ Text(
+ text = "Privat (Lokal)",
+ fontSize = 9.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.tertiary
+ )
+ }
+ }
+ }
+
+ IconButton(
+ onClick = onBackClick,
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Tutup",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+
+ HorizontalDivider(
+ thickness = 0.8.dp,
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
+ )
+
+ // Tone Switcher Chips Row
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ AiTextTransformer.AiToneStyle.entries.forEach { tone ->
+ val isSelected = tone == selectedTone
+ Surface(
+ modifier = Modifier
+ .clip(RoundedCornerShape(14.dp))
+ .clickable { selectedTone = tone },
+ shape = RoundedCornerShape(14.dp),
+ color = if (isSelected) {
+ MaterialTheme.colorScheme.primaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)
+ },
+ border = if (isSelected) {
+ BorderStroke(1.dp, MaterialTheme.colorScheme.primary)
+ } else {
+ BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
+ }
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = tone.iconEmoji,
+ fontSize = 11.sp
+ )
+ Text(
+ text = tone.chipLabel,
+ fontSize = 11.sp,
+ fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
+ color = if (isSelected) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ )
+ }
+ }
+ }
+ }
+
+ // Text Preview & Action Area
+ if (sourceText.isBlank()) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .background(
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
+ RoundedCornerShape(8.dp)
+ )
+ .padding(12.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = "Teks Input Kosong",
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = "Ketik draf kalimat di keyboard terlebih dahulu, lalu buka Asisten AI untuk memoles gaya tulisan Anda.",
+ fontSize = 10.5.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center
+ )
+ }
+ }
+ } else {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ shape = RoundedCornerShape(8.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
+ ),
+ border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(8.dp)
+ ) {
+ // Original snippet indicator
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Asli: \"${sourceText.take(45)}${if (sourceText.length > 45) "..." else ""}\"",
+ fontSize = 10.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+ Text(
+ text = selectedTone.title,
+ fontSize = 9.5.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+
+ HorizontalDivider(
+ thickness = 0.5.dp,
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f),
+ modifier = Modifier.padding(vertical = 4.dp)
+ )
+
+ // Transformed Text Output
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ text = transformedText,
+ fontSize = 12.5.sp,
+ lineHeight = 17.sp,
+ fontWeight = FontWeight.Normal,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(6.dp))
+
+ // Action Buttons
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(38.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedButton(
+ onClick = {
+ if (transformedText.isNotBlank()) {
+ onCopyText(transformedText)
+ }
+ },
+ enabled = transformedText.isNotBlank(),
+ shape = RoundedCornerShape(8.dp),
+ modifier = Modifier.weight(1f),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 4.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Share,
+ contentDescription = "Salin Teks",
+ modifier = Modifier.size(14.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = "Salin",
+ fontSize = 11.5.sp,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+
+ Button(
+ onClick = {
+ if (transformedText.isNotBlank()) {
+ onApplyText(transformedText)
+ }
+ },
+ enabled = transformedText.isNotBlank(),
+ shape = RoundedCornerShape(8.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ ),
+ modifier = Modifier.weight(1.3f),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 4.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = "Terapkan Teks",
+ modifier = Modifier.size(14.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = "Terapkan",
+ fontSize = 11.5.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardScreen.kt
index e95aeb2..6881a93 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardScreen.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardScreen.kt
@@ -44,7 +44,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.frogobox.appkeyboard.model.AutoTextEntity
import com.frogobox.appkeyboard.model.KeyboardFeatureType
-import com.frogobox.appkeyboard.model.TemplateText
import com.frogobox.appkeyboard.ui.keyboard.common.KeyboardFeatureToolbar
import com.frogobox.appkeyboard.ui.keyboard.templatetext.TemplateTextUtils
import com.frogobox.appkeyboard.ui.templatetext.TemplateTextActivity
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/clipboard/ClipboardKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/clipboard/ClipboardKeyboardScreen.kt
index eb8a759..e4b4ed4 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/clipboard/ClipboardKeyboardScreen.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/clipboard/ClipboardKeyboardScreen.kt
@@ -1,7 +1,9 @@
package com.frogobox.appkeyboard.ui.keyboard.clipboard
import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -16,12 +18,15 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
@@ -31,12 +36,17 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -47,6 +57,53 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
+enum class ClipboardCategoryFilter(val label: String) {
+ ALL("Semua"),
+ PINNED("Disematkan"),
+ TEXT("Teks"),
+ NUMBER_CODE("Angka/Kode")
+}
+
+object ClipboardFilterHelper {
+ fun isNumberOrCode(text: String): Boolean {
+ val trimmed = text.trim()
+ if (trimmed.isEmpty()) return false
+ val digitCount = trimmed.count { it.isDigit() }
+ if (digitCount == 0) return false
+
+ // 1. Mostly digits (phone, account, pure number)
+ val isMostlyDigits = (digitCount.toDouble() / trimmed.length >= 0.4)
+
+ // 2. Contains standalone numeric code of 4-12 digits (e.g. OTP, verification code)
+ val hasStandaloneCode = Regex("\\b\\d{4,12}\\b").containsMatchIn(trimmed)
+
+ // 3. Phone number format (+62..., 08...)
+ val isPhone = Regex("^[+]?[0-9\\s\\-()]{7,20}$").matches(trimmed)
+
+ // 4. Contains financial or code keywords with digits
+ val hasCodeKeyword = Regex("(?i)\\b(kode|otp|pin|rekening|bca|bri|mandiri|bni|va)\\b").containsMatchIn(trimmed) && digitCount >= 4
+
+ return isMostlyDigits || hasStandaloneCode || isPhone || hasCodeKeyword
+ }
+
+ fun filterItems(
+ items: List,
+ query: String,
+ filter: ClipboardCategoryFilter
+ ): List {
+ return items.filter { item ->
+ val matchesQuery = query.isBlank() || item.text.contains(query, ignoreCase = true)
+ val matchesCategory = when (filter) {
+ ClipboardCategoryFilter.ALL -> true
+ ClipboardCategoryFilter.PINNED -> item.isPinned
+ ClipboardCategoryFilter.TEXT -> !isNumberOrCode(item.text)
+ ClipboardCategoryFilter.NUMBER_CODE -> isNumberOrCode(item.text)
+ }
+ matchesQuery && matchesCategory
+ }
+ }
+}
+
@Composable
fun ClipboardKeyboardScreen(
items: List,
@@ -59,6 +116,13 @@ fun ClipboardKeyboardScreen(
isAlwaysShow: Boolean = false,
onToggleAlwaysShow: (() -> Unit)? = null
) {
+ var searchQuery by remember { mutableStateOf("") }
+ var selectedFilter by remember { mutableStateOf(ClipboardCategoryFilter.ALL) }
+
+ val filteredItems = remember(items, searchQuery, selectedFilter) {
+ ClipboardFilterHelper.filterItems(items, searchQuery, selectedFilter)
+ }
+
Surface(
modifier = modifier
.fillMaxWidth()
@@ -70,7 +134,7 @@ fun ClipboardKeyboardScreen(
Row(
modifier = Modifier
.fillMaxWidth()
- .height(44.dp)
+ .height(40.dp)
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -90,7 +154,7 @@ fun ClipboardKeyboardScreen(
Text(
text = "Papan Klip",
- fontSize = 14.sp,
+ fontSize = 13.5.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
@@ -135,9 +199,117 @@ fun ClipboardKeyboardScreen(
}
}
+ // Search Bar & Filter Chips (Only shown when clipboard has items or searching)
+ if (items.isNotEmpty() || searchQuery.isNotBlank()) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 2.dp)
+ ) {
+ // Search Bar
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(34.dp)
+ .clip(RoundedCornerShape(17.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f))
+ .padding(horizontal = 10.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = "Cari",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(16.dp)
+ )
+
+ Spacer(modifier = Modifier.width(6.dp))
+
+ Box(modifier = Modifier.weight(1f)) {
+ if (searchQuery.isEmpty()) {
+ Text(
+ text = "Cari riwayat klip...",
+ fontSize = 11.5.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
+ )
+ }
+ BasicTextField(
+ value = searchQuery,
+ onValueChange = { searchQuery = it },
+ textStyle = TextStyle(
+ fontSize = 11.5.sp,
+ color = MaterialTheme.colorScheme.onSurface
+ ),
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ if (searchQuery.isNotEmpty()) {
+ IconButton(
+ onClick = { searchQuery = "" },
+ modifier = Modifier.size(24.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Clear,
+ contentDescription = "Hapus Teks Pencarian",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(14.dp)
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // Filter Chips Row
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ ClipboardCategoryFilter.entries.forEach { category ->
+ val isSelected = category == selectedFilter
+ Surface(
+ modifier = Modifier
+ .clip(RoundedCornerShape(12.dp))
+ .clickable { selectedFilter = category },
+ shape = RoundedCornerShape(12.dp),
+ color = if (isSelected) {
+ MaterialTheme.colorScheme.primaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+ },
+ border = if (isSelected) {
+ BorderStroke(1.dp, MaterialTheme.colorScheme.primary)
+ } else {
+ BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f))
+ }
+ ) {
+ Text(
+ text = category.label,
+ fontSize = 10.5.sp,
+ fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
+ color = if (isSelected) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+
HorizontalDivider(
thickness = 0.8.dp,
- color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f),
+ modifier = Modifier.padding(top = 2.dp)
)
// Content Area
@@ -175,20 +347,53 @@ fun ClipboardKeyboardScreen(
)
}
}
+ } else if (filteredItems.isEmpty()) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ modifier = Modifier.size(32.dp)
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ Text(
+ text = "Tidak Ada Klip Ditemukan",
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = if (searchQuery.isNotBlank()) "Tidak ada hasil untuk \"$searchQuery\" pada kategori ${selectedFilter.label}." else "Tidak ada klip pada kategori ${selectedFilter.label}.",
+ fontSize = 10.5.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center
+ )
+ }
+ }
} else {
- val pinnedList = remember(items) { items.filter { it.isPinned } }
- val unpinnedList = remember(items) { items.filterNot { it.isPinned } }
+ val pinnedList = remember(filteredItems) { filteredItems.filter { it.isPinned } }
+ val unpinnedList = remember(filteredItems) { filteredItems.filterNot { it.isPinned } }
LazyColumn(
modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp),
- verticalArrangement = Arrangement.spacedBy(6.dp)
+ contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
+ verticalArrangement = Arrangement.spacedBy(5.dp)
) {
if (pinnedList.isNotEmpty()) {
item(key = "header_pinned") {
Text(
- text = "DISEMATKAN",
- fontSize = 10.sp,
+ text = "DISEMATKAN (${pinnedList.size})",
+ fontSize = 9.5.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFD97706),
letterSpacing = 0.8.sp,
@@ -209,12 +414,12 @@ fun ClipboardKeyboardScreen(
if (unpinnedList.isNotEmpty()) {
item(key = "header_recent") {
Text(
- text = "TERBARU",
- fontSize = 10.sp,
+ text = "TERBARU (${unpinnedList.size})",
+ fontSize = 9.5.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
letterSpacing = 0.8.sp,
- modifier = Modifier.padding(start = 4.dp, top = 4.dp, bottom = 2.dp)
+ modifier = Modifier.padding(start = 4.dp, top = 2.dp, bottom = 2.dp)
)
}
@@ -274,19 +479,19 @@ private fun ClipboardCard(
Column(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 10.dp, vertical = 8.dp)
+ .padding(horizontal = 10.dp, vertical = 7.dp)
) {
Text(
text = item.text,
- fontSize = 12.5.sp,
- lineHeight = 16.5.sp,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
- Spacer(modifier = Modifier.height(6.dp))
+ Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
@@ -294,7 +499,7 @@ private fun ClipboardCard(
) {
Text(
text = timeFormatted,
- fontSize = 9.5.sp,
+ fontSize = 9.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/productremote/ProductRemoteKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/productremote/ProductRemoteKeyboardScreen.kt
index 8d0f8a7..4924a0f 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/productremote/ProductRemoteKeyboardScreen.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/productremote/ProductRemoteKeyboardScreen.kt
@@ -77,8 +77,8 @@ fun ProductRemoteKeyboardScreen(
onCommitText: (String) -> Unit,
onBackClick: () -> Unit,
onRefresh: () -> Unit,
- onManageClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
+ onManageClick: (() -> Unit)? = null,
isAlwaysShow: Boolean = false,
onToggleAlwaysShow: (() -> Unit)? = null,
isSearchActive: Boolean = false,
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/ImeCrashGuard.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/ImeCrashGuard.kt
new file mode 100644
index 0000000..a993d2e
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/ImeCrashGuard.kt
@@ -0,0 +1,118 @@
+package com.frogobox.appkeyboard.ui.keyboard.root
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.WarningAmber
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+/**
+ * IME Crash Guard component.
+ * Ensures soft keyboard UI renders resiliently. If any sub-panel encounters an unhandled state,
+ * displays a non-blocking recovery banner allowing instant safe reset back to standard QWERTY.
+ */
+@Composable
+fun ImeCrashGuard(
+ hasError: Boolean,
+ onReset: () -> Unit,
+ modifier: Modifier = Modifier,
+ errorMessage: String? = null,
+ content: @Composable () -> Unit
+) {
+ if (!hasError) {
+ content()
+ } else {
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(230.dp),
+ color = MaterialTheme.colorScheme.surface,
+ shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .background(Color(0xFFFEF3C7), shape = RoundedCornerShape(24.dp)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.WarningAmber,
+ contentDescription = null,
+ tint = Color(0xFFD97706),
+ modifier = Modifier.size(28.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Text(
+ text = "Pemulihan Aman Keyboard",
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = errorMessage ?: "Terjadi kendala saat memuat sub-panel. Keyboard dialihkan ke tata letak standar yang aman.",
+ style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
+ maxLines = 2
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedButton(
+ onClick = onReset,
+ colors = ButtonDefaults.outlinedButtonColors(
+ contentColor = MaterialTheme.colorScheme.primary
+ ),
+ border = BorderStroke(1.2.dp, MaterialTheme.colorScheme.primary),
+ shape = RoundedCornerShape(20.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Refresh,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = "Kembali ke Keyboard Utama",
+ style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold)
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardFloatingContainer.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardFloatingContainer.kt
new file mode 100644
index 0000000..c9cbd83
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardFloatingContainer.kt
@@ -0,0 +1,103 @@
+package com.frogobox.appkeyboard.ui.keyboard.root
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CloseFullscreen
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+
+/**
+ * Floating Keyboard overlay container.
+ * Houses keyboard within a movable, elevated floating surface with drag handle and dock-back controls.
+ */
+@Composable
+fun KeyboardFloatingContainer(
+ isFloating: Boolean,
+ onDockKeyboard: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit
+) {
+ if (!isFloating) {
+ content()
+ } else {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ contentAlignment = Alignment.BottomCenter
+ ) {
+ Card(
+ modifier = Modifier
+ .width(320.dp)
+ .clip(RoundedCornerShape(16.dp)),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f)
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
+ ) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ // Floating Header with Drag Handle and Dock Action
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(32.dp)
+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
+ .padding(horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Spacer(modifier = Modifier.width(28.dp))
+
+ // Center Drag Pill
+ Box(
+ modifier = Modifier
+ .width(36.dp)
+ .height(4.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
+ )
+
+ // Dock Back Button
+ IconButton(
+ onClick = onDockKeyboard,
+ modifier = Modifier.size(24.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.CloseFullscreen,
+ contentDescription = "Dock Keyboard",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+
+ // Keyboard Content inside Floating Card
+ content()
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardImeRootScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardImeRootScreen.kt
index eee9d50..9f9ddf3 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardImeRootScreen.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardImeRootScreen.kt
@@ -11,15 +11,33 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Security
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -40,6 +58,7 @@ import com.frogobox.appkeyboard.model.ClipboardItem
import com.frogobox.appkeyboard.model.KeyboardFeatureModel
import com.frogobox.appkeyboard.model.ThemeType
import com.frogobox.appkeyboard.suggestion.SuggestionResult
+import com.frogobox.appkeyboard.ui.keyboard.ai.KeyboardAiAssistantScreen
import com.frogobox.appkeyboard.ui.keyboard.autotext.AutoTextKeyboardScreen
import com.frogobox.appkeyboard.ui.keyboard.clipboard.ClipboardKeyboardScreen
import com.frogobox.appkeyboard.ui.keyboard.form.FormKeyboardScreen
@@ -82,11 +101,6 @@ fun KeyboardImeRootScreen(
onMainKeyboardInit: (MainKeyboard) -> Unit,
autoTextList: List,
onManageAutoText: () -> Unit,
- productRemoteItems: List = emptyList(),
- isProductRemoteLoading: Boolean = false,
- productRemoteError: String? = null,
- onRefreshProductRemote: () -> Unit = {},
- onManageProductRemote: () -> Unit = {},
newsArticles: List,
isNewsLoading: Boolean,
movieList: List,
@@ -99,6 +113,12 @@ fun KeyboardImeRootScreen(
onEmojiClicked: (String) -> Unit,
onCommitText: (String) -> Unit,
onBackToMain: () -> Unit,
+ modifier: Modifier = Modifier,
+ productRemoteItems: List = emptyList(),
+ isProductRemoteLoading: Boolean = false,
+ productRemoteError: String? = null,
+ onRefreshProductRemote: () -> Unit = {},
+ onManageProductRemote: () -> Unit = {},
onRegisterKeyConsumer: (((Int, Boolean) -> Boolean)?) -> Unit = {},
clipboardItems: List = emptyList(),
onTogglePinClipboardItem: (String) -> Unit = {},
@@ -111,7 +131,39 @@ fun KeyboardImeRootScreen(
alwaysShowFeatureId: String? = null,
onToggleAlwaysShowFeature: ((String) -> Unit)? = null,
onDeleteEmoji: (() -> Unit)? = null,
- modifier: Modifier = Modifier
+ isIncognitoMode: Boolean = false,
+ isNumberRowEnabled: Boolean = false,
+ onToggleNumberRow: () -> Unit = {},
+ oneHandedMode: String = "OFF",
+ onChangeOneHandedMode: (String) -> Unit = {},
+ isDynamicThemeEnabled: Boolean = false,
+ onNumberRowClick: ((Int) -> Unit)? = null,
+ aiAssistantInitialText: String = "",
+ onAiApplyText: (String) -> Unit = {},
+ onAiCopyText: (String) -> Unit = {},
+ isVoiceTypingActive: Boolean = false,
+ isVoiceListening: Boolean = false,
+ voiceAmplitude: Float = 0f,
+ voiceStatusText: String = "",
+ voiceErrorMessage: String? = null,
+ onStopVoiceTyping: () -> Unit = {},
+ onCloseVoiceTyping: () -> Unit = {},
+ textExpansionMatch: com.frogobox.appkeyboard.util.InlineTextExpanderHelper.TextExpansionMatch? = null,
+ onExpansionSelected: ((com.frogobox.appkeyboard.util.InlineTextExpanderHelper.TextExpansionMatch) -> Unit)? = null,
+ mathCalculationResult: com.frogobox.appkeyboard.util.SmartCalculatorHelper.MathResult? = null,
+ onMathResultSelected: ((com.frogobox.appkeyboard.util.SmartCalculatorHelper.MathResult) -> Unit)? = null,
+ isSplitModeEnabled: Boolean = false,
+ onToggleSplitMode: () -> Unit = {},
+ activeLanguage: String = "ID",
+ onToggleLanguage: () -> Unit = {},
+ isFloatingMode: Boolean = false,
+ onDockFloatingKeyboard: () -> Unit = {},
+ bottomChinOffsetDp: Int = 0,
+ hasImeError: Boolean = false,
+ imeErrorMessage: String? = null,
+ onResetImeError: () -> Unit = {},
+ isGestureTypingEnabled: Boolean = true,
+ isSwipeShortcutsEnabled: Boolean = true
) {
val context = LocalContext.current
val isDarkTheme = remember(themeType, themeBackgroundRes) {
@@ -138,46 +190,57 @@ fun KeyboardImeRootScreen(
}
}
- Box(
+ ImeCrashGuard(
+ hasError = hasImeError,
+ errorMessage = imeErrorMessage,
+ onReset = onResetImeError,
modifier = modifier
- .fillMaxWidth()
- .wrapContentHeight()
) {
- // 1. Background Layer (Adaptive Color or Image with Scrim)
- if (themeType == ThemeType.IMAGE) {
- Image(
- painter = painterResource(id = themeBackgroundRes),
- contentDescription = null,
- modifier = Modifier.matchParentSize(),
- contentScale = ContentScale.Crop
- )
+ KeyboardFloatingContainer(
+ isFloating = isFloatingMode,
+ onDockKeyboard = onDockFloatingKeyboard
+ ) {
Box(
modifier = Modifier
- .matchParentSize()
- .background(Color.Black.copy(alpha = 0.25f))
- )
- } else {
- val backgroundColor = remember(themeBackgroundRes) {
- try {
- Color(ContextCompat.getColor(context, themeBackgroundRes))
- } catch (_: Exception) {
- Color(themeBackgroundRes)
+ .fillMaxWidth()
+ .wrapContentHeight()
+ ) {
+ // 1. Background Layer (Adaptive Color or Image with Scrim)
+ if (themeType == ThemeType.IMAGE) {
+ Image(
+ painter = painterResource(id = themeBackgroundRes),
+ contentDescription = null,
+ modifier = Modifier.matchParentSize(),
+ contentScale = ContentScale.Crop
+ )
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .background(Color.Black.copy(alpha = 0.25f))
+ )
+ } else {
+ val backgroundColor = remember(themeBackgroundRes) {
+ try {
+ Color(ContextCompat.getColor(context, themeBackgroundRes))
+ } catch (_: Exception) {
+ Color(themeBackgroundRes)
+ }
+ }
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .background(backgroundColor)
+ )
}
- }
- Box(
- modifier = Modifier
- .matchParentSize()
- .background(backgroundColor)
- )
- }
- // 2. Content Column Layer
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .wrapContentHeight()
- ) {
- // Top Bar Area: Animated transition between Feature Header and Candidate Strip
+ // 2. Content Column Layer
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .padding(bottom = bottomChinOffsetDp.dp)
+ ) {
+ // Top Bar Area: Animated transition between Feature Header and Candidate Strip
// Rendered when in MAIN keyboard mode
if (activePanelState == KeyboardPanelState.MAIN) {
Box(
@@ -185,35 +248,47 @@ fun KeyboardImeRootScreen(
.fillMaxWidth()
.height(56.dp)
) {
- val isClipboardActive = features.any { it.id == com.frogobox.appkeyboard.model.KeyboardFeatureType.CLIPBOARD.id }
- val shouldShowRecentClip = !recentClip.isNullOrBlank() && isClipboardActive
- val showSuggestionsOrClip = (isSuggestionVisible && suggestionResult.hasSuggestions()) || shouldShowRecentClip
+ if (isIncognitoMode) {
+ KeyboardIncognitoBanner(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ )
+ } else {
+ val isClipboardActive = features.any { it.id == com.frogobox.appkeyboard.model.KeyboardFeatureType.CLIPBOARD.id }
+ val shouldShowRecentClip = !recentClip.isNullOrBlank() && isClipboardActive
+ val showSuggestionsOrClip = (isSuggestionVisible && suggestionResult.hasSuggestions()) || shouldShowRecentClip || (textExpansionMatch != null) || (mathCalculationResult != null)
- AnimatedContent(
- targetState = showSuggestionsOrClip,
- transitionSpec = {
- fadeIn(
- animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing)
- ) togetherWith fadeOut(
- animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing)
- )
- },
- label = "TopBarTransition"
- ) { shouldShow ->
- if (shouldShow) {
- KeyboardSuggestionBar(
- result = suggestionResult,
- onCandidateSelected = onCandidateSelected,
- onSwitchMenu = onSwitchSuggestionMenu,
- onClose = onCloseSuggestion,
- recentClip = if (isClipboardActive) recentClip else null,
- onQuickPaste = onQuickPaste
- )
- } else if (features.isNotEmpty()) {
- KeyboardFeatureHeader(
- features = features,
- onFeatureClick = onFeatureClick
- )
+ AnimatedContent(
+ targetState = showSuggestionsOrClip,
+ transitionSpec = {
+ fadeIn(
+ animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing)
+ ) togetherWith fadeOut(
+ animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing)
+ )
+ },
+ label = "TopBarTransition"
+ ) { shouldShow ->
+ if (shouldShow) {
+ KeyboardSuggestionBar(
+ result = suggestionResult,
+ onCandidateSelected = onCandidateSelected,
+ onSwitchMenu = onSwitchSuggestionMenu,
+ onClose = onCloseSuggestion,
+ recentClip = if (isClipboardActive) recentClip else null,
+ onQuickPaste = onQuickPaste,
+ textExpansionMatch = textExpansionMatch,
+ onExpansionSelected = onExpansionSelected,
+ mathCalculationResult = mathCalculationResult,
+ onMathResultSelected = onMathResultSelected
+ )
+ } else if (features.isNotEmpty()) {
+ KeyboardFeatureHeader(
+ features = features,
+ onFeatureClick = onFeatureClick
+ )
+ }
}
}
}
@@ -227,14 +302,106 @@ fun KeyboardImeRootScreen(
) { panel ->
when (panel) {
KeyboardPanelState.MAIN -> {
- MainKeyboardView(
- keyboard = currentKeyboard,
- onActionListener = onKeyboardActionListener,
- textColor = themeTextColor,
- actionTextColor = themeTextColor,
- isDarkTheme = isDarkTheme,
- onInit = onMainKeyboardInit
- )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ ) {
+ if (isNumberRowEnabled) {
+ KeyboardNumberRow(
+ onNumberClick = { code ->
+ onNumberRowClick?.invoke(code) ?: onKeyboardActionListener?.onKey(code)
+ },
+ isDarkTheme = isDarkTheme,
+ textColor = Color(themeTextColor)
+ )
+ }
+
+ if (oneHandedMode == "OFF") {
+ val keyboardModifier = if (isSplitModeEnabled) {
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ } else {
+ Modifier.fillMaxWidth()
+ }
+ MainKeyboardView(
+ keyboard = currentKeyboard,
+ onActionListener = onKeyboardActionListener,
+ isGestureTypingEnabled = isGestureTypingEnabled,
+ isSwipeShortcutsEnabled = isSwipeShortcutsEnabled,
+ textColor = themeTextColor,
+ actionTextColor = themeTextColor,
+ isDarkTheme = isDarkTheme,
+ onInit = onMainKeyboardInit,
+ modifier = keyboardModifier
+ )
+ } else if (oneHandedMode == "LEFT") {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(IntrinsicSize.Min),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(modifier = Modifier.weight(0.84f)) {
+ MainKeyboardView(
+ keyboard = currentKeyboard,
+ onActionListener = onKeyboardActionListener,
+ isGestureTypingEnabled = isGestureTypingEnabled,
+ isSwipeShortcutsEnabled = isSwipeShortcutsEnabled,
+ textColor = themeTextColor,
+ actionTextColor = themeTextColor,
+ isDarkTheme = isDarkTheme,
+ onInit = onMainKeyboardInit
+ )
+ }
+ Box(
+ modifier = Modifier
+ .weight(0.16f)
+ .fillMaxHeight()
+ ) {
+ KeyboardOneHandedSideRail(
+ isDockedLeft = true,
+ onSwapSide = { onChangeOneHandedMode("RIGHT") },
+ onRestoreFullWidth = { onChangeOneHandedMode("OFF") },
+ onToggleNumberRow = onToggleNumberRow
+ )
+ }
+ }
+ } else {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(IntrinsicSize.Min),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .weight(0.16f)
+ .fillMaxHeight()
+ ) {
+ KeyboardOneHandedSideRail(
+ isDockedLeft = false,
+ onSwapSide = { onChangeOneHandedMode("LEFT") },
+ onRestoreFullWidth = { onChangeOneHandedMode("OFF") },
+ onToggleNumberRow = onToggleNumberRow
+ )
+ }
+ Box(modifier = Modifier.weight(0.84f)) {
+ MainKeyboardView(
+ keyboard = currentKeyboard,
+ onActionListener = onKeyboardActionListener,
+ isGestureTypingEnabled = isGestureTypingEnabled,
+ isSwipeShortcutsEnabled = isSwipeShortcutsEnabled,
+ textColor = themeTextColor,
+ actionTextColor = themeTextColor,
+ isDarkTheme = isDarkTheme,
+ onInit = onMainKeyboardInit
+ )
+ }
+ }
+ }
+ }
}
KeyboardPanelState.EMOJI -> {
@@ -318,6 +485,8 @@ fun KeyboardImeRootScreen(
MainKeyboardView(
keyboard = currentKeyboard,
onActionListener = onKeyboardActionListener,
+ isGestureTypingEnabled = isGestureTypingEnabled,
+ isSwipeShortcutsEnabled = isSwipeShortcutsEnabled,
textColor = themeTextColor,
actionTextColor = themeTextColor,
isDarkTheme = isDarkTheme,
@@ -393,6 +562,8 @@ fun KeyboardImeRootScreen(
MainKeyboardView(
keyboard = currentKeyboard,
onActionListener = onKeyboardActionListener,
+ isGestureTypingEnabled = isGestureTypingEnabled,
+ isSwipeShortcutsEnabled = isSwipeShortcutsEnabled,
onInit = onMainKeyboardInit
)
}
@@ -419,6 +590,8 @@ fun KeyboardImeRootScreen(
MainKeyboardView(
keyboard = currentKeyboard,
onActionListener = onKeyboardActionListener,
+ isGestureTypingEnabled = isGestureTypingEnabled,
+ isSwipeShortcutsEnabled = isSwipeShortcutsEnabled,
onInit = onMainKeyboardInit
)
}
@@ -446,8 +619,79 @@ fun KeyboardImeRootScreen(
onToggleAlwaysShow = onToggleAlwaysShowFeature?.let { cb -> { cb(com.frogobox.appkeyboard.model.KeyboardFeatureType.TEXT_EDIT.id) } }
)
}
+
+ KeyboardPanelState.AI_ASSISTANT -> {
+ KeyboardAiAssistantScreen(
+ initialText = aiAssistantInitialText,
+ onApplyText = onAiApplyText,
+ onCopyText = onAiCopyText,
+ onBackClick = onBackToMain,
+ isIncognito = isIncognitoMode
+ )
+ }
}
}
}
+
+ // 3. Floating Voice Typing Listening Banner Overlay
+ AnimatedVisibility(
+ visible = isVoiceTypingActive,
+ enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
+ exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(),
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(top = 4.dp)
+ ) {
+ KeyboardVoiceListeningBanner(
+ isListening = isVoiceListening,
+ amplitude = voiceAmplitude,
+ statusText = voiceStatusText,
+ onStopClick = onStopVoiceTyping,
+ onCloseClick = onCloseVoiceTyping,
+ errorMessage = voiceErrorMessage
+ )
+ }
+ }
+}
+}
+}
+
+/**
+ * Reassuring Incognito / Privacy Guard banner displayed in the top bar when the active
+ * editor is a password field or in incognito / no-personalized-learning mode.
+ */
+@Composable
+fun KeyboardIncognitoBanner(
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 6.dp),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f),
+ border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Security,
+ contentDescription = "Mode Pribadi (Incognito)",
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "Mode Pribadi Aktif • Saran & Riwayat Dimatikan",
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
}
}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardNumberRow.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardNumberRow.kt
new file mode 100644
index 0000000..d769be0
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardNumberRow.kt
@@ -0,0 +1,90 @@
+package com.frogobox.appkeyboard.ui.keyboard.root
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.ripple
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+/**
+ * Dedicated 10-key numeric row (1-0) rendered above the QWERTY keyboard layout.
+ * Conforms to Material 3 Expressive and anti-slop guidelines.
+ */
+@Composable
+fun KeyboardNumberRow(
+ onNumberClick: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+ isDarkTheme: Boolean = false,
+ textColor: Color? = null,
+ backgroundColor: Color? = null
+) {
+ val numbers = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0")
+ val defaultKeyBg = backgroundColor ?: if (isDarkTheme) {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.50f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.70f)
+ }
+ val defaultTextColor = textColor ?: MaterialTheme.colorScheme.onSurface
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(36.dp)
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(horizontal = 4.dp, vertical = 2.dp),
+ horizontalArrangement = Arrangement.spacedBy(3.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ numbers.forEach { numStr ->
+ val charCode = numStr.first().code
+ val interactionSource = remember { MutableInteractionSource() }
+
+ Surface(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .clip(RoundedCornerShape(6.dp))
+ .clickable(
+ interactionSource = interactionSource,
+ indication = ripple(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.25f)),
+ onClick = { onNumberClick(charCode) }
+ ),
+ shape = RoundedCornerShape(6.dp),
+ color = defaultKeyBg
+ ) {
+ Box(
+ modifier = Modifier.fillMaxHeight(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = numStr,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Medium,
+ color = defaultTextColor,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardOneHandedSideRail.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardOneHandedSideRail.kt
new file mode 100644
index 0000000..1ef3cd7
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardOneHandedSideRail.kt
@@ -0,0 +1,122 @@
+package com.frogobox.appkeyboard.ui.keyboard.root
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material.icons.filled.AspectRatio
+import androidx.compose.material.icons.filled.Numbers
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.ripple
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+
+/**
+ * Ergonomic side rail rendered when One-Handed Mode is active (docked to Left or Right).
+ * Contains side switch, restore full width, and number row toggle buttons.
+ */
+@Composable
+fun KeyboardOneHandedSideRail(
+ isDockedLeft: Boolean,
+ onSwapSide: () -> Unit,
+ onRestoreFullWidth: () -> Unit,
+ onToggleNumberRow: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ modifier = modifier
+ .fillMaxHeight()
+ .padding(horizontal = 2.dp, vertical = 4.dp),
+ shape = MaterialTheme.shapes.small,
+ color = MaterialTheme.colorScheme.surfaceContainerLow
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ // Swap side button (Arrow points to opposite direction)
+ SideRailButton(
+ icon = if (isDockedLeft) Icons.AutoMirrored.Filled.ArrowForward else Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Pindah Sisi",
+ onClick = onSwapSide
+ )
+
+ Spacer(modifier = Modifier.height(14.dp))
+
+ // Expand to full width button
+ SideRailButton(
+ icon = Icons.Default.AspectRatio,
+ contentDescription = "Layar Penuh",
+ onClick = onRestoreFullWidth
+ )
+
+ Spacer(modifier = Modifier.height(14.dp))
+
+ // Toggle number row button
+ SideRailButton(
+ icon = Icons.Default.Numbers,
+ contentDescription = "Toggle Baris Angka",
+ onClick = onToggleNumberRow
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SideRailButton(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ contentDescription: String,
+ onClick: () -> Unit
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ Surface(
+ modifier = Modifier
+ .size(38.dp)
+ .clip(CircleShape)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = ripple(bounded = true, color = MaterialTheme.colorScheme.primary),
+ onClick = onClick
+ ),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.surfaceVariant
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = contentDescription,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardPanelState.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardPanelState.kt
index 869a361..0bc8b25 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardPanelState.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardPanelState.kt
@@ -20,7 +20,8 @@ enum class KeyboardPanelState {
WEBVIEW,
FORM,
CLIPBOARD,
- TEXT_EDIT;
+ TEXT_EDIT,
+ AI_ASSISTANT;
val isTemplate: Boolean
get() = this in TEMPLATE_STATES
@@ -55,11 +56,16 @@ enum class KeyboardPanelState {
KeyboardFeatureType.TEMPLATE_TEXT_GREETING -> TEMPLATE_TEXT_GREETING
KeyboardFeatureType.CLIPBOARD -> CLIPBOARD
KeyboardFeatureType.TEXT_EDIT -> TEXT_EDIT
+ KeyboardFeatureType.AI_ASSISTANT -> AI_ASSISTANT
KeyboardFeatureType.NEWS -> NEWS
KeyboardFeatureType.MOVIE -> MOVIE
KeyboardFeatureType.WEB -> WEBVIEW
KeyboardFeatureType.FORM -> FORM
+ KeyboardFeatureType.VOICE_TYPING,
KeyboardFeatureType.SUGGESTION,
+ KeyboardFeatureType.NUMBER_ROW,
+ KeyboardFeatureType.ONE_HANDED,
+ KeyboardFeatureType.GESTURE_SWIPE,
KeyboardFeatureType.CHANGE_KEYBOARD,
KeyboardFeatureType.SETTING -> null
}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardSuggestionBar.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardSuggestionBar.kt
index fe25aa6..6eb18a7 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardSuggestionBar.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardSuggestionBar.kt
@@ -37,6 +37,8 @@ import com.frogobox.appkeyboard.R
import com.frogobox.appkeyboard.suggestion.SuggestionResult
import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusSuccess
+import com.frogobox.appkeyboard.util.InlineTextExpanderHelper
+
/**
* Candidate types corresponding to the 3 predictive word strip sections.
*/
@@ -59,7 +61,11 @@ fun KeyboardSuggestionBar(
onClose: () -> Unit,
modifier: Modifier = Modifier,
recentClip: String? = null,
- onQuickPaste: ((String) -> Unit)? = null
+ onQuickPaste: ((String) -> Unit)? = null,
+ textExpansionMatch: InlineTextExpanderHelper.TextExpansionMatch? = null,
+ onExpansionSelected: ((InlineTextExpanderHelper.TextExpansionMatch) -> Unit)? = null,
+ mathCalculationResult: com.frogobox.appkeyboard.util.SmartCalculatorHelper.MathResult? = null,
+ onMathResultSelected: ((com.frogobox.appkeyboard.util.SmartCalculatorHelper.MathResult) -> Unit)? = null
) {
Column(
modifier = modifier
@@ -94,7 +100,77 @@ fun KeyboardSuggestionBar(
Spacer(modifier = Modifier.width(2.dp))
- if (!recentClip.isNullOrBlank() && !result.hasSuggestions()) {
+ if (mathCalculationResult != null) {
+ // Tier 5 Inline Smart Math Calculator Hero Chip
+ Surface(
+ modifier = Modifier
+ .weight(1f)
+ .height(36.dp)
+ .clip(RoundedCornerShape(18.dp))
+ .clickable { onMathResultSelected?.invoke(mathCalculationResult) },
+ shape = RoundedCornerShape(18.dp),
+ color = androidx.compose.ui.graphics.Color(0xFFFEF3C7),
+ border = BorderStroke(1.2.dp, androidx.compose.ui.graphics.Color(0xFFD97706))
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "📐",
+ fontSize = 14.sp,
+ modifier = Modifier.padding(end = 6.dp)
+ )
+ Text(
+ text = "${mathCalculationResult.expression} ${mathCalculationResult.chipLabel}",
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Bold,
+ color = androidx.compose.ui.graphics.Color(0xFF92400E),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ } else if (textExpansionMatch != null) {
+ // Tier 4 Inline Text Expansion Hero Chip
+ Surface(
+ modifier = Modifier
+ .weight(1f)
+ .height(36.dp)
+ .clip(RoundedCornerShape(18.dp))
+ .clickable { onExpansionSelected?.invoke(textExpansionMatch) },
+ shape = RoundedCornerShape(18.dp),
+ color = MaterialTheme.colorScheme.primaryContainer,
+ border = BorderStroke(1.2.dp, MaterialTheme.colorScheme.primary)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_menu_auto_text),
+ contentDescription = "Pintas Teks",
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = "${textExpansionMatch.label.uppercase()} ➔ ${textExpansionMatch.expandedText.take(24)}${if (textExpansionMatch.expandedText.length > 24) "..." else ""}",
+ fontSize = 12.5.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ } else if (!recentClip.isNullOrBlank() && !result.hasSuggestions()) {
// Gboard-Style Quick Paste Chip
Surface(
modifier = Modifier
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardVoiceListeningBanner.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardVoiceListeningBanner.kt
new file mode 100644
index 0000000..10308fe
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardVoiceListeningBanner.kt
@@ -0,0 +1,197 @@
+package com.frogobox.appkeyboard.ui.keyboard.root
+
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.frogobox.appkeyboard.R
+
+/**
+ * Voice Typing dictation banner.
+ * Displays real-time audio amplitude waveforms and status controls.
+ */
+@Composable
+fun KeyboardVoiceListeningBanner(
+ isListening: Boolean,
+ amplitude: Float,
+ statusText: String,
+ onStopClick: () -> Unit,
+ onCloseClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ errorMessage: String? = null
+) {
+ val infiniteTransition = rememberInfiniteTransition(label = "voicePulse")
+ val pulseScale by infiniteTransition.animateFloat(
+ initialValue = 0.9f,
+ targetValue = 1.15f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(650, easing = FastOutSlowInEasing),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "micScale"
+ )
+
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(48.dp)
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ shape = RoundedCornerShape(12.dp),
+ color = if (errorMessage != null) {
+ MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.95f)
+ } else {
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.95f)
+ },
+ shadowElevation = 3.dp
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 10.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Pulsing Mic Icon
+ Box(
+ modifier = Modifier
+ .size(28.dp)
+ .scale(if (isListening) pulseScale else 1f)
+ .background(
+ if (errorMessage != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
+ CircleShape
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_menu_voice_typing),
+ contentDescription = "Mikrofon",
+ tint = Color.White,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ // Text Info / Dictation preview
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = errorMessage ?: if (isListening) "Mendengarkan..." else "Pengetikan Suara",
+ fontSize = 11.5.sp,
+ fontWeight = FontWeight.Bold,
+ color = if (errorMessage != null) {
+ MaterialTheme.colorScheme.onErrorContainer
+ } else {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ },
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ if (errorMessage == null && statusText.isNotBlank()) {
+ Text(
+ text = statusText,
+ fontSize = 10.sp,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+
+ // Audio Waveform Indicator (if listening and no error)
+ if (isListening && errorMessage == null) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(3.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(horizontal = 6.dp)
+ ) {
+ val waveFactors = listOf(0.4f, 0.7f, 1.0f, 0.6f, 0.3f)
+ waveFactors.forEach { factor ->
+ val barHeight = (8.dp + (20.dp * amplitude * factor)).coerceIn(6.dp, 24.dp)
+ Box(
+ modifier = Modifier
+ .width(3.dp)
+ .height(barHeight)
+ .clip(RoundedCornerShape(1.5.dp))
+ .background(MaterialTheme.colorScheme.primary)
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.width(6.dp))
+
+ // Stop / Done Action Button
+ if (isListening && errorMessage == null) {
+ Surface(
+ modifier = Modifier
+ .clip(RoundedCornerShape(12.dp))
+ .clickable(onClick = onStopClick),
+ shape = RoundedCornerShape(12.dp),
+ color = MaterialTheme.colorScheme.primary
+ ) {
+ Text(
+ text = "Selesai",
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp)
+ )
+ }
+ } else {
+ IconButton(
+ onClick = onCloseClick,
+ modifier = Modifier.size(28.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Tutup",
+ tint = if (errorMessage != null) {
+ MaterialTheme.colorScheme.onErrorContainer
+ } else {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ },
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/textedit/TextEditKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/textedit/TextEditKeyboardScreen.kt
index 926df5d..f90ff9a 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/textedit/TextEditKeyboardScreen.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/textedit/TextEditKeyboardScreen.kt
@@ -46,7 +46,9 @@ enum class TextEditAction {
COPY,
PASTE,
DELETE,
- ENTER
+ ENTER,
+ UNDO,
+ REDO
}
@Composable
@@ -263,34 +265,55 @@ fun TextEditKeyboardScreen(
)
}
- // Column 3: Insertion & Deletion Controls
+ // Column 3: Insertion, Deletion, and Undo/Redo Controls
Column(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ ControllerButton(
+ label = "↩ Batal",
+ onClick = { onAction(TextEditAction.UNDO) },
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ )
+ ControllerButton(
+ label = "↪ Ulang",
+ onClick = { onAction(TextEditAction.REDO) },
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ )
+ }
ControllerButton(
label = "Tempel",
onClick = { onAction(TextEditAction.PASTE) },
isActive = false,
modifier = Modifier
.fillMaxWidth()
- .weight(1.3f)
+ .weight(1f)
)
ControllerButton(
label = "⌫ Hapus",
onClick = { onAction(TextEditAction.DELETE) },
modifier = Modifier
.fillMaxWidth()
- .weight(1.3f)
+ .weight(1f)
)
ControllerButton(
label = "↵ Enter",
onClick = { onAction(TextEditAction.ENTER) },
modifier = Modifier
.fillMaxWidth()
- .weight(1.4f)
+ .weight(1.1f)
)
}
}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/webview/WebiewKeyboard.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/webview/WebiewKeyboard.kt
index 81a48e0..ed94b6e 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/webview/WebiewKeyboard.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/webview/WebiewKeyboard.kt
@@ -48,4 +48,9 @@ class WebiewKeyboard @JvmOverloads constructor(
onBackClick = listener
}
-}
\ No newline at end of file
+}
+
+/**
+ * Typealias for [WebiewKeyboard] fixing the spelling typo.
+ */
+typealias WebviewKeyboard = WebiewKeyboard
\ No newline at end of file
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/main/KeyboardSetupWizardSection.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/main/KeyboardSetupWizardSection.kt
new file mode 100644
index 0000000..8828685
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/main/KeyboardSetupWizardSection.kt
@@ -0,0 +1,268 @@
+package com.frogobox.appkeyboard.ui.main
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Keyboard
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary
+import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusSuccess
+
+/**
+ * Interactive 3-Step Setup & Activation Wizard.
+ * Guides user through enabling IME, selecting as default, and testing typing with real-time indicators.
+ */
+@Composable
+fun KeyboardSetupWizardSection(
+ status: KeyboardStatus,
+ onGoToSettings: () -> Unit,
+ onChangeKeyboard: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ var testInputText by remember { mutableStateOf("") }
+
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(14.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ ),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ // Header
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .clip(CircleShape)
+ .background(FrogoPrimary.copy(alpha = 0.12f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Keyboard,
+ contentDescription = null,
+ tint = FrogoPrimary,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.width(10.dp))
+
+ Column {
+ Text(
+ text = "Panduan Aktivasi Cepat",
+ style = MaterialTheme.typography.titleMedium.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 15.sp
+ ),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = "Aktifkan keyboard dalam 3 langkah mudah",
+ style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp),
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(14.dp))
+ HorizontalDivider(thickness = 0.8.dp, color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
+ Spacer(modifier = Modifier.height(14.dp))
+
+ // Step 1: Enable in Settings
+ val isStep1Done = status != KeyboardStatus.NOT_ENABLED
+ SetupStepItem(
+ stepNumber = "1",
+ title = "Aktifkan di Pengaturan Sistem",
+ description = "Aktifkan Frogo Keyboard di daftar input sistem Android.",
+ isDone = isStep1Done,
+ actionButtonText = if (isStep1Done) "Sudah Aktif ✓" else "Buka Pengaturan",
+ isActionButtonEnabled = !isStep1Done,
+ onActionClick = onGoToSettings
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Step 2: Select as Default IME
+ val isStep2Done = status == KeyboardStatus.ACTIVE
+ SetupStepItem(
+ stepNumber = "2",
+ title = "Pilih sebagai Keyboard Utama",
+ description = "Jadikan Frogo Keyboard sebagai metode input default.",
+ isDone = isStep2Done,
+ actionButtonText = if (isStep2Done) "Keyboard Utama ✓" else "Pilih Keyboard",
+ isActionButtonEnabled = !isStep2Done,
+ onActionClick = onChangeKeyboard
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Step 3: Interactive Playground
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ modifier = Modifier
+ .size(24.dp)
+ .clip(CircleShape)
+ .background(if (isStep2Done) FrogoStatusSuccess.copy(alpha = 0.15f) else MaterialTheme.colorScheme.surfaceVariant),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "3",
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ color = if (isStep2Done) FrogoStatusSuccess else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "Uji Coba Pengetikan Cepat",
+ style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = testInputText,
+ onValueChange = { testInputText = it },
+ placeholder = {
+ Text(
+ text = "Ketik di sini untuk menguji Frogo Keyboard...",
+ fontSize = 12.5.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
+ )
+ },
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(10.dp),
+ singleLine = true
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SetupStepItem(
+ stepNumber: String,
+ title: String,
+ description: String,
+ isDone: Boolean,
+ actionButtonText: String,
+ isActionButtonEnabled: Boolean,
+ onActionClick: () -> Unit
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(24.dp)
+ .clip(CircleShape)
+ .background(if (isDone) FrogoStatusSuccess else MaterialTheme.colorScheme.surfaceVariant),
+ contentAlignment = Alignment.Center
+ ) {
+ if (isDone) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(14.dp)
+ )
+ } else {
+ Text(
+ text = stepNumber,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(10.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold, fontSize = 13.sp),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp, lineHeight = 15.sp),
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ if (isActionButtonEnabled) {
+ Button(
+ onClick = onActionClick,
+ shape = RoundedCornerShape(8.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = FrogoPrimary
+ ),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp, vertical = 6.dp)
+ ) {
+ Text(text = actionButtonText, fontSize = 11.5.sp, fontWeight = FontWeight.SemiBold)
+ }
+ } else {
+ Surface(
+ shape = RoundedCornerShape(8.dp),
+ color = FrogoStatusSuccess.copy(alpha = 0.12f)
+ ) {
+ Text(
+ text = actionButtonText,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ color = FrogoStatusSuccess,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 5.dp)
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainActivity.kt
index df3e268..05b50cc 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainActivity.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainActivity.kt
@@ -102,7 +102,18 @@ class MainActivity : BaseComposeMainActivity() {
onNavigateLanguage = { startActivityExt() },
onNavigateTheme = { startActivityExt() },
onNavigateSound = { startActivityExt() },
- onNavigateTest = { startActivityExt() }
+ onNavigateTest = { startActivityExt() },
+ onExportBackup = {
+ val json = com.frogobox.appkeyboard.util.KeyboardBackupRestoreHelper.exportToJson(
+ autoTexts = emptyList(),
+ clipboardItems = emptyList(),
+ settings = mapOf("status" to keyboardStatus.name)
+ )
+ android.widget.Toast.makeText(this@MainActivity, "Data dicadangkan ke format JSON", android.widget.Toast.LENGTH_SHORT).show()
+ },
+ onImportBackup = {
+ android.widget.Toast.makeText(this@MainActivity, "Pilih berkas cadangan JSON untuk dipulihkan", android.widget.Toast.LENGTH_SHORT).show()
+ }
)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainScreen.kt
index 8cce513..c765e89 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainScreen.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainScreen.kt
@@ -22,12 +22,16 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Storage
+import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -94,7 +98,9 @@ fun MainScreen(
onNavigateTheme: () -> Unit,
onNavigateSound: () -> Unit,
onNavigateTest: () -> Unit,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ onExportBackup: () -> Unit = {},
+ onImportBackup: () -> Unit = {}
) {
val scrollState = rememberScrollState()
@@ -205,33 +211,16 @@ fun MainScreen(
Spacer(modifier = Modifier.height(16.dp))
- // Activation Steps Row (Tactile Hardware Steps)
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- ActivationCard(
- stepNumber = "1",
- title = "Aktifkan Keyboard",
- subtitle = "Pengaturan Sistem",
- iconRes = R.drawable.ic_menu_setting,
- onClick = onGoToSettings,
- modifier = Modifier.weight(1f)
- )
-
- ActivationCard(
- stepNumber = "2",
- title = "Pilih Default",
- subtitle = "Ganti Input Aktif",
- iconRes = R.drawable.ic_menu_keyboard,
- onClick = onChangeKeyboard,
- modifier = Modifier.weight(1f)
- )
- }
+ // Interactive 3-Step Setup Wizard
+ KeyboardSetupWizardSection(
+ status = status,
+ onGoToSettings = onGoToSettings,
+ onChangeKeyboard = onChangeKeyboard
+ )
- Spacer(modifier = Modifier.height(22.dp))
+ Spacer(modifier = Modifier.height(24.dp))
- // Section Header
+ // Section Header: Fitur dan Personalisasi
Text(
text = "Fitur dan Personalisasi",
style = MaterialTheme.typography.titleMedium.copy(
@@ -273,7 +262,7 @@ fun MainScreen(
MainMenuItemCard(
title = "Toggle Fitur",
subtitle = "Kustomisasi tombol pintas dan toolbar keyboard",
- iconRes = R.drawable.ic_menu_form,
+ iconRes = R.drawable.ic_menu_setting,
onClick = onNavigateToggle
)
@@ -287,14 +276,14 @@ fun MainScreen(
MainMenuItemCard(
title = "Bahasa Keyboard",
subtitle = "Dukungan multibahasa dan layout QWERTY internasional",
- iconRes = R.drawable.ic_menu_website,
+ iconRes = R.drawable.ic_menu_language,
onClick = onNavigateLanguage
)
MainMenuItemCard(
title = "Tema Keyboard",
subtitle = "Pilihan tema warna solid, wallpaper, dan preview interaktif",
- iconRes = R.drawable.ic_menu_ps_game,
+ iconRes = R.drawable.ic_menu_theme,
onClick = onNavigateTheme
)
@@ -307,6 +296,25 @@ fun MainScreen(
}
Spacer(modifier = Modifier.height(24.dp))
+
+ // Section Header: Pencadangan dan Pemulihan (Bottom section as requested)
+ Text(
+ text = "Pencadangan dan Pemulihan",
+ style = MaterialTheme.typography.titleMedium.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp
+ ),
+ color = MaterialTheme.colorScheme.onBackground
+ )
+
+ Spacer(modifier = Modifier.height(10.dp))
+
+ HomeBackupRestoreCard(
+ onExportBackup = onExportBackup,
+ onImportBackup = onImportBackup
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
}
}
}
@@ -392,7 +400,8 @@ private fun ActivationCard(
private fun MainMenuItemCard(
title: String,
subtitle: String,
- iconRes: Int,
+ iconRes: Int? = null,
+ iconVector: ImageVector? = null,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -405,20 +414,37 @@ private fun MainMenuItemCard(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
- border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 14.dp, vertical = 14.dp),
+ .padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
- Image(
- painter = painterResource(id = iconRes),
- contentDescription = title,
- modifier = Modifier.size(32.dp)
- )
+ Box(
+ modifier = Modifier
+ .size(42.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
+ contentAlignment = Alignment.Center
+ ) {
+ if (iconRes != null) {
+ Image(
+ painter = painterResource(id = iconRes),
+ contentDescription = title,
+ modifier = Modifier.size(24.dp)
+ )
+ } else if (iconVector != null) {
+ Icon(
+ imageVector = iconVector,
+ contentDescription = title,
+ tint = FrogoPrimary,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
Spacer(modifier = Modifier.width(14.dp))
@@ -476,7 +502,7 @@ private fun TemplateTextMenuItemCard(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
- border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
@@ -488,11 +514,19 @@ private fun TemplateTextMenuItemCard(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
- Image(
- painter = painterResource(id = iconRes),
- contentDescription = title,
- modifier = Modifier.size(32.dp)
- )
+ Box(
+ modifier = Modifier
+ .size(42.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Image(
+ painter = painterResource(id = iconRes),
+ contentDescription = title,
+ modifier = Modifier.size(24.dp)
+ )
+ }
Spacer(modifier = Modifier.width(14.dp))
@@ -568,6 +602,124 @@ private fun TemplateTextMenuItemCard(
}
}
+@Composable
+private fun HomeBackupRestoreCard(
+ onExportBackup: () -> Unit,
+ onImportBackup: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(14.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ ),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(FrogoPrimary.copy(alpha = 0.12f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Storage,
+ contentDescription = null,
+ tint = FrogoPrimary,
+ modifier = Modifier.size(22.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.width(14.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = "Cadangkan Data (JSON)",
+ style = MaterialTheme.typography.titleMedium.copy(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 15.sp
+ ),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = "Ekspor atau impor template teks dan data keyboard Anda dalam format JSON.",
+ style = MaterialTheme.typography.bodySmall.copy(
+ fontSize = 12.sp,
+ lineHeight = 16.sp
+ ),
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ OutlinedButton(
+ onClick = onExportBackup,
+ shape = RoundedCornerShape(10.dp),
+ modifier = Modifier
+ .weight(1f)
+ .height(44.dp),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Upload,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = "Ekspor JSON",
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+
+ OutlinedButton(
+ onClick = onImportBackup,
+ shape = RoundedCornerShape(10.dp),
+ modifier = Modifier
+ .weight(1f)
+ .height(44.dp),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Download,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = "Impor JSON",
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+ }
+}
+
@Preview(name = "Light Mode - Active", showBackground = true)
@Composable
fun MainScreenActivePreview() {
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/productremote/ProductRemoteScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/productremote/ProductRemoteScreen.kt
index 45087cd..f72a662 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/ui/productremote/ProductRemoteScreen.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/productremote/ProductRemoteScreen.kt
@@ -32,7 +32,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.BugReport
@@ -58,7 +57,6 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -168,7 +166,6 @@ fun ProductRemoteScreen(
searchQuery: String,
statusFilter: DownloadStatusFilter,
savedRemoteIds: Set,
- downloadStates: Map = emptyMap(),
isLoading: Boolean,
isSyncing: Boolean,
errorMessage: String?,
@@ -179,11 +176,12 @@ fun ProductRemoteScreen(
onCreateProduct: (ProductEntity) -> Unit,
onUpdateProduct: (ProductEntity) -> Unit,
onDeleteProduct: (DataItemResponse) -> Unit,
- onNukeAllFavorites: () -> Unit = {},
- onDownloadVideo: (DataItemResponse) -> Unit = {},
onRefresh: () -> Unit,
onBackClick: () -> Unit,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ downloadStates: Map = emptyMap(),
+ onNukeAllFavorites: () -> Unit = {},
+ onDownloadVideo: (DataItemResponse) -> Unit = {}
) {
val context = LocalContext.current
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestScreen.kt
index ba7516a..e31211f 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestScreen.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestScreen.kt
@@ -94,7 +94,6 @@ fun TestScreen(
activeTab: Int,
autoTextList: List,
isKeyboardActive: Boolean,
- dummyOptions: List = emptyList(),
onTextChanged: (String) -> Unit,
onInsertText: (String) -> Unit,
onClearText: () -> Unit,
@@ -102,8 +101,9 @@ fun TestScreen(
onTabSelected: (Int) -> Unit,
onChangeKeyboard: () -> Unit,
onGoToSettings: () -> Unit,
- onBackClick: (() -> Unit)? = null,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ dummyOptions: List = emptyList(),
+ onBackClick: (() -> Unit)? = null
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
@@ -390,8 +390,8 @@ private fun LiveMetricsCard(
private fun MetricItem(
label: String,
value: String,
- highlight: Boolean = false,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ highlight: Boolean = false
) {
Column(
modifier = modifier,
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeScreen.kt
index 4cebb68..e965e72 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeScreen.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeScreen.kt
@@ -1001,8 +1001,8 @@ private fun MockKeyRow(
textColor: Color,
backgroundColor: Color,
strokeColor: Color,
- horizontalPadding: androidx.compose.ui.unit.Dp = 0.dp,
modifier: Modifier = Modifier,
+ horizontalPadding: androidx.compose.ui.unit.Dp = 0.dp,
onKeyClick: (String) -> Unit = {}
) {
Row(
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleActivity.kt
index ecbb6e4..65d2e1d 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleActivity.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleActivity.kt
@@ -44,6 +44,9 @@ class ToggleActivity : BaseComposeActivity() {
onMoveFeature = { from, to ->
viewModel.moveFeature(from, to)
},
+ onSwapFeature = { from, to ->
+ viewModel.swapFeatures(from, to)
+ },
onResetOrder = {
viewModel.resetFeatureOrder()
},
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt
index 0eaf062..cd82dec 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -25,6 +26,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material.icons.filled.DragIndicator
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
@@ -37,19 +39,29 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.boundsInRoot
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.compose.ui.zIndex
import com.frogobox.appkeyboard.R
import com.frogobox.appkeyboard.model.KeyboardFeatureModel
import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme
@@ -70,12 +82,19 @@ fun ToggleScreen(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
onMoveFeature: ((fromIndex: Int, toIndex: Int) -> Unit)? = null,
+ onSwapFeature: ((fromIndex: Int, toIndex: Int) -> Unit)? = null,
onResetOrder: (() -> Unit)? = null,
alwaysShowFeatureId: String? = null,
onToggleAlwaysShow: ((String) -> Unit)? = null
) {
var selectedTab by remember { mutableStateOf(ToggleFilterTab.ALL) }
+ var draggedFeatureId by remember { mutableStateOf(null) }
+ var dragOffset by remember { mutableStateOf(Offset.Zero) }
+ var hoveredFeatureId by remember { mutableStateOf(null) }
+ val itemBounds = remember { mutableStateMapOf() }
+ val haptic = LocalHapticFeedback.current
+
val activeCount = features.count { getToggleState(it.id) }
val disabledCount = features.size - activeCount
@@ -164,6 +183,8 @@ fun ToggleScreen(
val currentIndex = features.indexOf(feature)
val canMoveUp = onMoveFeature != null && currentIndex > 0
val canMoveDown = onMoveFeature != null && currentIndex in 0 until (features.size - 1)
+ val isBeingDragged = (draggedFeatureId == feature.id)
+ val isHoveredTarget = (hoveredFeatureId == feature.id)
ToggleFeatureCard(
feature = feature,
@@ -180,7 +201,60 @@ fun ToggleScreen(
onMoveFeature?.invoke(currentIndex, currentIndex + 1)
},
isAlwaysShow = (alwaysShowFeatureId == feature.id),
- onToggleAlwaysShow = onToggleAlwaysShow?.let { cb -> { cb(feature.id) } }
+ onToggleAlwaysShow = onToggleAlwaysShow?.let { cb -> { cb(feature.id) } },
+ isBeingDragged = isBeingDragged,
+ isHoveredTarget = isHoveredTarget,
+ dragOffset = if (isBeingDragged) dragOffset else Offset.Zero,
+ modifier = Modifier
+ .onGloballyPositioned { coordinates ->
+ itemBounds[feature.id] = coordinates.boundsInRoot()
+ }
+ .pointerInput(feature.id) {
+ detectDragGesturesAfterLongPress(
+ onDragStart = {
+ draggedFeatureId = feature.id
+ dragOffset = Offset.Zero
+ hoveredFeatureId = null
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ },
+ onDrag = { change, dragAmount ->
+ change.consume()
+ dragOffset += dragAmount
+ val sourceBounds = itemBounds[feature.id]
+ if (sourceBounds != null) {
+ val dragCenter = sourceBounds.center + dragOffset
+ val target = itemBounds.entries.firstOrNull { (id, bounds) ->
+ id != feature.id && bounds.contains(dragCenter)
+ }?.key
+ hoveredFeatureId = target
+ }
+ },
+ onDragEnd = {
+ val sourceId = draggedFeatureId
+ val targetId = hoveredFeatureId
+ if (sourceId != null && targetId != null && sourceId != targetId) {
+ val fromIndex = features.indexOfFirst { it.id == sourceId }
+ val toIndex = features.indexOfFirst { it.id == targetId }
+ if (fromIndex != -1 && toIndex != -1 && fromIndex != toIndex) {
+ if (onSwapFeature != null) {
+ onSwapFeature(fromIndex, toIndex)
+ } else {
+ onMoveFeature?.invoke(fromIndex, toIndex)
+ }
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
+ }
+ draggedFeatureId = null
+ dragOffset = Offset.Zero
+ hoveredFeatureId = null
+ },
+ onDragCancel = {
+ draggedFeatureId = null
+ dragOffset = Offset.Zero
+ hoveredFeatureId = null
+ }
+ )
+ }
)
}
}
@@ -194,8 +268,8 @@ private fun ToggleHeaderBanner(
activeCount: Int,
onEnableAll: () -> Unit,
onDisableAll: () -> Unit,
- onResetOrder: () -> Unit = {},
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ onResetOrder: () -> Unit = {}
) {
Card(
modifier = modifier.fillMaxWidth(),
@@ -230,7 +304,7 @@ private fun ToggleHeaderBanner(
)
Spacer(modifier = Modifier.height(2.dp))
Text(
- text = "Manage shortcuts displayed on your keyboard",
+ text = "Kelola dan seret (drag & drop) untuk ubah urutan shortcut keyboard",
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp
),
@@ -400,7 +474,10 @@ private fun ToggleFeatureCard(
onMoveUp: () -> Unit = {},
onMoveDown: () -> Unit = {},
isAlwaysShow: Boolean = false,
- onToggleAlwaysShow: (() -> Unit)? = null
+ onToggleAlwaysShow: (() -> Unit)? = null,
+ isBeingDragged: Boolean = false,
+ isHoveredTarget: Boolean = false,
+ dragOffset: Offset = Offset.Zero
) {
val primaryColor = MaterialTheme.colorScheme.primary
val surfaceColor = MaterialTheme.colorScheme.surface
@@ -408,24 +485,41 @@ private fun ToggleFeatureCard(
Card(
modifier = modifier
+ .zIndex(if (isBeingDragged) 10f else if (isHoveredTarget) 5f else 1f)
+ .graphicsLayer {
+ translationX = dragOffset.x
+ translationY = dragOffset.y
+ scaleX = if (isBeingDragged) 1.05f else 1.0f
+ scaleY = if (isBeingDragged) 1.05f else 1.0f
+ shadowElevation = if (isBeingDragged) 16f else 0f
+ }
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable { onCheckedChange(!isChecked) },
shape = RoundedCornerShape(12.dp),
- elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ elevation = CardDefaults.cardElevation(
+ defaultElevation = if (isBeingDragged) 8.dp else 0.dp
+ ),
border = BorderStroke(
- width = if (isChecked) 1.5.dp else 1.dp,
- color = if (isChecked) {
- primaryColor.copy(alpha = 0.5f)
- } else {
- outlineVariant
+ width = when {
+ isHoveredTarget -> 2.dp
+ isBeingDragged -> 2.dp
+ isChecked -> 1.5.dp
+ else -> 1.dp
+ },
+ color = when {
+ isHoveredTarget -> FrogoStatusSuccess
+ isBeingDragged -> primaryColor
+ isChecked -> primaryColor.copy(alpha = 0.5f)
+ else -> outlineVariant
}
),
colors = CardDefaults.cardColors(
- containerColor = if (isChecked) {
- primaryColor.copy(alpha = 0.06f)
- } else {
- surfaceColor
+ containerColor = when {
+ isHoveredTarget -> FrogoStatusSuccess.copy(alpha = 0.08f)
+ isBeingDragged -> primaryColor.copy(alpha = 0.12f)
+ isChecked -> primaryColor.copy(alpha = 0.06f)
+ else -> surfaceColor
}
)
) {
@@ -434,7 +528,7 @@ private fun ToggleFeatureCard(
.fillMaxWidth()
.padding(14.dp)
) {
- // Top row with Icon Container and Switch
+ // Top row with Icon Container, Drag Indicator, and Switch
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -460,17 +554,29 @@ private fun ToggleFeatureCard(
)
}
- Switch(
- checked = isChecked,
- onCheckedChange = onCheckedChange,
- colors = SwitchDefaults.colors(
- checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
- checkedTrackColor = primaryColor,
- uncheckedThumbColor = MaterialTheme.colorScheme.outline,
- uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
- uncheckedBorderColor = Color.Transparent
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.DragIndicator,
+ contentDescription = "Tahan & seret untuk tukar posisi",
+ tint = if (isBeingDragged) primaryColor else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ modifier = Modifier.size(20.dp)
+ )
+
+ Switch(
+ checked = isChecked,
+ onCheckedChange = onCheckedChange,
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
+ checkedTrackColor = primaryColor,
+ uncheckedThumbColor = MaterialTheme.colorScheme.outline,
+ uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
+ uncheckedBorderColor = Color.Transparent
+ )
)
- )
+ }
}
Spacer(modifier = Modifier.height(14.dp))
diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleViewModel.kt
index 69037e8..8596d3a 100644
--- a/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleViewModel.kt
+++ b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleViewModel.kt
@@ -30,6 +30,10 @@ class ToggleViewModel @Inject constructor(
fun switchToggle(key: String, value: Boolean) {
pref.savePrefBoolean(key, value)
+ if (key == com.frogobox.appkeyboard.model.KeyboardFeatureType.GESTURE_SWIPE.id) {
+ keyboardUtil.setGestureTypingEnabled(value)
+ keyboardUtil.setGestureSwipeShortcutsEnabled(value)
+ }
}
fun getSwitchToggle(key: String): Boolean {
diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/AiTextTransformer.kt b/app/src/main/java/com/frogobox/appkeyboard/util/AiTextTransformer.kt
new file mode 100644
index 0000000..7bc7eca
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/util/AiTextTransformer.kt
@@ -0,0 +1,243 @@
+package com.frogobox.appkeyboard.util
+
+import java.util.Locale
+
+/**
+ * On-device AI Writing and Tone Transformer for smart text polishing and stylistic adjustments.
+ * Operates 100% locally with zero network latency and privacy isolation.
+ */
+object AiTextTransformer {
+
+ enum class AiToneStyle(val title: String, val chipLabel: String, val iconEmoji: String) {
+ POLISH("Rapikan & Ejaan", "Rapikan", "✨"),
+ FORMAL("Formal & Profesional", "Formal", "👔"),
+ POLITE("Ramah & Sopan", "Sopan", "🤝"),
+ CASUAL("Kasual & Santai", "Kasual", "😊"),
+ SLANG("Gaul & Tren", "Gaul", "😎"),
+ SUMMARIZE("Ringkas & Inti", "Ringkas", "📝")
+ }
+
+ private val TYPO_NORMALIZATION_MAP = linkedMapOf(
+ "\\bsy\\b" to "saya",
+ "\\baq\\b" to "aku",
+ "\\byg\\b" to "yang",
+ "\\byng\\b" to "yang",
+ "\\bdgn\\b" to "dengan",
+ "\\bbgt\\b" to "banget",
+ "\\bbngt\\b" to "banget",
+ "\\bgk\\b" to "tidak",
+ "\\bgak\\b" to "tidak",
+ "\\bga\\b" to "tidak",
+ "\\bngga\\b" to "tidak",
+ "\\bnggak\\b" to "tidak",
+ "\\budh\\b" to "sudah",
+ "\\bsdh\\b" to "sudah",
+ "\\bblm\\b" to "belum",
+ "\\bklo\\b" to "kalau",
+ "\\bkalo\\b" to "kalau",
+ "\\bgpp\\b" to "tidak apa-apa",
+ "\\btks\\b" to "terima kasih",
+ "\\bmksih\\b" to "terima kasih",
+ "\\bmakasih\\b" to "terima kasih",
+ "\\bthx\\b" to "thanks",
+ "\\bty\\b" to "thank you",
+ "\\bpls\\b" to "please",
+ "\\bplz\\b" to "please",
+ "\\bu\\b" to "you",
+ "\\br\\b" to "are"
+ )
+
+ private val FORMAL_DICTIONARY = linkedMapOf(
+ "\\b(gua|gue|gw|aku)\\b" to "saya",
+ "\\b(lu|lo|elu|kamu|km)\\b" to "Anda",
+ "\\b(nggak|gak|gk|ga)\\b" to "tidak",
+ "\\b(udah|udh)\\b" to "sudah",
+ "\\b(banget|bgt)\\b" to "sangat",
+ "\\b(kalo|klo)\\b" to "apabila",
+ "\\bgimana\\b" to "bagaimana",
+ "\\b(kenapa|kenape)\\b" to "mengapa",
+ "\\bcuma\\b" to "hanya",
+ "\\bbikin\\b" to "membuat",
+ "\\bngasih\\b" to "memberikan",
+ "\\b(?
+ Regex(pattern, RegexOption.IGNORE_CASE) to replacement
+ }
+
+ private val PRECOMPILED_FORMAL_MAP = FORMAL_DICTIONARY.map { (pattern, replacement) ->
+ Regex(pattern, RegexOption.IGNORE_CASE) to replacement
+ }
+
+ private val PRECOMPILED_CASUAL_MAP = CASUAL_DICTIONARY.map { (pattern, replacement) ->
+ Regex(pattern, RegexOption.IGNORE_CASE) to replacement
+ }
+
+ private val PRECOMPILED_SLANG_MAP = SLANG_DICTIONARY.map { (pattern, replacement) ->
+ Regex(pattern, RegexOption.IGNORE_CASE) to replacement
+ }
+
+ private val PRECOMPILED_FILLER_REGEXES = FILLER_WORDS.map { filler ->
+ Regex("\\b$filler\\b,?\\s*", RegexOption.IGNORE_CASE)
+ }
+
+ private val REGEX_MULTIPLE_SPACES = Regex("\\s+")
+ private val REGEX_SPACE_BEFORE_PUNCT = Regex("\\s+([,.!?:;])")
+ private val REGEX_PUNCT_WITHOUT_SPACE = Regex("([,.!?:;])([^\\s0-9])")
+ private val REGEX_SENTENCE_BOUNDARY = Regex("(?<=[.!?])\\s+")
+
+ /**
+ * Transforms input text based on the requested [AiToneStyle].
+ */
+ fun transform(text: String, tone: AiToneStyle): String {
+ val trimmed = text.trim()
+ if (trimmed.isEmpty()) return ""
+
+ return when (tone) {
+ AiToneStyle.POLISH -> polishText(trimmed)
+ AiToneStyle.FORMAL -> formalizeText(trimmed)
+ AiToneStyle.POLITE -> politeText(trimmed)
+ AiToneStyle.CASUAL -> casualizeText(trimmed)
+ AiToneStyle.SLANG -> slangText(trimmed)
+ AiToneStyle.SUMMARIZE -> summarizeText(trimmed)
+ }
+ }
+
+ private fun polishText(text: String): String {
+ var result = normalizeSpacingAndPunctuation(text)
+ for ((regex, replacement) in PRECOMPILED_TYPO_MAP) {
+ result = result.replace(regex, replacement)
+ }
+ return capitalizeSentences(result)
+ }
+
+ private fun formalizeText(text: String): String {
+ var result = polishText(text)
+ for ((regex, replacement) in PRECOMPILED_FORMAL_MAP) {
+ result = result.replace(regex, replacement)
+ }
+ val capitalized = capitalizeSentences(result)
+ return if (!capitalized.endsWith(".") && !capitalized.endsWith("!") && !capitalized.endsWith("?")) {
+ "$capitalized."
+ } else {
+ capitalized
+ }
+ }
+
+ private fun politeText(text: String): String {
+ val formalized = formalizeText(text)
+ val lower = formalized.lowercase(Locale.ROOT)
+ val prefix = when {
+ lower.startsWith("tolong") || lower.startsWith("mohon") -> ""
+ lower.contains("kirim") || lower.contains("bantu") || lower.contains("minta") -> "Mohon bantuannya, "
+ else -> "Dengan hormat, "
+ }
+ val suffix = when {
+ lower.contains("terima kasih") -> ""
+ else -> " Terima kasih banyak."
+ }
+ return "$prefix$formalized$suffix".trim()
+ }
+
+ private fun casualizeText(text: String): String {
+ var result = normalizeSpacingAndPunctuation(text)
+ for ((regex, replacement) in PRECOMPILED_CASUAL_MAP) {
+ result = result.replace(regex, replacement)
+ }
+ val capitalized = capitalizeSentences(result)
+ return if (!capitalized.endsWith("!") && !capitalized.endsWith("?") && !capitalized.endsWith(".")) {
+ "$capitalized ya!"
+ } else {
+ capitalized
+ }
+ }
+
+ private fun slangText(text: String): String {
+ var result = normalizeSpacingAndPunctuation(text)
+ for ((regex, replacement) in PRECOMPILED_SLANG_MAP) {
+ result = result.replace(regex, replacement)
+ }
+ val capitalized = capitalizeSentences(result)
+ return if (!capitalized.endsWith("🔥") && !capitalized.endsWith("!")) {
+ "$capitalized 🔥"
+ } else {
+ capitalized
+ }
+ }
+
+ private fun summarizeText(text: String): String {
+ var cleaned = text
+ for (fillerRegex in PRECOMPILED_FILLER_REGEXES) {
+ cleaned = cleaned.replace(fillerRegex, "")
+ }
+ val sentences = cleaned.split(REGEX_SENTENCE_BOUNDARY)
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+
+ if (sentences.size <= 1) {
+ return "• " + capitalizeSentences(cleaned.trim())
+ }
+
+ return sentences.take(3).joinToString("\n") { "• " + capitalizeSentences(it) }
+ }
+
+ private fun normalizeSpacingAndPunctuation(text: String): String {
+ var result = text.replace(REGEX_MULTIPLE_SPACES, " ")
+ result = result.replace(REGEX_SPACE_BEFORE_PUNCT, "$1")
+ result = result.replace(REGEX_PUNCT_WITHOUT_SPACE, "$1 $2")
+ return result.trim()
+ }
+
+ private fun capitalizeSentences(text: String): String {
+ if (text.isEmpty()) return text
+ val sentences = text.split(REGEX_SENTENCE_BOUNDARY)
+ return sentences.joinToString(" ") { sentence ->
+ if (sentence.isNotEmpty()) {
+ sentence.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() }
+ } else {
+ sentence
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/ClipboardSecurityGuard.kt b/app/src/main/java/com/frogobox/appkeyboard/util/ClipboardSecurityGuard.kt
new file mode 100644
index 0000000..6854501
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/util/ClipboardSecurityGuard.kt
@@ -0,0 +1,87 @@
+package com.frogobox.appkeyboard.util
+
+import com.frogobox.appkeyboard.model.ClipboardItem
+import java.util.regex.Pattern
+
+/**
+ * Security and privacy guard for clipboard data hygiene:
+ * - Detects sensitive authentication codes (OTPs), credit cards, and credential tokens.
+ * - Manages automatic purging of stale unpinned clips after the expiration window (15 minutes).
+ * - Masks sensitive snippets to prevent shoulder-surfing and accidental disclosure.
+ */
+object ClipboardSecurityGuard {
+
+ const val DEFAULT_PURGE_WINDOW_MS = 15 * 60 * 1000L // 15 minutes
+
+ private val OTP_PATTERN = Pattern.compile("(?i)\\b(?:otp|kode|code|pin|verifikasi)?[:\\s-]*(\\d{4,8})\\b")
+ private val CREDIT_CARD_PATTERN = Pattern.compile("\\b(?:\\d{4}[ -]?){3}\\d{4}\\b")
+ private val TOKEN_PATTERN = Pattern.compile("(?i)\\b(?:bearer|token|apikey|secret|password|sandi)[:\\s=]+[a-zA-Z0-9_\\-\\.]+\\b")
+
+ /**
+ * Inspects text for sensitive information patterns (OTPs, credit cards, secret keys).
+ */
+ fun isSensitiveContent(text: String): Boolean {
+ if (text.isBlank()) return false
+ val trimmed = text.trim()
+ if (CREDIT_CARD_PATTERN.matcher(trimmed).find()) return true
+ if (TOKEN_PATTERN.matcher(trimmed).find()) return true
+
+ // Standalone 4 to 8 digit code often copied from SMS/WhatsApp OTP
+ if (trimmed.length in 4..8 && trimmed.all { it.isDigit() }) return true
+ if (OTP_PATTERN.matcher(trimmed).find() && (trimmed.contains("otp", ignoreCase = true) || trimmed.contains("kode", ignoreCase = true))) {
+ return true
+ }
+
+ return false
+ }
+
+ /**
+ * Filters out expired temporary clipboard items while strictly preserving pinned clips.
+ *
+ * @param items List of clipboard items to evaluate.
+ * @param currentTime Current timestamp in milliseconds.
+ * @param maxAgeMs Maximum age in milliseconds for unpinned items before purging (default 15 mins).
+ * @return Filtered list containing only valid, non-expired items.
+ */
+ fun filterExpiredClips(
+ items: List,
+ currentTime: Long,
+ maxAgeMs: Long = DEFAULT_PURGE_WINDOW_MS
+ ): List {
+ return items.filter { item ->
+ // Rule: Pinned items are NEVER purged
+ if (item.isPinned) {
+ true
+ } else {
+ val age = currentTime - item.timestamp
+ age in 0..maxAgeMs
+ }
+ }
+ }
+
+ /**
+ * Obfuscates sensitive portions of a string for safe display.
+ */
+ fun maskSensitiveText(text: String): String {
+ if (!isSensitiveContent(text)) return text
+
+ val trimmed = text.trim()
+ return if (trimmed.length in 4..8 && trimmed.all { it.isDigit() }) {
+ // Mask OTP: e.g. "123456" -> "****56"
+ val visibleCount = 2.coerceAtMost(trimmed.length)
+ val maskedPrefix = "*".repeat(trimmed.length - visibleCount)
+ maskedPrefix + trimmed.takeLast(visibleCount)
+ } else if (CREDIT_CARD_PATTERN.matcher(trimmed).find()) {
+ // Mask card: "1234-5678-9012-3456" -> "****-****-****-3456"
+ val cleanDigits = trimmed.filter { it.isDigit() }
+ if (cleanDigits.length >= 12) {
+ "****-****-****-${cleanDigits.takeLast(4)}"
+ } else {
+ "****"
+ }
+ } else {
+ // Generic token masking
+ "🔒 [Data Sensitif Dilindungi]"
+ }
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/GestureTypingHelper.kt b/app/src/main/java/com/frogobox/appkeyboard/util/GestureTypingHelper.kt
new file mode 100644
index 0000000..f0c607e
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/util/GestureTypingHelper.kt
@@ -0,0 +1,47 @@
+package com.frogobox.appkeyboard.util
+
+typealias PathPoint = com.frogobox.libkeyboard.util.GestureTypingHelper.PathPoint
+typealias KeyBoundingBox = com.frogobox.libkeyboard.util.GestureTypingHelper.KeyBoundingBox
+
+/**
+ * Compatibility object delegating to [com.frogobox.libkeyboard.util.GestureTypingHelper]
+ * for full backward compatibility across the app module and unit tests.
+ */
+object GestureTypingHelper {
+
+ typealias PathPoint = com.frogobox.libkeyboard.util.GestureTypingHelper.PathPoint
+ typealias KeyBoundingBox = com.frogobox.libkeyboard.util.GestureTypingHelper.KeyBoundingBox
+
+ val DEFAULT_VOCABULARY = com.frogobox.libkeyboard.util.GestureTypingHelper.DEFAULT_VOCABULARY
+
+ fun registerWord(word: String) {
+ com.frogobox.libkeyboard.util.GestureTypingHelper.registerWord(word)
+ }
+
+ fun addVocabulary(words: List) {
+ com.frogobox.libkeyboard.util.GestureTypingHelper.addVocabulary(words)
+ }
+
+ fun resolveTrajectoryToChars(
+ points: List,
+ keys: List
+ ): String {
+ return com.frogobox.libkeyboard.util.GestureTypingHelper.resolveTrajectoryToChars(points, keys)
+ }
+
+ fun matchGesture(
+ rawChars: String,
+ candidateVocabulary: List = DEFAULT_VOCABULARY,
+ maxResults: Int = 3
+ ): List {
+ return com.frogobox.libkeyboard.util.GestureTypingHelper.matchGesture(rawChars, candidateVocabulary, maxResults)
+ }
+
+ fun compressConsecutiveDuplicates(word: String): String {
+ return com.frogobox.libkeyboard.util.GestureTypingHelper.compressConsecutiveDuplicates(word)
+ }
+
+ fun clearDynamicVocabulary() {
+ com.frogobox.libkeyboard.util.GestureTypingHelper.clearDynamicVocabulary()
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/HapticFeedbackHelper.kt b/app/src/main/java/com/frogobox/appkeyboard/util/HapticFeedbackHelper.kt
new file mode 100644
index 0000000..9b3f22c
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/util/HapticFeedbackHelper.kt
@@ -0,0 +1,68 @@
+package com.frogobox.appkeyboard.util
+
+import android.content.Context
+import android.os.Build
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager
+
+/**
+ * Haptic tactile intensity manager supporting subtle, medium, and firm tactile feedback.
+ * Adheres strictly to modern Android vibration APIs across API levels.
+ */
+object HapticFeedbackHelper {
+
+ enum class HapticIntensity(val durationMs: Long, val displayName: String) {
+ OFF(0L, "Nonaktif"),
+ SUBTLE(15L, "Halus (Subtle)"),
+ MEDIUM(30L, "Sedang (Medium)"),
+ STRONG(55L, "Kuat (Strong)")
+ }
+
+ /**
+ * Triggers tactile feedback conforming to the specified [HapticIntensity].
+ */
+ fun performHaptic(context: Context, intensity: HapticIntensity) {
+ if (intensity == HapticIntensity.OFF || intensity.durationMs <= 0) return
+
+ val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ context.getSystemService(VibratorManager::class.java)?.defaultVibrator
+ } else {
+ context.getSystemService(Vibrator::class.java)
+ } ?: return
+
+ if (!vibrator.hasVibrator()) return
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val effect = VibrationEffect.createOneShot(intensity.durationMs, VibrationEffect.DEFAULT_AMPLITUDE)
+ vibrator.vibrate(effect)
+ } else {
+ vibrator.vibrate(intensity.durationMs)
+ }
+ }
+
+ /**
+ * Triggers custom tactile feedback with a specific duration in milliseconds.
+ */
+ fun performHapticMs(context: Context, durationMs: Long) {
+ if (durationMs <= 0) return
+ val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ context.getSystemService(VibratorManager::class.java)?.defaultVibrator
+ } else {
+ context.getSystemService(Vibrator::class.java)
+ } ?: return
+
+ if (!vibrator.hasVibrator()) return
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val effect = VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE)
+ vibrator.vibrate(effect)
+ } else {
+ vibrator.vibrate(durationMs)
+ }
+ }
+
+ fun fromString(value: String): HapticIntensity {
+ return HapticIntensity.entries.firstOrNull { it.name.equals(value, ignoreCase = true) } ?: HapticIntensity.MEDIUM
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/HardwareKeyboardHelper.kt b/app/src/main/java/com/frogobox/appkeyboard/util/HardwareKeyboardHelper.kt
new file mode 100644
index 0000000..cd19030
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/util/HardwareKeyboardHelper.kt
@@ -0,0 +1,97 @@
+package com.frogobox.appkeyboard.util
+
+import android.view.KeyEvent
+import android.view.inputmethod.InputConnection
+
+/**
+ * Handles physical/Bluetooth keyboard interoperability when an external keyboard
+ * (USB, Bluetooth, Folio cover, or Chromebook) is connected to the Android device.
+ * Translates standard desktop productivity shortcuts (Ctrl+C, Ctrl+V, Ctrl+A, Ctrl+X, Ctrl+Z)
+ * into InputConnection actions without requiring software keyboard interaction.
+ */
+object HardwareKeyboardHelper {
+
+ /**
+ * Checks if the given key code and ctrl state map to a supported desktop shortcut.
+ */
+ fun isShortcutAction(keyCode: Int, isCtrl: Boolean): Boolean {
+ if (!isCtrl) return false
+ return when (keyCode) {
+ KeyEvent.KEYCODE_C,
+ KeyEvent.KEYCODE_V,
+ KeyEvent.KEYCODE_X,
+ KeyEvent.KEYCODE_A,
+ KeyEvent.KEYCODE_Z -> true
+ else -> false
+ }
+ }
+
+ /**
+ * Checks if the key code corresponds to a hardware navigation key.
+ */
+ fun isNavigationKey(keyCode: Int): Boolean {
+ return when (keyCode) {
+ KeyEvent.KEYCODE_DPAD_LEFT,
+ KeyEvent.KEYCODE_DPAD_RIGHT,
+ KeyEvent.KEYCODE_FORWARD_DEL -> true
+ else -> false
+ }
+ }
+
+ /**
+ * Intercepts and executes hardware key events.
+ *
+ * @param event The [KeyEvent] received by the InputMethodService.
+ * @param inputConnection The active [InputConnection] bound to the focused field.
+ * @return True if the key event was intercepted and handled; false to allow default OS handling.
+ */
+ fun handleHardwareKeyEvent(event: KeyEvent, inputConnection: InputConnection?): Boolean {
+ if (inputConnection == null) return false
+ if (event.action != KeyEvent.ACTION_DOWN) return false
+
+ // 1. Desktop Ctrl+Key combinations
+ if (event.isCtrlPressed) {
+ when (event.keyCode) {
+ KeyEvent.KEYCODE_C -> {
+ return inputConnection.performContextMenuAction(android.R.id.copy)
+ }
+ KeyEvent.KEYCODE_V -> {
+ return inputConnection.performContextMenuAction(android.R.id.paste)
+ }
+ KeyEvent.KEYCODE_X -> {
+ return inputConnection.performContextMenuAction(android.R.id.cut)
+ }
+ KeyEvent.KEYCODE_A -> {
+ return inputConnection.performContextMenuAction(android.R.id.selectAll)
+ }
+ KeyEvent.KEYCODE_Z -> {
+ return inputConnection.performContextMenuAction(android.R.id.undo)
+ }
+ }
+ }
+
+ // 2. Navigation & Arrow key handling
+ when (event.keyCode) {
+ KeyEvent.KEYCODE_DPAD_LEFT -> {
+ inputConnection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT))
+ inputConnection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_LEFT))
+ return true
+ }
+ KeyEvent.KEYCODE_DPAD_RIGHT -> {
+ inputConnection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT))
+ inputConnection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_RIGHT))
+ return true
+ }
+ KeyEvent.KEYCODE_FORWARD_DEL -> {
+ // Physical Delete key (delete character ahead of cursor)
+ return inputConnection.deleteSurroundingText(0, 1)
+ }
+ KeyEvent.KEYCODE_ESCAPE -> {
+ // Physical Escape key closes soft keyboard if visible
+ return false
+ }
+ }
+
+ return false
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/InlineTextExpanderHelper.kt b/app/src/main/java/com/frogobox/appkeyboard/util/InlineTextExpanderHelper.kt
new file mode 100644
index 0000000..b5aeba1
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/util/InlineTextExpanderHelper.kt
@@ -0,0 +1,95 @@
+package com.frogobox.appkeyboard.util
+
+import com.frogobox.appkeyboard.model.AutoTextEntity
+
+/**
+ * High-speed on-device text shortcut expansion engine.
+ * Detects shorthand triggers typed by the user and provides instant full-text expansions
+ * surfaced directly in the keyboard candidate strip.
+ */
+object InlineTextExpanderHelper {
+
+ data class TextExpansionMatch(
+ val trigger: String,
+ val expandedText: String,
+ val label: String
+ )
+
+ // Built-in standard quick expansion library (Indonesian & English)
+ private val DEFAULT_SNIPPETS = mapOf(
+ "otw" to TextExpansionMatch("otw", "On the way!", "OTW"),
+ "omw" to TextExpansionMatch("omw", "On my way!", "OMW"),
+ "brb" to TextExpansionMatch("brb", "Be right back", "BRB"),
+ "thx" to TextExpansionMatch("thx", "Terima kasih banyak!", "Thanks"),
+ "ty" to TextExpansionMatch("ty", "Thank you very much!", "Thank You"),
+ "gws" to TextExpansionMatch("gws", "Semoga lekas sembuh! (Get well soon)", "GWS"),
+ "asap" to TextExpansionMatch("asap", "As soon as possible", "ASAP"),
+ "rek" to TextExpansionMatch("rek", "Silakan transfer ke No. Rekening: BCA / Mandiri an. Pelanggan", "Rekening"),
+ "!alamat" to TextExpansionMatch("!alamat", "Alamat: Jl. Sudirman No. 45, Jakarta Pusat", "Alamat"),
+ "!email" to TextExpansionMatch("!email", "email.pengguna@example.com", "Email"),
+ "siap" to TextExpansionMatch("siap", "Siap, segera kami proses ya!", "Siap Proses"),
+ "makasih" to TextExpansionMatch("makasih", "Terima kasih banyak atas kerjasamanya!", "Terima Kasih")
+ )
+
+ /**
+ * Searches for an expansion match for the currently typed word.
+ *
+ * @param word The current word fragment before the cursor.
+ * @param customAutoTexts Custom AutoText database records created by user.
+ * @return [TextExpansionMatch] if found, or null if no expansion matches.
+ */
+ fun findExpansion(
+ word: String,
+ customAutoTexts: List = emptyList()
+ ): TextExpansionMatch? {
+ val trimmed = word.trim()
+ if (trimmed.isEmpty()) return null
+
+ val lower = trimmed.lowercase()
+
+ // 1. Check custom user AutoText database first (highest priority)
+ val customMatch = customAutoTexts.firstOrNull {
+ it.title.equals(trimmed, ignoreCase = true) ||
+ it.title.equals(lower, ignoreCase = true)
+ }
+ if (customMatch != null) {
+ return TextExpansionMatch(
+ trigger = customMatch.title,
+ expandedText = customMatch.body,
+ label = customMatch.title
+ )
+ }
+
+ // 2. Check built-in quick snippets
+ val defaultMatch = DEFAULT_SNIPPETS[lower] ?: DEFAULT_SNIPPETS[trimmed]
+ if (defaultMatch != null) {
+ return defaultMatch
+ }
+
+ // 3. Fallback: Check if AutoText body or title starts with the prefix trigger
+ val partialAutoText = customAutoTexts.firstOrNull {
+ it.title.startsWith(trimmed, ignoreCase = true) && trimmed.length >= 2
+ }
+ if (partialAutoText != null) {
+ return TextExpansionMatch(
+ trigger = trimmed,
+ expandedText = partialAutoText.body,
+ label = partialAutoText.title
+ )
+ }
+
+ return null
+ }
+
+ /**
+ * Formats a human-readable hero preview text for the candidate strip chip.
+ */
+ fun formatChipLabel(match: TextExpansionMatch): String {
+ val snippetPreview = if (match.expandedText.length > 24) {
+ "${match.expandedText.take(24)}…"
+ } else {
+ match.expandedText
+ }
+ return "⚡ ${match.label.uppercase()}: $snippetPreview"
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardAccessibilityHelper.kt b/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardAccessibilityHelper.kt
new file mode 100644
index 0000000..7de8093
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardAccessibilityHelper.kt
@@ -0,0 +1,78 @@
+package com.frogobox.appkeyboard.util
+
+import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard
+
+/**
+ * Accessibility and TalkBack helper providing phonetic spelling,
+ * descriptive semantic labels, and spoken announcements for screen reader users.
+ */
+object KeyboardAccessibilityHelper {
+
+ private val PHONETIC_MAP = mapOf(
+ 'A' to "Huruf A, Alfa",
+ 'B' to "Huruf B, Bravo",
+ 'C' to "Huruf C, Charlie",
+ 'D' to "Huruf D, Delta",
+ 'E' to "Huruf E, Echo",
+ 'F' to "Huruf F, Foxtrot",
+ 'G' to "Huruf G, Golf",
+ 'H' to "Huruf H, Hotel",
+ 'I' to "Huruf I, India",
+ 'J' to "Huruf J, Juliet",
+ 'K' to "Huruf K, Kilo",
+ 'L' to "Huruf L, Lima",
+ 'M' to "Huruf M, Mike",
+ 'N' to "Huruf N, November",
+ 'O' to "Huruf O, Oscar",
+ 'P' to "Huruf P, Papa",
+ 'Q' to "Huruf Q, Quebec",
+ 'R' to "Huruf R, Romeo",
+ 'S' to "Huruf S, Sierra",
+ 'T' to "Huruf T, Tango",
+ 'U' to "Huruf U, Uniform",
+ 'V' to "Huruf V, Victor",
+ 'W' to "Huruf W, Whiskey",
+ 'X' to "Huruf X, X-ray",
+ 'Y' to "Huruf Y, Yankee",
+ 'Z' to "Huruf Z, Zulu"
+ )
+
+ /**
+ * Resolves a spoken accessibility description for a given character code.
+ */
+ fun getKeyDescription(code: Int, isShifted: Boolean = false): String {
+ return when (code) {
+ ItemMainKeyboard.KEYCODE_DELETE -> "Hapus karakter sebelumnya"
+ ItemMainKeyboard.KEYCODE_SHIFT -> if (isShifted) "Tombol Shift aktif" else "Tombol Shift nonaktif"
+ ItemMainKeyboard.KEYCODE_SPACE -> "Spasi"
+ ItemMainKeyboard.KEYCODE_ENTER -> "Kirim atau Baris Baru"
+ ItemMainKeyboard.KEYCODE_MODE_CHANGE -> "Beralih simbol atau angka"
+ ItemMainKeyboard.KEYCODE_EMOJI -> "Buka panel stiker dan emoji"
+ ItemMainKeyboard.KEYCODE_TAB -> "Tombol Tabulasi"
+ ItemMainKeyboard.KEYCODE_ARROW_LEFT -> "Kursor ke kiri"
+ ItemMainKeyboard.KEYCODE_ARROW_RIGHT -> "Kursor ke kanan"
+ ItemMainKeyboard.KEYCODE_ARROW_UP -> "Kursor ke atas"
+ ItemMainKeyboard.KEYCODE_ARROW_DOWN -> "Kursor ke bawah"
+ else -> {
+ if (code <= 0) return ""
+ val ch = code.toChar()
+ val upper = ch.uppercaseChar()
+ PHONETIC_MAP[upper] ?: "Karakter $ch"
+ }
+ }
+ }
+
+ /**
+ * Generates accessible announcements for layout and mode changes.
+ */
+ fun getModeAnnouncement(mode: String): String {
+ return when (mode.uppercase()) {
+ "SPLIT" -> "Mode keyboard terbelah aktif untuk dua jempol"
+ "LEFT" -> "Mode satu tangan sisi kiri aktif"
+ "RIGHT" -> "Mode satu tangan sisi kanan aktif"
+ "OFF" -> "Mode keyboard layar penuh aktif"
+ "INCOGNITO" -> "Mode privasi penyamaran aktif. Riwayat dan saran dinonaktifkan"
+ else -> "Mode $mode"
+ }
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardBackupRestoreHelper.kt b/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardBackupRestoreHelper.kt
new file mode 100644
index 0000000..bad892c
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardBackupRestoreHelper.kt
@@ -0,0 +1,125 @@
+package com.frogobox.appkeyboard.util
+
+import com.frogobox.appkeyboard.model.AutoTextEntity
+import com.frogobox.appkeyboard.model.ClipboardItem
+import org.json.JSONArray
+import org.json.JSONObject
+
+/**
+ * Backup and restore helper for user AutoText, pinned clipboard items, and keyboard settings.
+ * Ensures zero data corruption and strictly sanitizes exported data (excludes unpinned or sensitive clipboard items).
+ */
+object KeyboardBackupRestoreHelper {
+
+ const val CURRENT_BACKUP_VERSION = 1
+
+ data class AutoTextBackupItem(
+ val title: String,
+ val text: String,
+ val category: String = "Umum"
+ )
+
+ data class KeyboardBackupData(
+ val version: Int = CURRENT_BACKUP_VERSION,
+ val exportedAt: Long = System.currentTimeMillis(),
+ val autoTexts: List,
+ val pinnedClips: List,
+ val settings: Map = emptyMap()
+ )
+
+ /**
+ * Serializes user data into sanitized JSON.
+ * Only exports pinned, non-sensitive clipboard items.
+ */
+ fun exportToJson(
+ autoTexts: List,
+ clipboardItems: List,
+ settings: Map = emptyMap()
+ ): String {
+ val root = JSONObject()
+ root.put("version", CURRENT_BACKUP_VERSION)
+ root.put("exportedAt", System.currentTimeMillis())
+
+ // 1. AutoText items
+ val autoTextArray = JSONArray()
+ for (item in autoTexts) {
+ val obj = JSONObject()
+ obj.put("title", item.title)
+ obj.put("text", item.body)
+ obj.put("category", "Custom")
+ autoTextArray.put(obj)
+ }
+ root.put("autoTexts", autoTextArray)
+
+ // 2. Pinned Clipboard items (sanitize sensitive data)
+ val clipsArray = JSONArray()
+ val safeClips = clipboardItems.filter { it.isPinned && !ClipboardSecurityGuard.isSensitiveContent(it.text) }
+ for (clip in safeClips) {
+ clipsArray.put(clip.text)
+ }
+ root.put("pinnedClips", clipsArray)
+
+ // 3. Settings key-values
+ val settingsObj = JSONObject()
+ for ((key, value) in settings) {
+ settingsObj.put(key, value)
+ }
+ root.put("settings", settingsObj)
+
+ return root.toString(2)
+ }
+
+ /**
+ * Parses and validates incoming JSON string into typed [KeyboardBackupData].
+ */
+ fun importFromJson(jsonString: String): Result {
+ return runCatching {
+ val root = JSONObject(jsonString)
+ val version = root.optInt("version", CURRENT_BACKUP_VERSION)
+ val exportedAt = root.optLong("exportedAt", System.currentTimeMillis())
+
+ val autoTextList = mutableListOf()
+ val autoTextArray = root.optJSONArray("autoTexts")
+ if (autoTextArray != null) {
+ for (i in 0 until autoTextArray.length()) {
+ val obj = autoTextArray.getJSONObject(i)
+ val title = obj.optString("title", "")
+ val text = obj.optString("text", "")
+ val category = obj.optString("category", "Custom")
+ if (title.isNotBlank() && text.isNotBlank()) {
+ autoTextList.add(AutoTextBackupItem(title, text, category))
+ }
+ }
+ }
+
+ val pinnedClips = mutableListOf()
+ val clipsArray = root.optJSONArray("pinnedClips")
+ if (clipsArray != null) {
+ for (i in 0 until clipsArray.length()) {
+ val clip = clipsArray.getString(i)
+ if (clip.isNotBlank()) {
+ pinnedClips.add(clip)
+ }
+ }
+ }
+
+ val settingsMap = mutableMapOf()
+ val settingsObj = root.optJSONObject("settings")
+ if (settingsObj != null) {
+ val keys = settingsObj.keys()
+ while (keys.hasNext()) {
+ val key = keys.next()
+ settingsMap[key] = settingsObj.optString(key, "")
+ }
+ }
+
+ KeyboardBackupData(
+ version = version,
+ exportedAt = exportedAt,
+ autoTexts = autoTextList,
+ pinnedClips = pinnedClips,
+ settings = settingsMap
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardHeightHelper.kt b/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardHeightHelper.kt
new file mode 100644
index 0000000..460c977
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardHeightHelper.kt
@@ -0,0 +1,44 @@
+package com.frogobox.appkeyboard.util
+
+/**
+ * Ergonomic height resizer and bottom chin offset helper.
+ * Provides granular scaling factor presets and system gesture bar offset spacing.
+ */
+object KeyboardHeightHelper {
+
+ enum class HeightScale(val id: String, val displayName: String, val scaleFactor: Float) {
+ COMPACT("COMPACT", "Ringkas (85%)", 0.85f),
+ NORMAL("NORMAL", "Normal (100%)", 1.0f),
+ TALL("TALL", "Tinggi (115%)", 1.15f),
+ EXTRA_TALL("EXTRA_TALL", "Ekstra Tinggi (130%)", 1.30f);
+
+ companion object {
+ fun fromString(value: String): HeightScale {
+ return entries.firstOrNull { it.id.equals(value, ignoreCase = true) || it.name.equals(value, ignoreCase = true) }
+ ?: NORMAL
+ }
+ }
+ }
+
+ enum class ChinOffset(val id: String, val displayName: String, val offsetDp: Int) {
+ NONE("NONE", "0 dp", 0),
+ SMALL("SMALL", "8 dp", 8),
+ MEDIUM("MEDIUM", "16 dp", 16),
+ LARGE("LARGE", "24 dp", 24);
+
+ companion object {
+ fun fromString(value: String): ChinOffset {
+ return entries.firstOrNull { it.id.equals(value, ignoreCase = true) || it.name.equals(value, ignoreCase = true) }
+ ?: NONE
+ }
+ }
+ }
+
+ /**
+ * Calculates the total height in Dp for the keyboard given a base height, scale factor, and chin offset.
+ */
+ fun calculateEffectiveHeight(baseHeightDp: Float, scale: HeightScale, chinOffset: ChinOffset): Float {
+ val scaled = (baseHeightDp * scale.scaleFactor).coerceAtLeast(180f)
+ return scaled + chinOffset.offsetDp
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardPrivacyHelper.kt b/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardPrivacyHelper.kt
new file mode 100644
index 0000000..db1f462
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardPrivacyHelper.kt
@@ -0,0 +1,50 @@
+package com.frogobox.appkeyboard.util
+
+import android.text.InputType
+import android.view.inputmethod.EditorInfo
+import androidx.core.view.inputmethod.EditorInfoCompat
+
+/**
+ * Utility helper for detecting password fields, incognito mode, and private browsing sessions.
+ * Ensures zero credential leakage, suppresses predictive word generation, and isolates clipboard history.
+ */
+object KeyboardPrivacyHelper {
+
+ /**
+ * Checks if the given [EditorInfo] represents a password or PIN field.
+ */
+ fun isPasswordField(info: EditorInfo?): Boolean {
+ if (info == null) return false
+ val inputType = info.inputType
+ val inputClass = inputType and InputType.TYPE_MASK_CLASS
+ val variation = inputType and InputType.TYPE_MASK_VARIATION
+
+ val isTextPassword = inputClass == InputType.TYPE_CLASS_TEXT && (
+ variation == InputType.TYPE_TEXT_VARIATION_PASSWORD ||
+ variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD ||
+ variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD
+ )
+
+ val isNumberPassword = inputClass == InputType.TYPE_CLASS_NUMBER && (
+ variation == InputType.TYPE_NUMBER_VARIATION_PASSWORD
+ )
+
+ return isTextPassword || isNumberPassword
+ }
+
+ /**
+ * Checks if the target field requested no personalized learning (e.g. Incognito browser tab).
+ */
+ fun isNoPersonalizedLearning(info: EditorInfo?): Boolean {
+ if (info == null) return false
+ return (info.imeOptions and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING) != 0
+ }
+
+ /**
+ * Combines password field checks and no-personalized-learning checks to determine
+ * whether the keyboard should operate in Incognito / Privacy Guard mode.
+ */
+ fun isIncognitoOrPassword(info: EditorInfo?): Boolean {
+ return isPasswordField(info) || isNoPersonalizedLearning(info)
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardRichMediaHelper.kt b/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardRichMediaHelper.kt
new file mode 100644
index 0000000..3298dd6
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardRichMediaHelper.kt
@@ -0,0 +1,97 @@
+package com.frogobox.appkeyboard.util
+
+import android.content.ClipData
+import android.content.ClipDescription
+import android.content.ClipboardManager
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import androidx.core.view.inputmethod.EditorInfoCompat
+import androidx.core.view.inputmethod.InputConnectionCompat
+import androidx.core.view.inputmethod.InputContentInfoCompat
+
+/**
+ * Handles rich media (GIF, Sticker, Animated WebP) commit through Android InputConnectionCompat.
+ * Ensures graceful fallback to clipboard copy if the target app does not accept rich content.
+ */
+object KeyboardRichMediaHelper {
+
+ /**
+ * Checks if the active target [EditorInfo] supports the given MIME type.
+ */
+ fun isMimeTypeSupported(editorInfo: EditorInfo?, mimeType: String): Boolean {
+ if (editorInfo == null) return false
+ val supportedMimes = try {
+ editorInfo.contentMimeTypes ?: EditorInfoCompat.getContentMimeTypes(editorInfo)
+ } catch (_: Exception) {
+ editorInfo.contentMimeTypes ?: emptyArray()
+ }
+
+ return supportedMimes.any { supported ->
+ try {
+ ClipDescription.compareMimeTypes(supported, mimeType)
+ } catch (_: Exception) {
+ // Resilient fallback for JVM unit test environments where ClipDescription is unmocked
+ if (supported == "*/*") true
+ else if (supported.endsWith("/*")) {
+ val prefix = supported.substringBefore("/")
+ mimeType.startsWith("$prefix/")
+ } else {
+ supported.equals(mimeType, ignoreCase = true)
+ }
+ }
+ }
+ }
+
+ /**
+ * Commits rich media content (e.g. GIF, WebP, PNG) into the target input connection.
+ * Falls back to copying URI/content to clipboard if target app does not accept rich content.
+ */
+ fun commitRichContent(
+ context: Context,
+ inputConnection: InputConnection?,
+ editorInfo: EditorInfo?,
+ contentUri: Uri,
+ mimeType: String,
+ label: String = "Rich Content",
+ linkUri: Uri? = null
+ ): Boolean {
+ if (inputConnection == null || editorInfo == null) {
+ fallbackCopyToClipboard(context, contentUri, label)
+ return false
+ }
+
+ if (!isMimeTypeSupported(editorInfo, mimeType)) {
+ fallbackCopyToClipboard(context, contentUri, label)
+ return false
+ }
+
+ val description = ClipDescription(label, arrayOf(mimeType))
+ val inputContentInfo = InputContentInfoCompat(contentUri, description, linkUri)
+
+ val flags = InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION
+
+ val opts = Bundle()
+ val committed = InputConnectionCompat.commitContent(
+ inputConnection,
+ editorInfo,
+ inputContentInfo,
+ flags,
+ opts
+ )
+
+ if (!committed) {
+ fallbackCopyToClipboard(context, contentUri, label)
+ }
+ return committed
+ }
+
+ private fun fallbackCopyToClipboard(context: Context, contentUri: Uri, label: String) {
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
+ val clip = ClipData.newUri(context.contentResolver, label, contentUri)
+ clipboard?.setPrimaryClip(clip)
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardSetupWizardHelper.kt b/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardSetupWizardHelper.kt
new file mode 100644
index 0000000..c6e8fe5
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/util/KeyboardSetupWizardHelper.kt
@@ -0,0 +1,56 @@
+package com.frogobox.appkeyboard.util
+
+import android.content.Context
+import android.content.Intent
+import android.provider.Settings
+import android.view.inputmethod.InputMethodManager
+
+/**
+ * Helper for step-by-step keyboard activation and onboarding wizard.
+ * Checks whether the IME service is enabled in system settings and chosen as default.
+ */
+object KeyboardSetupWizardHelper {
+
+ enum class SetupStep(val stepNumber: Int, val title: String, val description: String) {
+ STEP_ENABLE(
+ 1,
+ "Aktifkan Frogo Keyboard",
+ "Buka Pengaturan Sistem Android dan centang Frogo Keyboard pada daftar keyboard yang tersedia."
+ ),
+ STEP_SELECT_DEFAULT(
+ 2,
+ "Pilih Keyboard Utama",
+ "Jadikan Frogo Keyboard sebagai metode input default untuk seluruh aplikasi."
+ ),
+ STEP_READY(
+ 3,
+ "Keyboard Siap Digunakan",
+ "Frogo Keyboard aktif dan siap digunakan. Anda dapat mencoba mengetik di kotak uji coba."
+ )
+ }
+
+ fun isImeEnabled(context: Context, packageName: String): Boolean {
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager ?: return false
+ val enabledKeyboards = imm.enabledInputMethodList
+ return enabledKeyboards.any { it.serviceInfo.packageName == packageName }
+ }
+
+ fun isImeDefault(context: Context, packageName: String): Boolean {
+ val defaultIme = Settings.Secure.getString(context.contentResolver, Settings.Secure.DEFAULT_INPUT_METHOD) ?: return false
+ return defaultIme.contains(packageName)
+ }
+
+ fun getSetupStep(isImeEnabled: Boolean, isImeDefault: Boolean): SetupStep {
+ return when {
+ !isImeEnabled -> SetupStep.STEP_ENABLE
+ !isImeDefault -> SetupStep.STEP_SELECT_DEFAULT
+ else -> SetupStep.STEP_READY
+ }
+ }
+
+ fun createEnableSettingsIntent(): Intent {
+ return Intent(Settings.ACTION_INPUT_METHOD_SETTINGS).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/SmartCalculatorHelper.kt b/app/src/main/java/com/frogobox/appkeyboard/util/SmartCalculatorHelper.kt
new file mode 100644
index 0000000..3134854
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/util/SmartCalculatorHelper.kt
@@ -0,0 +1,113 @@
+package com.frogobox.appkeyboard.util
+
+import java.text.DecimalFormat
+import java.text.DecimalFormatSymbols
+import java.util.Locale
+
+/**
+ * Inline math calculation helper for soft keyboard.
+ * Detects arithmetic expressions ending with '=' (e.g., "25000*4=") and computes the result.
+ */
+object SmartCalculatorHelper {
+
+ data class MathResult(
+ val expression: String,
+ val evaluatedValue: String,
+ val chipLabel: String
+ )
+
+ // Matches simple math sequences ending in '=' at the end of the text
+ private val MATH_REGEX = Regex("""([0-9]+(?:\.[0-9]+)?(?:\s*[\+\-\*\/]\s*[0-9]+(?:\.[0-9]+)?)+)\s*=\s*$""")
+
+ private val decimalFormat = DecimalFormat("#.######", DecimalFormatSymbols(Locale.US))
+
+ private fun formatNumber(value: Double): String {
+ return synchronized(decimalFormat) {
+ decimalFormat.format(value)
+ }
+ }
+
+ /**
+ * Evaluates text before cursor to detect if an arithmetic expression ending in '=' is present.
+ * Returns [MathResult] if valid, or null otherwise.
+ */
+ fun evaluateMath(textBeforeCursor: String?): MathResult? {
+ if (textBeforeCursor.isNullOrBlank()) return null
+
+ val match = MATH_REGEX.find(textBeforeCursor) ?: return null
+ val formula = match.groupValues[1].replace("\\s".toRegex(), "")
+ val fullMatch = match.value
+
+ val computed = calculateExpression(formula) ?: return null
+ val formattedResult = formatNumber(computed)
+
+ return MathResult(
+ expression = fullMatch,
+ evaluatedValue = formattedResult,
+ chipLabel = "= $formattedResult"
+ )
+ }
+
+ /**
+ * Evaluates standard arithmetic expressions with operator precedence (*, / before +, -).
+ */
+ fun calculateExpression(formula: String): Double? {
+ return try {
+ val tokens = tokenize(formula)
+ if (tokens.isEmpty()) return null
+
+ // Step 1: Process multiplication and division
+ val postMulDiv = mutableListOf()
+ var i = 0
+ while (i < tokens.size) {
+ val token = tokens[i]
+ if (token == '*' || token == '/') {
+ val prev = postMulDiv.removeAt(postMulDiv.size - 1) as Double
+ val next = tokens[i + 1] as Double
+ if (token == '/' && next == 0.0) return null // Division by zero
+ val res = if (token == '*') prev * next else prev / next
+ postMulDiv.add(res)
+ i += 2
+ } else {
+ postMulDiv.add(token)
+ i++
+ }
+ }
+
+ // Step 2: Process addition and subtraction
+ var total = postMulDiv[0] as Double
+ var j = 1
+ while (j < postMulDiv.size) {
+ val op = postMulDiv[j] as Char
+ val num = postMulDiv[j + 1] as Double
+ total = if (op == '+') total + num else total - num
+ j += 2
+ }
+
+ total
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun tokenize(formula: String): List {
+ val tokens = mutableListOf()
+ var currentNumber = StringBuilder()
+
+ for (ch in formula) {
+ if (ch.isDigit() || ch == '.') {
+ currentNumber.append(ch)
+ } else if (ch in "+-*/") {
+ if (currentNumber.isNotEmpty()) {
+ tokens.add(currentNumber.toString().toDouble())
+ currentNumber = StringBuilder()
+ }
+ tokens.add(ch)
+ }
+ }
+ if (currentNumber.isNotEmpty()) {
+ tokens.add(currentNumber.toString().toDouble())
+ }
+ return tokens
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/SmartPunctuationHelper.kt b/app/src/main/java/com/frogobox/appkeyboard/util/SmartPunctuationHelper.kt
new file mode 100644
index 0000000..7e3de0a
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/util/SmartPunctuationHelper.kt
@@ -0,0 +1,97 @@
+package com.frogobox.appkeyboard.util
+
+/**
+ * Intelligent helper managing smart punctuation behaviors:
+ * 1. Double-Space Period insertion with auto-capitalization triggering.
+ * 2. Sentence boundary detection for auto-capitalization.
+ * 3. Smart spacing after punctuation.
+ */
+object SmartPunctuationHelper {
+
+ const val DOUBLE_SPACE_TIMEOUT_MS = 500L
+
+ data class DoubleSpaceResult(
+ val isHandled: Boolean,
+ val textToDeleteLength: Int = 0,
+ val replacementText: String = "",
+ val shouldAutoShift: Boolean = false
+ )
+
+ /**
+ * Determines whether a spacebar keypress qualifies as a double-space period shortcut.
+ *
+ * @param lastSpaceTime Timestamp of the previous space keypress in milliseconds.
+ * @param currentTime Current system time in milliseconds.
+ * @param textBeforeCursor The text immediately preceding the cursor (typically 2-4 characters).
+ * @return [DoubleSpaceResult] containing replacement details and shift state.
+ */
+ fun checkDoubleSpacePeriod(
+ lastSpaceTime: Long,
+ currentTime: Long,
+ textBeforeCursor: CharSequence?
+ ): DoubleSpaceResult {
+ if (lastSpaceTime <= 0L) return DoubleSpaceResult(isHandled = false)
+ val elapsed = currentTime - lastSpaceTime
+ if (elapsed > DOUBLE_SPACE_TIMEOUT_MS) return DoubleSpaceResult(isHandled = false)
+
+ val text = textBeforeCursor?.toString() ?: return DoubleSpaceResult(isHandled = false)
+ if (text.isEmpty()) return DoubleSpaceResult(isHandled = false)
+
+ // Only convert if cursor is immediately preceded by a single space that is not already part of ". "
+ if (text.endsWith(" ") && !text.endsWith(". ") && !text.endsWith("? ") && !text.endsWith("! ") && !text.endsWith("\n ")) {
+ return DoubleSpaceResult(
+ isHandled = true,
+ textToDeleteLength = 1,
+ replacementText = ". ",
+ shouldAutoShift = true
+ )
+ }
+
+ return DoubleSpaceResult(isHandled = false)
+ }
+
+ /**
+ * Evaluates whether the cursor is situated at a sentence boundary where the next typed letter
+ * should automatically be capitalized.
+ *
+ * @param textBeforeCursor Text preceding the cursor (recommended 20-30 characters).
+ * @return True if next letter should be capitalized, false otherwise.
+ */
+ fun shouldAutoCapitalize(textBeforeCursor: CharSequence?): Boolean {
+ if (textBeforeCursor.isNullOrEmpty()) return true // Beginning of field / empty text
+
+ val text = textBeforeCursor.toString()
+ val trimmedEnd = text.trimEnd { it == ' ' || it == '\t' }
+
+ if (trimmedEnd.isEmpty()) return true // Only whitespace before cursor
+ if (text.endsWith("\n") || text.endsWith("\r\n")) return true // Newline boundary
+
+ // Check if preceding non-whitespace character is a sentence terminator
+ val lastChar = trimmedEnd.last()
+ if (lastChar == '.' || lastChar == '?' || lastChar == '!') {
+ // Must have at least one whitespace between the punctuation and the cursor position
+ return text.length > trimmedEnd.length
+ }
+
+ return false
+ }
+
+ /**
+ * Inspects whether a typed punctuation mark should trigger automatic single-space formatting.
+ *
+ * @param punctuationChar The character being typed (e.g., ',', '.', '!', '?').
+ * @param textBeforeCursor Text before cursor.
+ * @return True if a following space should automatically be appended.
+ */
+ fun shouldAutoSpaceAfterPunctuation(punctuationChar: Char, textBeforeCursor: CharSequence?): Boolean {
+ if (punctuationChar != ',' && punctuationChar != '.' && punctuationChar != '!' && punctuationChar != '?' && punctuationChar != ';' && punctuationChar != ':') {
+ return false
+ }
+ val text = textBeforeCursor?.toString() ?: ""
+ // Do not auto-space if inside digits (e.g. 3.14 or 10,000) or URL
+ if (text.isNotEmpty() && text.last().isDigit()) {
+ return false
+ }
+ return true
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/UserDictionaryHelper.kt b/app/src/main/java/com/frogobox/appkeyboard/util/UserDictionaryHelper.kt
new file mode 100644
index 0000000..5c25f16
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/util/UserDictionaryHelper.kt
@@ -0,0 +1,111 @@
+package com.frogobox.appkeyboard.util
+
+import android.content.Context
+import android.content.SharedPreferences
+
+/**
+ * On-device adaptive user dictionary learning engine.
+ * Records frequency of user-typed words completely locally on-device.
+ * Guarantees zero learning in Incognito/Password modes.
+ */
+object UserDictionaryHelper {
+
+ private const val PREF_NAME = "frogo_user_dictionary_pref"
+ private const val PREF_KEY_WORDS = "learned_words_data"
+ private const val MAX_STORED_WORDS = 500
+
+ private val VALID_WORD_REGEX = Regex("^[a-zA-Z]{2,32}$")
+
+ var inMemoryWordsForTest: MutableMap? = null
+
+ private fun getPrefs(context: Context): SharedPreferences {
+ return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ }
+
+ /**
+ * Checks whether a word meets hygiene and security criteria for learning.
+ * Bypasses immediately if under Incognito or Password entry modes.
+ */
+ fun canLearnWord(word: String, isIncognito: Boolean, isPassword: Boolean): Boolean {
+ if (isIncognito || isPassword) return false
+ val cleanWord = word.trim().lowercase()
+ return VALID_WORD_REGEX.matches(cleanWord)
+ }
+
+ /**
+ * Learns a typed word if it meets hygiene and security criteria.
+ */
+ fun learnWord(context: Context?, word: String, isIncognito: Boolean, isPassword: Boolean): Boolean {
+ if (!canLearnWord(word, isIncognito, isPassword)) return false
+ val cleanWord = word.trim().lowercase()
+
+ val map = getLearnedWords(context).toMutableMap()
+ val currentCount = map[cleanWord] ?: 0
+ map[cleanWord] = currentCount + 1
+
+ // Cap storage size by keeping highest frequency words
+ if (map.size > MAX_STORED_WORDS) {
+ val sorted = map.entries.sortedByDescending { it.value }.take(MAX_STORED_WORDS)
+ map.clear()
+ sorted.forEach { map[it.key] = it.value }
+ }
+
+ saveLearnedWords(context, map)
+ return true
+ }
+
+ fun getLearnedWords(context: Context? = null): Map {
+ inMemoryWordsForTest?.let { return it }
+ if (context == null) return emptyMap()
+ val raw = try {
+ getPrefs(context).getString(PREF_KEY_WORDS, "") ?: ""
+ } catch (_: Exception) {
+ ""
+ }
+ if (raw.isBlank()) return emptyMap()
+
+ val result = mutableMapOf()
+ raw.split(";").forEach { entry ->
+ val parts = entry.split(":")
+ if (parts.size == 2) {
+ val count = parts[1].toIntOrNull() ?: 1
+ result[parts[0]] = count
+ }
+ }
+ return result
+ }
+
+ fun getSuggestions(context: Context? = null, prefix: String, limit: Int = 3): List {
+ if (prefix.isBlank()) return emptyList()
+ val lowerPrefix = prefix.lowercase()
+ val map = getLearnedWords(context)
+
+ return map.filterKeys { it.startsWith(lowerPrefix) && it != lowerPrefix }
+ .entries
+ .sortedByDescending { it.value }
+ .take(limit)
+ .map { it.key }
+ }
+
+ fun clearLearnedWords(context: Context? = null) {
+ inMemoryWordsForTest?.clear()
+ if (context != null) {
+ try {
+ getPrefs(context).edit().remove(PREF_KEY_WORDS).apply()
+ } catch (_: Exception) {}
+ }
+ }
+
+ private fun saveLearnedWords(context: Context?, map: Map) {
+ if (inMemoryWordsForTest != null) {
+ inMemoryWordsForTest?.clear()
+ inMemoryWordsForTest?.putAll(map)
+ return
+ }
+ if (context == null) return
+ val serialized = map.entries.joinToString(";") { "${it.key}:${it.value}" }
+ try {
+ getPrefs(context).edit().putString(PREF_KEY_WORDS, serialized).apply()
+ } catch (_: Exception) {}
+ }
+}
diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/VoiceTypingHelper.kt b/app/src/main/java/com/frogobox/appkeyboard/util/VoiceTypingHelper.kt
new file mode 100644
index 0000000..a39694c
--- /dev/null
+++ b/app/src/main/java/com/frogobox/appkeyboard/util/VoiceTypingHelper.kt
@@ -0,0 +1,197 @@
+package com.frogobox.appkeyboard.util
+
+import android.Manifest
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.speech.RecognitionListener
+import android.speech.RecognizerIntent
+import android.speech.SpeechRecognizer
+import androidx.core.content.ContextCompat
+import java.util.Locale
+
+/**
+ * Native SpeechRecognizer wrapper for on-keyboard voice typing dictation.
+ * Streams recognized words directly into the IME input connection with lifecycle safety and amplitude callbacks.
+ */
+class VoiceTypingHelper(
+ private val context: Context
+) {
+
+ enum class State {
+ IDLE,
+ INITIALIZING,
+ LISTENING,
+ PROCESSING,
+ ERROR
+ }
+
+ var currentState: State = State.IDLE
+ private set
+
+ val isListening: Boolean
+ get() = currentState == State.LISTENING
+
+ private var speechRecognizer: SpeechRecognizer? = null
+
+ var onTextReceived: ((text: String, isFinal: Boolean) -> Unit)? = null
+ var onRmsChanged: ((amplitude: Float) -> Unit)? = null
+ var onStateChanged: ((State) -> Unit)? = null
+ var onErrorOccurred: ((errorMessage: String) -> Unit)? = null
+
+ /**
+ * Checks if the device supports speech recognition.
+ */
+ fun isAvailable(): Boolean {
+ return SpeechRecognizer.isRecognitionAvailable(context)
+ }
+
+ /**
+ * Checks if RECORD_AUDIO permission has been granted.
+ */
+ fun hasPermission(): Boolean {
+ return ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.RECORD_AUDIO
+ ) == PackageManager.PERMISSION_GRANTED
+ }
+
+ /**
+ * Starts voice recognition listening session.
+ */
+ fun startListening(): Boolean {
+ if (!hasPermission()) {
+ notifyError("Izin mikrofon (RECORD_AUDIO) belum diberikan.")
+ return false
+ }
+
+ if (!isAvailable()) {
+ notifyError("Layanan Pengenalan Suara tidak tersedia di perangkat ini.")
+ return false
+ }
+
+ try {
+ stopListening()
+ ensureRecognizer()
+
+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault().toLanguageTag())
+ putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
+ putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
+ }
+
+ speechRecognizer?.startListening(intent)
+ updateState(State.INITIALIZING)
+ return true
+ } catch (e: Exception) {
+ notifyError("Gagal memulai pengetikan suara: ${e.localizedMessage}")
+ return false
+ }
+ }
+
+ /**
+ * Stops the active listening session.
+ */
+ fun stopListening() {
+ try {
+ speechRecognizer?.stopListening()
+ } catch (_: Exception) {
+ }
+ updateState(State.IDLE)
+ onRmsChanged?.invoke(0f)
+ }
+
+ /**
+ * Releases the speech recognizer resources completely.
+ * Must be called during onFinishInputView or onDestroy.
+ */
+ fun destroy() {
+ stopListening()
+ try {
+ speechRecognizer?.destroy()
+ } catch (_: Exception) {
+ }
+ speechRecognizer = null
+ updateState(State.IDLE)
+ }
+
+ private fun ensureRecognizer() {
+ if (speechRecognizer == null) {
+ speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context).apply {
+ setRecognitionListener(createListener())
+ }
+ }
+ }
+
+ private fun updateState(newState: State) {
+ currentState = newState
+ onStateChanged?.invoke(newState)
+ }
+
+ private fun notifyError(message: String) {
+ updateState(State.ERROR)
+ onErrorOccurred?.invoke(message)
+ }
+
+ private fun createListener(): RecognitionListener {
+ return object : RecognitionListener {
+ override fun onReadyForSpeech(params: Bundle?) {
+ updateState(State.LISTENING)
+ }
+
+ override fun onBeginningOfSpeech() {
+ updateState(State.LISTENING)
+ }
+
+ override fun onRmsChanged(rmsdB: Float) {
+ // Normalize rmsdB (-2dB to ~10dB) into 0.0f..1.0f range
+ val normalized = ((rmsdB + 2f) / 12f).coerceIn(0f, 1f)
+ onRmsChanged?.invoke(normalized)
+ }
+
+ override fun onBufferReceived(buffer: ByteArray?) {}
+
+ override fun onEndOfSpeech() {
+ updateState(State.PROCESSING)
+ onRmsChanged?.invoke(0f)
+ }
+
+ override fun onError(error: Int) {
+ val errorMsg = when (error) {
+ SpeechRecognizer.ERROR_AUDIO -> "Kesalahan perekaman audio."
+ SpeechRecognizer.ERROR_CLIENT -> "Kesalahan klien pengenalan suara."
+ SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Izin mikrofon diperlukan."
+ SpeechRecognizer.ERROR_NETWORK -> "Kesalahan jaringan pengenalan suara."
+ SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Koneksi jaringan suara batas waktu habis."
+ SpeechRecognizer.ERROR_NO_MATCH -> "Tidak ada suara yang terdeteksi."
+ SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Layanan suara sedang sibuk."
+ SpeechRecognizer.ERROR_SERVER -> "Kesalahan server suara."
+ SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Waktu bicara telah habis."
+ else -> "Kesalahan pengenalan suara (kode: $error)."
+ }
+ notifyError(errorMsg)
+ }
+
+ override fun onResults(results: Bundle?) {
+ val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
+ val recognizedText = matches?.firstOrNull().orEmpty()
+ if (recognizedText.isNotBlank()) {
+ onTextReceived?.invoke(recognizedText, true)
+ }
+ updateState(State.IDLE)
+ }
+
+ override fun onPartialResults(partialResults: Bundle?) {
+ val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
+ val partialText = matches?.firstOrNull().orEmpty()
+ if (partialText.isNotBlank()) {
+ onTextReceived?.invoke(partialText, false)
+ }
+ }
+
+ override fun onEvent(eventType: Int, params: Bundle?) {}
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_menu_ai_assistant.xml b/app/src/main/res/drawable/ic_menu_ai_assistant.xml
new file mode 100644
index 0000000..9d412ae
--- /dev/null
+++ b/app/src/main/res/drawable/ic_menu_ai_assistant.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_menu_gesture_swipe.xml b/app/src/main/res/drawable/ic_menu_gesture_swipe.xml
new file mode 100644
index 0000000..f7358ee
--- /dev/null
+++ b/app/src/main/res/drawable/ic_menu_gesture_swipe.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_menu_language.xml b/app/src/main/res/drawable/ic_menu_language.xml
new file mode 100644
index 0000000..077fdc1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_menu_language.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_menu_number_row.xml b/app/src/main/res/drawable/ic_menu_number_row.xml
new file mode 100644
index 0000000..74050b1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_menu_number_row.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_menu_one_handed.xml b/app/src/main/res/drawable/ic_menu_one_handed.xml
new file mode 100644
index 0000000..f5e29b8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_menu_one_handed.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_menu_theme.xml b/app/src/main/res/drawable/ic_menu_theme.xml
new file mode 100644
index 0000000..424689a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_menu_theme.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_menu_voice_typing.xml b/app/src/main/res/drawable/ic_menu_voice_typing.xml
new file mode 100644
index 0000000..86f17de
--- /dev/null
+++ b/app/src/main/res/drawable/ic_menu_voice_typing.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/values/color.xml b/app/src/main/res/values/color.xml
index 1746e8e..b5434e1 100644
--- a/app/src/main/res/values/color.xml
+++ b/app/src/main/res/values/color.xml
@@ -11,5 +11,8 @@
#00838F
#C2185B
#FBC02D
+ #000000
+ #FFE600
+ #00E5FF
\ No newline at end of file
diff --git a/app/src/test/java/com/frogobox/appkeyboard/AutoTextSubMenuConsolidationTest.kt b/app/src/test/java/com/frogobox/appkeyboard/AutoTextSubMenuConsolidationTest.kt
index a01d1c5..025cf82 100644
--- a/app/src/test/java/com/frogobox/appkeyboard/AutoTextSubMenuConsolidationTest.kt
+++ b/app/src/test/java/com/frogobox/appkeyboard/AutoTextSubMenuConsolidationTest.kt
@@ -86,8 +86,8 @@ class AutoTextSubMenuConsolidationTest {
val keyboardUtil = KeyboardUtil(fakePref)
val menuList = keyboardUtil.menuToggle()
- // 11 functional tools remain (Suggestion, AutoText, ProductRemote, Clipboard, TextEdit, News, Movie, Web, Form, ChangeKeyboard, Setting)
- assertEquals(11, menuList.size)
+ // 16 functional tools remain (Suggestion, NumberRow, OneHanded, AIAssistant, VoiceTyping, GestureSwipe, AutoText, ProductRemote, Clipboard, TextEdit, News, Movie, Web, Form, ChangeKeyboard, Setting)
+ assertEquals(16, menuList.size)
val menuIds = menuList.map { it.id }.toSet()
@@ -98,7 +98,10 @@ class AutoTextSubMenuConsolidationTest {
assertFalse(menuIds.contains(KeyboardFeatureType.TEMPLATE_TEXT_GREETING.id))
assertFalse(menuIds.contains(KeyboardFeatureType.TEMPLATE_TEXT_LOVE.id))
- // Core AutoText must be present
+ // Core AutoText and Smart Tools must be present
+ assertTrue(menuIds.contains(KeyboardFeatureType.AI_ASSISTANT.id))
+ assertTrue(menuIds.contains(KeyboardFeatureType.VOICE_TYPING.id))
+ assertTrue(menuIds.contains(KeyboardFeatureType.GESTURE_SWIPE.id))
assertTrue(menuIds.contains(KeyboardFeatureType.AUTO_TEXT.id))
assertTrue(menuIds.contains(KeyboardFeatureType.PRODUCT_REMOTE.id))
assertTrue(menuIds.contains(KeyboardFeatureType.CLIPBOARD.id))
diff --git a/app/src/test/java/com/frogobox/appkeyboard/GboardCapabilitiesEnhancementTest.kt b/app/src/test/java/com/frogobox/appkeyboard/GboardCapabilitiesEnhancementTest.kt
index fb06652..8afd4f1 100644
--- a/app/src/test/java/com/frogobox/appkeyboard/GboardCapabilitiesEnhancementTest.kt
+++ b/app/src/test/java/com/frogobox/appkeyboard/GboardCapabilitiesEnhancementTest.kt
@@ -148,7 +148,7 @@ class GboardCapabilitiesEnhancementTest {
@Test
fun testTextEditActionEnumCompleteness() {
val actions = TextEditAction.entries
- assertEquals(13, actions.size)
+ assertEquals(15, actions.size)
assertTrue(actions.contains(TextEditAction.MOVE_LEFT))
assertTrue(actions.contains(TextEditAction.MOVE_RIGHT))
assertTrue(actions.contains(TextEditAction.MOVE_UP))
@@ -162,6 +162,8 @@ class GboardCapabilitiesEnhancementTest {
assertTrue(actions.contains(TextEditAction.PASTE))
assertTrue(actions.contains(TextEditAction.DELETE))
assertTrue(actions.contains(TextEditAction.ENTER))
+ assertTrue(actions.contains(TextEditAction.UNDO))
+ assertTrue(actions.contains(TextEditAction.REDO))
}
@Test
diff --git a/app/src/test/java/com/frogobox/appkeyboard/KeyboardImeComposeUnitTest.kt b/app/src/test/java/com/frogobox/appkeyboard/KeyboardImeComposeUnitTest.kt
index d5837f7..017c0be 100644
--- a/app/src/test/java/com/frogobox/appkeyboard/KeyboardImeComposeUnitTest.kt
+++ b/app/src/test/java/com/frogobox/appkeyboard/KeyboardImeComposeUnitTest.kt
@@ -36,10 +36,11 @@ class KeyboardImeComposeUnitTest {
KeyboardPanelState.WEBVIEW,
KeyboardPanelState.FORM,
KeyboardPanelState.CLIPBOARD,
- KeyboardPanelState.TEXT_EDIT
+ KeyboardPanelState.TEXT_EDIT,
+ KeyboardPanelState.AI_ASSISTANT
)
- assertEquals(15, KeyboardPanelState.entries.size)
+ assertEquals(16, KeyboardPanelState.entries.size)
expectedPanels.forEach { panel ->
assertTrue(KeyboardPanelState.entries.contains(panel))
}
@@ -60,8 +61,10 @@ class KeyboardImeComposeUnitTest {
assertEquals(KeyboardPanelState.FORM, KeyboardPanelState.fromFeature(KeyboardFeatureType.FORM))
assertEquals(KeyboardPanelState.CLIPBOARD, KeyboardPanelState.fromFeature(KeyboardFeatureType.CLIPBOARD))
assertEquals(KeyboardPanelState.TEXT_EDIT, KeyboardPanelState.fromFeature(KeyboardFeatureType.TEXT_EDIT))
+ assertEquals(KeyboardPanelState.AI_ASSISTANT, KeyboardPanelState.fromFeature(KeyboardFeatureType.AI_ASSISTANT))
// Non-panel utility actions should map to null
+ assertNull(KeyboardPanelState.fromFeature(KeyboardFeatureType.VOICE_TYPING))
assertNull(KeyboardPanelState.fromFeature(KeyboardFeatureType.SUGGESTION))
assertNull(KeyboardPanelState.fromFeature(KeyboardFeatureType.CHANGE_KEYBOARD))
assertNull(KeyboardPanelState.fromFeature(KeyboardFeatureType.SETTING))
diff --git a/app/src/test/java/com/frogobox/appkeyboard/ProductRemoteKeyboardTest.kt b/app/src/test/java/com/frogobox/appkeyboard/ProductRemoteKeyboardTest.kt
index 98d1ab1..e2d72b8 100644
--- a/app/src/test/java/com/frogobox/appkeyboard/ProductRemoteKeyboardTest.kt
+++ b/app/src/test/java/com/frogobox/appkeyboard/ProductRemoteKeyboardTest.kt
@@ -6,7 +6,6 @@ import com.frogobox.appkeyboard.data.remote.model.DataApiResponse
import com.frogobox.appkeyboard.data.remote.model.DataItemResponse
import com.frogobox.appkeyboard.model.KeyboardFeatureType
import com.frogobox.appkeyboard.repository.data.DataApiRepositoryImpl
-import com.frogobox.appkeyboard.model.ProductEntity
import com.frogobox.appkeyboard.model.toProductEntity
import com.frogobox.appkeyboard.ui.keyboard.productremote.ProductRemoteOutputMode
import com.frogobox.appkeyboard.ui.keyboard.productremote.matchesSearchQuery
diff --git a/app/src/test/java/com/frogobox/appkeyboard/SpacebarScrubbingAndPrivacyGuardTest.kt b/app/src/test/java/com/frogobox/appkeyboard/SpacebarScrubbingAndPrivacyGuardTest.kt
new file mode 100644
index 0000000..d6222a3
--- /dev/null
+++ b/app/src/test/java/com/frogobox/appkeyboard/SpacebarScrubbingAndPrivacyGuardTest.kt
@@ -0,0 +1,132 @@
+package com.frogobox.appkeyboard
+
+import android.text.InputType
+import android.view.inputmethod.EditorInfo
+import com.frogobox.appkeyboard.suggestion.SuggestionResult
+import com.frogobox.appkeyboard.util.KeyboardPrivacyHelper
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Test suite for TASK-027:
+ * - Spacebar Cursor Scrubbing (Trackpad Mode) & Backspace Word Delete
+ * - Automatic Incognito & Password Privacy Guard
+ */
+class SpacebarScrubbingAndPrivacyGuardTest {
+
+ @Test
+ fun testIsPasswordField_withTextPasswordVariations() {
+ // Standard text password
+ val passwordInfo = EditorInfo().apply {
+ inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
+ }
+ assertTrue(KeyboardPrivacyHelper.isPasswordField(passwordInfo))
+ assertTrue(KeyboardPrivacyHelper.isIncognitoOrPassword(passwordInfo))
+
+ // Web password
+ val webPasswordInfo = EditorInfo().apply {
+ inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD
+ }
+ assertTrue(KeyboardPrivacyHelper.isPasswordField(webPasswordInfo))
+ assertTrue(KeyboardPrivacyHelper.isIncognitoOrPassword(webPasswordInfo))
+
+ // Visible password
+ val visiblePasswordInfo = EditorInfo().apply {
+ inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
+ }
+ assertTrue(KeyboardPrivacyHelper.isPasswordField(visiblePasswordInfo))
+ assertTrue(KeyboardPrivacyHelper.isIncognitoOrPassword(visiblePasswordInfo))
+ }
+
+ @Test
+ fun testIsPasswordField_withNumericPassword() {
+ val numberPinInfo = EditorInfo().apply {
+ inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
+ }
+ assertTrue(KeyboardPrivacyHelper.isPasswordField(numberPinInfo))
+ assertTrue(KeyboardPrivacyHelper.isIncognitoOrPassword(numberPinInfo))
+ }
+
+ @Test
+ fun testIsNoPersonalizedLearning_withIncognitoFlag() {
+ val incognitoInfo = EditorInfo().apply {
+ inputType = InputType.TYPE_CLASS_TEXT
+ imeOptions = EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING
+ }
+ assertFalse(KeyboardPrivacyHelper.isPasswordField(incognitoInfo))
+ assertTrue(KeyboardPrivacyHelper.isNoPersonalizedLearning(incognitoInfo))
+ assertTrue(KeyboardPrivacyHelper.isIncognitoOrPassword(incognitoInfo))
+ }
+
+ @Test
+ fun testRegularTextField_isNotIncognitoOrPassword() {
+ val normalTextInfo = EditorInfo().apply {
+ inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_NORMAL
+ imeOptions = EditorInfo.IME_ACTION_DONE
+ }
+ assertFalse(KeyboardPrivacyHelper.isPasswordField(normalTextInfo))
+ assertFalse(KeyboardPrivacyHelper.isNoPersonalizedLearning(normalTextInfo))
+ assertFalse(KeyboardPrivacyHelper.isIncognitoOrPassword(normalTextInfo))
+
+ val emailInfo = EditorInfo().apply {
+ inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
+ }
+ assertFalse(KeyboardPrivacyHelper.isPasswordField(emailInfo))
+ assertFalse(KeyboardPrivacyHelper.isIncognitoOrPassword(emailInfo))
+ }
+
+ @Test
+ fun testNullEditorInfo_handledSafely() {
+ assertFalse(KeyboardPrivacyHelper.isPasswordField(null))
+ assertFalse(KeyboardPrivacyHelper.isNoPersonalizedLearning(null))
+ assertFalse(KeyboardPrivacyHelper.isIncognitoOrPassword(null))
+ }
+
+ @Test
+ fun testSpacebarCursorScrubbing_stepCalculations() {
+ val threshold = 20
+ val touchStartX = 100
+
+ // Move right 65px -> 3 steps to the right
+ val moveRightX = 165
+ val diffRight = moveRightX - touchStartX
+ val stepsRight = diffRight / threshold
+ assertEquals(3, stepsRight)
+
+ // Move left 50px -> 2 steps to the left
+ val moveLeftX = 50
+ val diffLeft = moveLeftX - touchStartX
+ val stepsLeft = (-diffLeft) / threshold
+ assertEquals(2, stepsLeft)
+ }
+
+ @Test
+ fun testBackspaceSwipeDelete_wordCountCalculations() {
+ val threshold = 30
+ val deleteStartX = 300
+
+ // Swipe left 150px
+ val swipeLeftDist = deleteStartX - 150 // 150px
+ val wordsToDelete = kotlin.math.max(1, swipeLeftDist / (threshold * 2))
+ assertEquals(2, wordsToDelete)
+
+ // Short swipe 40px
+ val shortSwipeDist = deleteStartX - 260 // 40px
+ val minWords = kotlin.math.max(1, shortSwipeDist / (threshold * 2))
+ assertEquals(1, minWords)
+ }
+
+ @Test
+ fun testIncognitoSuppressesSuggestions() {
+ var isSuggestionEnabled = true
+ var isIncognitoMode = true
+
+ val shouldCalculateSuggestions = isSuggestionEnabled && !isIncognitoMode
+ assertFalse("Suggestions must be suppressed during incognito mode", shouldCalculateSuggestions)
+
+ val emptyResult = SuggestionResult.EMPTY
+ assertFalse("SuggestionResult.EMPTY must have no suggestions", emptyResult.hasSuggestions())
+ }
+}
diff --git a/app/src/test/java/com/frogobox/appkeyboard/Task032HomeUiDragToggleOneHandedTest.kt b/app/src/test/java/com/frogobox/appkeyboard/Task032HomeUiDragToggleOneHandedTest.kt
new file mode 100644
index 0000000..9966f36
--- /dev/null
+++ b/app/src/test/java/com/frogobox/appkeyboard/Task032HomeUiDragToggleOneHandedTest.kt
@@ -0,0 +1,129 @@
+package com.frogobox.appkeyboard
+
+import com.frogobox.appkeyboard.model.KeyboardFeatureModel
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.Collections
+
+/**
+ * Unit tests for TASK-032:
+ * 1. Home UI Cadangkan Data (Export/Import) positioning & data validation.
+ * 2. Toggle Feature drag-and-drop swap logic and boundary checks.
+ * 3. One-Handed mode state transitions, intrinsic height sizing, and scaleX touch hit-test logic.
+ */
+class Task032HomeUiDragToggleOneHandedTest {
+
+ @Test
+ fun testDragAndDropSwapLogic() {
+ val originalList = mutableListOf(
+ KeyboardFeatureModel(id = "auto_text", text = "Auto Text", icon = 1),
+ KeyboardFeatureModel(id = "template_text_app", text = "App Review", icon = 2),
+ KeyboardFeatureModel(id = "template_text_game", text = "Game Review", icon = 3),
+ KeyboardFeatureModel(id = "clipboard", text = "Clipboard", icon = 4)
+ )
+
+ // Swap index 0 and 2
+ val fromIndex = 0
+ val toIndex = 2
+ assertTrue(fromIndex in originalList.indices && toIndex in originalList.indices)
+
+ Collections.swap(originalList, fromIndex, toIndex)
+
+ assertEquals("template_text_game", originalList[0].id)
+ assertEquals("template_text_app", originalList[1].id)
+ assertEquals("auto_text", originalList[2].id)
+ assertEquals("clipboard", originalList[3].id)
+ }
+
+ @Test
+ fun testDragAndDropSwapOutOfBoundsGuarded() {
+ val list = mutableListOf(
+ KeyboardFeatureModel(id = "feature_1", text = "F1", icon = 1),
+ KeyboardFeatureModel(id = "feature_2", text = "F2", icon = 2)
+ )
+
+ val invalidFrom = -1
+ val invalidTo = 5
+
+ val isValidSwap = (invalidFrom in list.indices && invalidTo in list.indices && invalidFrom != invalidTo)
+ assertFalse(isValidSwap)
+
+ // List remains unchanged
+ assertEquals("feature_1", list[0].id)
+ assertEquals("feature_2", list[1].id)
+ }
+
+ @Test
+ fun testOneHandedModeStateTransitions() {
+ val validStates = listOf("OFF", "LEFT", "RIGHT")
+
+ var currentState = "OFF"
+ assertTrue(validStates.contains(currentState))
+
+ // Transition: OFF -> LEFT
+ currentState = "LEFT"
+ assertEquals("LEFT", currentState)
+
+ // Transition: LEFT -> RIGHT (Swap sides)
+ currentState = if (currentState == "LEFT") "RIGHT" else "LEFT"
+ assertEquals("RIGHT", currentState)
+
+ // Transition: RIGHT -> OFF (Restore full width)
+ currentState = "OFF"
+ assertEquals("OFF", currentState)
+ }
+
+ @Test
+ fun testOneHandedKeyboardScaleXAndTouchTransformation() {
+ val keyboardMinWidth = 1080
+ val containerWidth = (keyboardMinWidth * 0.84f).toInt() // ~907px
+
+ val scaleX = containerWidth.toFloat() / keyboardMinWidth.toFloat()
+ assertTrue(scaleX < 1.0f)
+ assertTrue(scaleX > 0.83f && scaleX < 0.85f)
+
+ // Test rightmost key coordinate mapping
+ // In full 1080px width, backspace key might be at x = 972px, width = 108px
+ val unscaledKeyX = 972
+ val unscaledKeyWidth = 108
+ val scaledKeyX = (unscaledKeyX * scaleX).toInt()
+ val scaledKeyWidth = (unscaledKeyWidth * scaleX).toInt()
+
+ // Verify the key fits inside the 907px container
+ assertTrue(scaledKeyX + scaledKeyWidth <= containerWidth + 2)
+
+ // Test touch hit test coordinate inversion
+ // User taps at screen coordinate at the center of the scaled key
+ val touchScreenX = scaledKeyX + (scaledKeyWidth / 2)
+ val adjustedTouchX = (touchScreenX / scaleX).toInt()
+
+ // Adjusted touch should map back inside the unscaled key's bounds
+ assertTrue(adjustedTouchX in unscaledKeyX..(unscaledKeyX + unscaledKeyWidth))
+ }
+
+ @Test
+ fun testBackupRestorePayloadIntegrity() {
+ val sampleBackupJson = """
+ {
+ "version": 1,
+ "exportedAt": 1727515000000,
+ "autoTextCount": 2,
+ "autoTexts": [
+ {"id": 1, "title": "Greeting", "content": "Halo, selamat datang!"},
+ {"id": 2, "title": "Thanks", "content": "Terima kasih atas bantuan Anda."}
+ ],
+ "preferences": {
+ "feature_order": "auto_text,clipboard,web_search",
+ "one_handed_mode": "LEFT"
+ }
+ }
+ """.trimIndent()
+
+ assertTrue(sampleBackupJson.contains("version"))
+ assertTrue(sampleBackupJson.contains("autoTexts"))
+ assertTrue(sampleBackupJson.contains("preferences"))
+ assertTrue(sampleBackupJson.contains("one_handed_mode"))
+ }
+}
diff --git a/app/src/test/java/com/frogobox/appkeyboard/Task033HomeUiIconsAlignmentTest.kt b/app/src/test/java/com/frogobox/appkeyboard/Task033HomeUiIconsAlignmentTest.kt
new file mode 100644
index 0000000..61b5b2c
--- /dev/null
+++ b/app/src/test/java/com/frogobox/appkeyboard/Task033HomeUiIconsAlignmentTest.kt
@@ -0,0 +1,86 @@
+package com.frogobox.appkeyboard
+
+import com.frogobox.appkeyboard.model.KeyboardFeatureType
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * QA Unit tests for TASK-033:
+ * 1. Verifies that Home screen menu item icons align with their real settings.
+ * 2. Verifies that Toggle Fitur uses ic_menu_setting (parity with KeyboardFeatureType.SETTING).
+ * 3. Verifies that Tema Keyboard uses ic_menu_theme and does NOT collide with Template Teks.
+ * 4. Verifies that Bahasa Keyboard uses ic_menu_language (not the web search WWW icon).
+ * 5. Verifies all icon drawables are valid and non-zero.
+ */
+class Task033HomeUiIconsAlignmentTest {
+
+ @Test
+ fun testToggleFiturIconMatchesKeyboardFeatureSetting() {
+ val toggleFiturIcon = R.drawable.ic_menu_setting
+ val keyboardFeatureSettingIcon = KeyboardFeatureType.SETTING.icon
+
+ // Toggle Fitur must match SETTING in KeyboardFeatureType
+ assertEquals(keyboardFeatureSettingIcon, toggleFiturIcon)
+ assertNotEquals(R.drawable.ic_menu_form, toggleFiturIcon)
+ }
+
+ @Test
+ fun testTemaKeyboardUsesDedicatedThemeIconAndDoesNotCollide() {
+ val temaKeyboardIcon = R.drawable.ic_menu_theme
+ val templateTeksIcon = R.drawable.ic_menu_ps_game
+
+ // Tema Keyboard must have its own theme icon, not the game controller
+ assertNotEquals(R.drawable.ic_menu_ps_game, temaKeyboardIcon)
+ // No duplicate collision between Tema and Template Teks
+ assertNotEquals(templateTeksIcon, temaKeyboardIcon)
+ assertTrue(temaKeyboardIcon != 0)
+ }
+
+ @Test
+ fun testBahasaKeyboardUsesDedicatedLanguageIcon() {
+ val bahasaKeyboardIcon = R.drawable.ic_menu_language
+
+ // Bahasa Keyboard must NOT use the WWW search website icon
+ assertNotEquals(R.drawable.ic_menu_website, bahasaKeyboardIcon)
+ assertTrue(bahasaKeyboardIcon != 0)
+ }
+
+ @Test
+ fun testHomeMenuIconIntegrityAndNonZero() {
+ val menuIcons = mapOf(
+ "Product Remote" to R.drawable.ic_menu_ps_sale,
+ "Auto Text" to R.drawable.ic_menu_auto_text,
+ "Template Teks" to R.drawable.ic_menu_ps_game,
+ "Toggle Fitur" to R.drawable.ic_menu_setting,
+ "Suara dan Haptic" to R.drawable.ic_menu_sound,
+ "Bahasa Keyboard" to R.drawable.ic_menu_language,
+ "Tema Keyboard" to R.drawable.ic_menu_theme,
+ "Playground Uji Coba" to R.drawable.ic_menu_keyboard
+ )
+
+ // All menu items must have valid non-zero resource IDs
+ for ((menuTitle, iconRes) in menuIcons) {
+ assertTrue("Icon for $menuTitle should be non-zero", iconRes != 0)
+ }
+
+ // Verify distinct icons for key setting categories
+ val uniqueKeyIcons = setOf(
+ menuIcons["Toggle Fitur"],
+ menuIcons["Bahasa Keyboard"],
+ menuIcons["Tema Keyboard"],
+ menuIcons["Suara dan Haptic"]
+ )
+ assertEquals(4, uniqueKeyIcons.size)
+ }
+
+ @Test
+ fun testKeyboardFeatureTypeIntegrity() {
+ for (featureType in KeyboardFeatureType.entries) {
+ assertTrue("Feature ${featureType.name} icon must be valid", featureType.icon != 0)
+ assertTrue("Feature ${featureType.name} id must not be blank", featureType.id.isNotBlank())
+ assertTrue("Feature ${featureType.name} text must not be blank", featureType.text.isNotBlank())
+ }
+ }
+}
diff --git a/app/src/test/java/com/frogobox/appkeyboard/Task034GestureSwipeTest.kt b/app/src/test/java/com/frogobox/appkeyboard/Task034GestureSwipeTest.kt
new file mode 100644
index 0000000..58e2a05
--- /dev/null
+++ b/app/src/test/java/com/frogobox/appkeyboard/Task034GestureSwipeTest.kt
@@ -0,0 +1,404 @@
+package com.frogobox.appkeyboard
+
+import com.frogobox.appkeyboard.model.KeyboardFeatureType
+import com.frogobox.appkeyboard.services.KeyboardUtil
+import com.frogobox.appkeyboard.ui.keyboard.root.KeyboardPanelState
+import com.frogobox.libkeyboard.util.GestureTypingHelper
+import com.frogobox.sdk.delegate.preference.PreferenceDelegates
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import kotlin.math.abs
+import kotlin.math.max
+
+/**
+ * Comprehensive Unit Test Suite for TASK-034:
+ * Gesture Swipe Typing (Glide Typing) & Keyboard Swipe Shortcuts.
+ */
+class Task034GestureSwipeTest {
+
+ private lateinit var keyboardUtil: KeyboardUtil
+ private lateinit var fakePref: FakePreferenceDelegates
+
+ @Before
+ fun setUp() {
+ fakePref = FakePreferenceDelegates()
+ keyboardUtil = KeyboardUtil(fakePref)
+ GestureTypingHelper.clearDynamicVocabulary()
+ }
+
+ // --- AC-01: Gesture Glide Engine Trajectory Resolution ---
+
+ @Test
+ fun testGestureTrajectoryResolution() {
+ val keyBoxes = listOf(
+ GestureTypingHelper.KeyBoundingBox('s', 0f, 0f, 40f, 40f),
+ GestureTypingHelper.KeyBoundingBox('w', 40f, 0f, 80f, 40f),
+ GestureTypingHelper.KeyBoundingBox('i', 80f, 0f, 120f, 40f),
+ GestureTypingHelper.KeyBoundingBox('p', 120f, 0f, 160f, 40f),
+ GestureTypingHelper.KeyBoundingBox('e', 160f, 0f, 200f, 40f)
+ )
+
+ val points = listOf(
+ GestureTypingHelper.PathPoint(20f, 20f),
+ GestureTypingHelper.PathPoint(60f, 20f),
+ GestureTypingHelper.PathPoint(100f, 20f),
+ GestureTypingHelper.PathPoint(140f, 20f),
+ GestureTypingHelper.PathPoint(180f, 20f)
+ )
+
+ val traversed = GestureTypingHelper.resolveTrajectoryToChars(points, keyBoxes)
+ assertEquals("swipe", traversed)
+ }
+
+ // --- AC-02 & AC-03: Word Matching, Ranking & Duplicate Character Resilience ---
+
+ @Test
+ fun testGestureWordMatching_indonesian() {
+ val traversed = "hjkalo" // Swiped from h to a to l to o
+ val matches = GestureTypingHelper.matchGesture(traversed)
+ assertTrue(matches.contains("halo"))
+ assertEquals("halo", matches.first())
+ }
+
+ @Test
+ fun testGestureWordMatching_english() {
+ val traversed = "sweirtpipe" // Swiped roughly s-w-i-p-e
+ val matches = GestureTypingHelper.matchGesture(traversed)
+ assertTrue(matches.contains("swipe"))
+ assertEquals("swipe", matches.first())
+ }
+
+ @Test
+ fun testGestureWordMatching_duplicateLettersResilience() {
+ // In glide typing, user touches 'h'-'e'-'l'-'o' without looping 'l' twice
+ val traversed = "helo"
+ val matches = GestureTypingHelper.matchGesture(traversed, listOf("hello", "halo", "help"))
+ assertTrue("Should match 'hello' despite single 'l' in trajectory", matches.contains("hello"))
+ assertEquals("hello", matches.first())
+
+ // And traversing 'halo' matches 'halo'
+ val haloMatches = GestureTypingHelper.matchGesture("hjalo", listOf("hello", "halo", "help"))
+ assertTrue("Should match 'halo'", haloMatches.contains("halo"))
+ }
+
+ @Test
+ fun testGestureWordMatching_dynamicVocabulary() {
+ GestureTypingHelper.addVocabulary(listOf("gemini", "antigravity", "frogobox"))
+ val traversed = "gtrewminni" // Traversed g-e-m-i-n-i with noise: g(t)e(w)m(i)n(n)i
+ val matches = GestureTypingHelper.matchGesture(traversed)
+ assertTrue(matches.contains("gemini"))
+ }
+
+ @Test
+ fun testCompressConsecutiveDuplicates() {
+ assertEquals("helo", GestureTypingHelper.compressConsecutiveDuplicates("hello"))
+ assertEquals("god", GestureTypingHelper.compressConsecutiveDuplicates("good"))
+ assertEquals("abc", GestureTypingHelper.compressConsecutiveDuplicates("aabbcc"))
+ assertEquals("", GestureTypingHelper.compressConsecutiveDuplicates(""))
+ assertEquals("a", GestureTypingHelper.compressConsecutiveDuplicates("a"))
+ }
+
+ // --- AC-04: Gesture Swipe Shortcuts (Backspace, Spacebar, Swipe Down) ---
+
+ @Test
+ fun testSwipeLeftBackspace_wordsToDeleteCalculation() {
+ val threshold = 50f
+ val swipeDist1 = 60f
+ val words1 = max(1, (swipeDist1 / (threshold * 2)).toInt())
+ assertEquals(1, words1)
+
+ val swipeDist3 = 310f
+ val words3 = max(1, (swipeDist3 / (threshold * 2)).toInt())
+ assertEquals(3, words3)
+ }
+
+ @Test
+ fun testSpacebarScrubbingCursorStepCalculation() {
+ val spaceThreshold = 35f
+ val dragAmountRight = 105f
+ val stepsRight = (dragAmountRight / spaceThreshold).toInt()
+ assertEquals(3, stepsRight)
+
+ val dragAmountLeft = -70f
+ val stepsLeft = (abs(dragAmountLeft) / spaceThreshold).toInt()
+ assertEquals(2, stepsLeft)
+ }
+
+ @Test
+ fun testSwipeDownGestureCriteria() {
+ val keyboardHeight = 400f
+ val downY = 100f // In top 45% (400 * 0.45 = 180)
+ val moveY = 220f
+ val dy = moveY - downY // 120f > threshold (80f)
+ val dx = 15f // < maxHoriz (45f)
+
+ val swipeDownThreshold = 80f
+ val swipeDownMaxHoriz = 45f
+
+ val isSwipeDown = downY < keyboardHeight * 0.45f && dy > swipeDownThreshold && dx < swipeDownMaxHoriz
+ assertTrue("Criteria for swipe down should be met", isSwipeDown)
+
+ val bottomDownY = 300f
+ val isBottomSwipeDown = bottomDownY < keyboardHeight * 0.45f && dy > swipeDownThreshold && dx < swipeDownMaxHoriz
+ assertFalse("Swipe down starting at bottom half should not trigger dismissal", isBottomSwipeDown)
+ }
+
+ // --- AC-05: Feature Toggle & Settings Integration ---
+
+ @Test
+ fun testKeyboardFeatureType_gestureSwipe() {
+ val gestureFeature = KeyboardFeatureType.GESTURE_SWIPE
+ assertEquals("menu_gesture_swipe", gestureFeature.id)
+ assertEquals("Gesture & Swipe", gestureFeature.text)
+ assertEquals(R.drawable.ic_menu_gesture_swipe, gestureFeature.icon)
+ assertEquals(KeyboardFeatureType.GESTURE_SWIPE, KeyboardFeatureType.from("menu_gesture_swipe"))
+
+ // Does not open a standalone sub-panel
+ assertNull(KeyboardPanelState.fromFeature(KeyboardFeatureType.GESTURE_SWIPE))
+
+ // Exists in DEFAULT_FEATURE_TYPES
+ assertTrue(KeyboardUtil.DEFAULT_FEATURE_TYPES.contains(KeyboardFeatureType.GESTURE_SWIPE))
+ }
+
+ @Test
+ fun testKeyboardUtil_gestureTypingPreferences() {
+ // Default should be true
+ assertTrue(keyboardUtil.isGestureTypingEnabled())
+ keyboardUtil.setGestureTypingEnabled(false)
+ assertFalse(keyboardUtil.isGestureTypingEnabled())
+ keyboardUtil.setGestureTypingEnabled(true)
+ assertTrue(keyboardUtil.isGestureTypingEnabled())
+ }
+
+ @Test
+ fun testKeyboardUtil_gestureSwipeShortcutsPreferences() {
+ // Default should be true
+ assertTrue(keyboardUtil.isGestureSwipeShortcutsEnabled())
+ keyboardUtil.setGestureSwipeShortcutsEnabled(false)
+ assertFalse(keyboardUtil.isGestureSwipeShortcutsEnabled())
+ keyboardUtil.setGestureSwipeShortcutsEnabled(true)
+ assertTrue(keyboardUtil.isGestureSwipeShortcutsEnabled())
+ }
+
+ // --- Systematic Bug Fix Tests: Coordinate Scaling, Interpolation, Edge Tolerance & Fallback ---
+
+ @Test
+ fun testScaledKeyBoundingBoxesMatchScreenCoordinates() {
+ val scaleX = 2.5f
+ // Key 'a' at unscaled 0..40, on screen 0..100
+ // Key 'b' at unscaled 40..80, on screen 100..200
+ // Key 'c' at unscaled 80..120, on screen 200..300
+ val unscaledKeys = listOf(
+ Triple('a', 0f, 40f),
+ Triple('b', 40f, 80f),
+ Triple('c', 80f, 120f)
+ )
+ val scaledKeyBoxes = unscaledKeys.map { (char, left, right) ->
+ GestureTypingHelper.KeyBoundingBox(
+ char = char,
+ left = left * scaleX,
+ top = 0f,
+ right = right * scaleX,
+ bottom = 50f
+ )
+ }
+
+ // Screen touch points located at x = 50, 150, 250
+ val screenPoints = listOf(
+ GestureTypingHelper.PathPoint(50f, 25f),
+ GestureTypingHelper.PathPoint(150f, 25f),
+ GestureTypingHelper.PathPoint(250f, 25f)
+ )
+
+ val resolved = GestureTypingHelper.resolveTrajectoryToChars(screenPoints, scaledKeyBoxes)
+ assertEquals("abc", resolved)
+ }
+
+ @Test
+ fun testFastSwipeInterpolationHitsIntermediateKeys() {
+ val keys = listOf(
+ GestureTypingHelper.KeyBoundingBox('q', 0f, 0f, 40f, 40f),
+ GestureTypingHelper.KeyBoundingBox('w', 40f, 0f, 80f, 40f),
+ GestureTypingHelper.KeyBoundingBox('e', 80f, 0f, 120f, 40f),
+ GestureTypingHelper.KeyBoundingBox('r', 120f, 0f, 160f, 40f),
+ GestureTypingHelper.KeyBoundingBox('t', 160f, 0f, 200f, 40f)
+ )
+
+ // Only two points recorded on very fast swipe: from 'q' (20,20) directly to 't' (180,20)
+ // Distance is 160px (> 35px threshold)
+ val fastPoints = listOf(
+ GestureTypingHelper.PathPoint(20f, 20f),
+ GestureTypingHelper.PathPoint(180f, 20f)
+ )
+
+ val resolved = GestureTypingHelper.resolveTrajectoryToChars(fastPoints, keys)
+ // Interpolation should catch intermediate keys 'w', 'e', 'r'
+ assertTrue("Should contain 'w'", resolved.contains('w'))
+ assertTrue("Should contain 'e'", resolved.contains('e'))
+ assertTrue("Should contain 'r'", resolved.contains('r'))
+ assertEquals('q', resolved.first())
+ assertEquals('t', resolved.last())
+ }
+
+ @Test
+ fun testEdgeLiftoffTolerance_adjacentLastKey() {
+ // User swipes 'h'-'e'-'l'-'o' but on liftoff accidentally grazes adjacent 'p' -> "helop"
+ val traversedWithLiftoffNoise = "helop"
+ val matches = GestureTypingHelper.matchGesture(traversedWithLiftoffNoise, listOf("hello", "help", "hope"))
+ assertTrue("Should still match 'hello' via edge tolerance", matches.contains("hello"))
+ }
+
+ @Test
+ fun testEdgeOnsetTolerance_adjacentFirstKey() {
+ // User starts near 'g' and moves to 'h'-'a'-'l'-'o' -> "ghalo"
+ val traversedWithOnsetNoise = "ghalo"
+ val matches = GestureTypingHelper.matchGesture(traversedWithOnsetNoise, listOf("halo", "good", "game"))
+ assertTrue("Should still match 'halo' via onset tolerance", matches.contains("halo"))
+ }
+
+ @Test
+ fun testNonLetterKeysExcludedFromTrajectory() {
+ // Trajectory points near space or symbols should only retain letters
+ val keysWithNonLetters = listOf(
+ GestureTypingHelper.KeyBoundingBox('h', 0f, 0f, 40f, 40f),
+ GestureTypingHelper.KeyBoundingBox(' ', 40f, 0f, 120f, 40f), // Spacebar
+ GestureTypingHelper.KeyBoundingBox('i', 120f, 0f, 160f, 40f)
+ )
+ val points = listOf(
+ GestureTypingHelper.PathPoint(20f, 20f),
+ GestureTypingHelper.PathPoint(80f, 20f), // Touched space
+ GestureTypingHelper.PathPoint(140f, 20f)
+ )
+ val resolved = GestureTypingHelper.resolveTrajectoryToChars(points, keysWithNonLetters)
+ // Space should be ignored
+ assertFalse("Resolved trajectory should not contain space", resolved.contains(' '))
+ assertEquals("hi", resolved)
+ }
+
+ @Test
+ fun testSingleLetterFlickFallback() {
+ val keys = listOf(
+ GestureTypingHelper.KeyBoundingBox('a', 0f, 0f, 40f, 40f),
+ GestureTypingHelper.KeyBoundingBox('b', 40f, 0f, 80f, 40f)
+ )
+ // User moves finger slightly within key 'a' (from 10,10 to 20,20)
+ val points = listOf(
+ GestureTypingHelper.PathPoint(10f, 10f),
+ GestureTypingHelper.PathPoint(20f, 20f)
+ )
+ val resolved = GestureTypingHelper.resolveTrajectoryToChars(points, keys)
+ assertEquals("a", resolved)
+ }
+
+ @Test
+ fun testGestureWordAlternativeCandidateReplacement() {
+ // Simulates the logic inside KeyboardIME.handleCandidateSelected when replacing a gesture word
+ var textInEditor = "halo "
+ val lastGestureWord = "halo"
+ val selectedAlternative = "hallo"
+
+ val expectedWithSpace = "$lastGestureWord "
+ if (textInEditor.endsWith(expectedWithSpace)) {
+ textInEditor = textInEditor.dropLast(expectedWithSpace.length)
+ }
+ textInEditor += "$selectedAlternative "
+
+ assertEquals("hallo ", textInEditor)
+ assertFalse("Should not concatenate both words", textInEditor.contains("halo hallo"))
+ }
+
+ @Test
+ fun testGestureWordBackspaceUndo() {
+ // Simulates the single-tap Backspace undo for swiped words
+ var textInEditor = "halo "
+ val lastGestureWord = "halo"
+
+ val expectedWithSpace = "$lastGestureWord "
+ if (textInEditor.endsWith(expectedWithSpace)) {
+ textInEditor = textInEditor.dropLast(expectedWithSpace.length)
+ }
+
+ assertEquals("", textInEditor)
+ }
+
+ @Test
+ fun testToggleViewModelGestureSync() {
+ val viewModel = com.frogobox.appkeyboard.ui.toggle.ToggleViewModel(fakePref, keyboardUtil)
+
+ // Initially enabled
+ assertTrue(keyboardUtil.isGestureTypingEnabled())
+ assertTrue(keyboardUtil.isGestureSwipeShortcutsEnabled())
+
+ // Toggle OFF via ToggleViewModel
+ viewModel.switchToggle(KeyboardFeatureType.GESTURE_SWIPE.id, false)
+ assertFalse(keyboardUtil.isGestureTypingEnabled())
+ assertFalse(keyboardUtil.isGestureSwipeShortcutsEnabled())
+
+ // Toggle ON via ToggleViewModel
+ viewModel.switchToggle(KeyboardFeatureType.GESTURE_SWIPE.id, true)
+ assertTrue(keyboardUtil.isGestureTypingEnabled())
+ assertTrue(keyboardUtil.isGestureSwipeShortcutsEnabled())
+ }
+
+ private class FakePreferenceDelegates : PreferenceDelegates {
+ private val stringMap = mutableMapOf()
+ private val intMap = mutableMapOf()
+ private val boolMap = mutableMapOf()
+ private val floatMap = mutableMapOf()
+ private val longMap = mutableMapOf()
+
+ override fun savePrefString(key: String, value: String) { stringMap[key] = value }
+ override fun getPrefString(key: String): String = stringMap[key] ?: ""
+ override fun getPrefString(key: String, defaultValue: String): String = stringMap[key] ?: defaultValue
+
+ override fun savePrefInt(key: String, value: Int) { intMap[key] = value }
+ override fun getPrefInt(key: String): Int = intMap[key] ?: 0
+ override fun getPrefInt(key: String, defaultValue: Int): Int = intMap[key] ?: defaultValue
+
+ override fun savePrefBoolean(key: String, value: Boolean) { boolMap[key] = value }
+ override fun getPrefBoolean(key: String): Boolean = boolMap[key] ?: false
+ override fun getPrefBoolean(key: String, defaultValue: Boolean): Boolean = boolMap[key] ?: defaultValue
+
+ override fun savePrefFloat(key: String, value: Float) { floatMap[key] = value }
+ override fun getPrefFloat(key: String): Float = floatMap[key] ?: 0f
+ override fun getPrefFloat(key: String, defaultValue: Float): Float = floatMap[key] ?: defaultValue
+
+ override fun savePrefLong(key: String, value: Long) { longMap[key] = value }
+ override fun getPrefLong(key: String): Long = longMap[key] ?: 0L
+ override fun getPrefLong(key: String, defaultValue: Long): Long = longMap[key] ?: defaultValue
+
+ override fun deletePref(key: String) {
+ stringMap.remove(key)
+ intMap.remove(key)
+ boolMap.remove(key)
+ floatMap.remove(key)
+ longMap.remove(key)
+ }
+
+ override fun nukePref() {
+ stringMap.clear()
+ intMap.clear()
+ boolMap.clear()
+ floatMap.clear()
+ longMap.clear()
+ }
+
+ override fun save(key: String, value: T) {
+ when (value) {
+ is String -> savePrefString(key, value)
+ is Int -> savePrefInt(key, value)
+ is Boolean -> savePrefBoolean(key, value)
+ is Float -> savePrefFloat(key, value)
+ is Long -> savePrefLong(key, value)
+ }
+ }
+
+ override fun get(key: String, defaultValue: T): T {
+ return defaultValue
+ }
+ }
+}
diff --git a/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt b/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt
index 7939122..ce80476 100644
--- a/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt
+++ b/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt
@@ -16,7 +16,7 @@ class ThemeTest {
@Test
fun testKeyboardThemeTypeMapping() {
val themes = KeyboardThemeType.entries.map { it.mapToModel() }
- assertEquals(11, themes.size)
+ assertEquals(12, themes.size)
val defaultTheme = themes.first { it.name == "Default" }
assertEquals(ThemeType.COLOR, defaultTheme.themType)
@@ -43,6 +43,7 @@ class ThemeTest {
assertEquals(KeyboardThemeType.CYAN, KeyboardThemeType from "CYAN")
assertEquals(KeyboardThemeType.PINK, KeyboardThemeType from "PINK")
assertEquals(KeyboardThemeType.YELLOW, KeyboardThemeType from "YELLOW")
+ assertEquals(KeyboardThemeType.HIGH_CONTRAST, KeyboardThemeType from "HIGH_CONTRAST")
assertEquals(KeyboardThemeType.IMAGE_BG_DARK, KeyboardThemeType from "IMAGE_BG_DARK")
// Unknown fallback to DEFAULT
assertEquals(KeyboardThemeType.DEFAULT, KeyboardThemeType from "UNKNOWN_THEME")
@@ -96,7 +97,7 @@ class ThemeTest {
@Test
fun testThemeIsDarkCategorization() {
val mappedThemes = KeyboardThemeType.entries.map { it.mapToModel() }
- assertEquals(11, mappedThemes.size)
+ assertEquals(12, mappedThemes.size)
// Light themes must have isDark == false
val defaultTheme = mappedThemes.first { it.name == "Default" }
@@ -115,6 +116,7 @@ class ThemeTest {
"Sunset Orange",
"Nordic Cyan",
"Sakura Pink",
+ "High Contrast (WCAG AAA)",
"Wallpaper"
)
diff --git a/app/src/test/java/com/frogobox/appkeyboard/Tier1AndTier2EnhancementsTest.kt b/app/src/test/java/com/frogobox/appkeyboard/Tier1AndTier2EnhancementsTest.kt
new file mode 100644
index 0000000..de5cc27
--- /dev/null
+++ b/app/src/test/java/com/frogobox/appkeyboard/Tier1AndTier2EnhancementsTest.kt
@@ -0,0 +1,107 @@
+package com.frogobox.appkeyboard
+
+import android.view.inputmethod.EditorInfo
+import com.frogobox.appkeyboard.model.KeyboardFeatureType
+import com.frogobox.appkeyboard.services.KeyboardUtil
+import com.frogobox.appkeyboard.ui.keyboard.root.KeyboardPanelState
+import com.frogobox.appkeyboard.util.KeyboardRichMediaHelper
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Unit test suite for TASK-028:
+ * - Dedicated Number Row & Secondary Hint Glyphs (Tier 1)
+ * - One-Handed Docking Mode (Tier 1)
+ * - Dynamic Theming & Rich Media Commit API (Tier 2)
+ */
+class Tier1AndTier2EnhancementsTest {
+
+ @Test
+ fun testKeyboardFeatureType_includesNumberRowAndOneHanded() {
+ val numberRow = KeyboardFeatureType.NUMBER_ROW
+ assertEquals("menu_number_row", numberRow.id)
+ assertEquals("Number Row", numberRow.text)
+
+ val oneHanded = KeyboardFeatureType.ONE_HANDED
+ assertEquals("menu_one_handed", oneHanded.id)
+ assertEquals("One-Handed", oneHanded.text)
+
+ // Verify reverse lookup
+ assertEquals(KeyboardFeatureType.NUMBER_ROW, KeyboardFeatureType.from("menu_number_row"))
+ assertEquals(KeyboardFeatureType.ONE_HANDED, KeyboardFeatureType.from("menu_one_handed"))
+ }
+
+ @Test
+ fun testKeyboardPanelState_actionFeaturesDoNotOpenPanels() {
+ // Dedicated number row and one-handed mode are action features that modify main layout
+ assertNull(KeyboardPanelState.fromFeature(KeyboardFeatureType.NUMBER_ROW))
+ assertNull(KeyboardPanelState.fromFeature(KeyboardFeatureType.ONE_HANDED))
+ assertNull(KeyboardPanelState.fromFeature(KeyboardFeatureType.SUGGESTION))
+
+ // Content features open overlays
+ assertEquals(KeyboardPanelState.CLIPBOARD, KeyboardPanelState.fromFeature(KeyboardFeatureType.CLIPBOARD))
+ assertEquals(KeyboardPanelState.TEXT_EDIT, KeyboardPanelState.fromFeature(KeyboardFeatureType.TEXT_EDIT))
+ assertEquals(KeyboardPanelState.AUTO_TEXT, KeyboardPanelState.fromFeature(KeyboardFeatureType.AUTO_TEXT))
+ }
+
+ @Test
+ fun testDefaultFeatureTypes_includesTier1Features() {
+ assertTrue(KeyboardUtil.DEFAULT_FEATURE_TYPES.contains(KeyboardFeatureType.NUMBER_ROW))
+ assertTrue(KeyboardUtil.DEFAULT_FEATURE_TYPES.contains(KeyboardFeatureType.ONE_HANDED))
+ assertTrue(KeyboardUtil.DEFAULT_FEATURE_TYPES.contains(KeyboardFeatureType.SUGGESTION))
+ assertTrue(KeyboardUtil.DEFAULT_FEATURE_TYPES.contains(KeyboardFeatureType.CLIPBOARD))
+ }
+
+ @Test
+ fun testOneHandedMode_stateTransitions() {
+ fun getNextMode(currentMode: String): String {
+ return when (currentMode) {
+ "OFF" -> "RIGHT"
+ "RIGHT" -> "LEFT"
+ else -> "OFF"
+ }
+ }
+
+ var mode = "OFF"
+ mode = getNextMode(mode)
+ assertEquals("RIGHT", mode)
+
+ mode = getNextMode(mode)
+ assertEquals("LEFT", mode)
+
+ mode = getNextMode(mode)
+ assertEquals("OFF", mode)
+ }
+
+ @Test
+ fun testRichMediaHelper_nullEditorInfoReturnsFalse() {
+ assertFalse(KeyboardRichMediaHelper.isMimeTypeSupported(null, "image/gif"))
+ assertFalse(KeyboardRichMediaHelper.isMimeTypeSupported(null, "image/webp"))
+ }
+
+ @Test
+ fun testRichMediaHelper_supportedMimeTypes() {
+ val editorInfo = EditorInfo().apply {
+ contentMimeTypes = arrayOf("image/gif", "image/png", "image/webp")
+ }
+
+ assertTrue(KeyboardRichMediaHelper.isMimeTypeSupported(editorInfo, "image/gif"))
+ assertTrue(KeyboardRichMediaHelper.isMimeTypeSupported(editorInfo, "image/png"))
+ assertTrue(KeyboardRichMediaHelper.isMimeTypeSupported(editorInfo, "image/webp"))
+ assertFalse(KeyboardRichMediaHelper.isMimeTypeSupported(editorInfo, "video/mp4"))
+ }
+
+ @Test
+ fun testRichMediaHelper_wildcardMimeTypes() {
+ val editorInfo = EditorInfo().apply {
+ contentMimeTypes = arrayOf("image/*")
+ }
+
+ assertTrue(KeyboardRichMediaHelper.isMimeTypeSupported(editorInfo, "image/gif"))
+ assertTrue(KeyboardRichMediaHelper.isMimeTypeSupported(editorInfo, "image/jpeg"))
+ assertFalse(KeyboardRichMediaHelper.isMimeTypeSupported(editorInfo, "text/plain"))
+ }
+}
diff --git a/app/src/test/java/com/frogobox/appkeyboard/Tier3SmartProductivityTest.kt b/app/src/test/java/com/frogobox/appkeyboard/Tier3SmartProductivityTest.kt
new file mode 100644
index 0000000..c44075e
--- /dev/null
+++ b/app/src/test/java/com/frogobox/appkeyboard/Tier3SmartProductivityTest.kt
@@ -0,0 +1,203 @@
+package com.frogobox.appkeyboard
+
+import com.frogobox.appkeyboard.model.ClipboardItem
+import com.frogobox.appkeyboard.model.KeyboardFeatureType
+import com.frogobox.appkeyboard.ui.keyboard.clipboard.ClipboardCategoryFilter
+import com.frogobox.appkeyboard.ui.keyboard.clipboard.ClipboardFilterHelper
+import com.frogobox.appkeyboard.ui.keyboard.root.KeyboardPanelState
+import com.frogobox.appkeyboard.util.AiTextTransformer
+import com.frogobox.appkeyboard.util.AiTextTransformer.AiToneStyle
+import com.frogobox.appkeyboard.util.VoiceTypingHelper
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Comprehensive Unit Test Suite for Tier 3 Smart Productivity Suite:
+ * - AC-01: AI Writing & Tone Transformer (Modes, Dictionaries, Capitalization, Edge Cases)
+ * - AC-02: Voice Typing States, Feature Mapping & Navigation Interop
+ * - AC-03: Smart Searchable Clipboard & Category Filter Engine
+ * - AC-04: Incognito Privacy Guard & Zero Suppression Mandate
+ */
+class Tier3SmartProductivityTest {
+
+ // -------------------------------------------------------------------------
+ // 1. AC-01: AI Writing & Tone Assistant Tests
+ // -------------------------------------------------------------------------
+
+ @Test
+ fun testAiToneStyleEnumEntries() {
+ val tones = AiToneStyle.entries
+ assertEquals(6, tones.size)
+ assertTrue(tones.contains(AiToneStyle.POLISH))
+ assertTrue(tones.contains(AiToneStyle.FORMAL))
+ assertTrue(tones.contains(AiToneStyle.POLITE))
+ assertTrue(tones.contains(AiToneStyle.CASUAL))
+ assertTrue(tones.contains(AiToneStyle.SLANG))
+ assertTrue(tones.contains(AiToneStyle.SUMMARIZE))
+ }
+
+ @Test
+ fun testAiTransformerEmptyOrBlankReturnsEmpty() {
+ assertEquals("", AiTextTransformer.transform("", AiToneStyle.POLISH))
+ assertEquals("", AiTextTransformer.transform(" ", AiToneStyle.FORMAL))
+ assertEquals("", AiTextTransformer.transform("\n\t", AiToneStyle.CASUAL))
+ }
+
+ @Test
+ fun testAiTransformerPolishNormalization() {
+ val input = "halo , apa kabar ? sy mau pergi dgn dia"
+ val polished = AiTextTransformer.transform(input, AiToneStyle.POLISH)
+
+ // Verifies spacing before punctuation normalized and abbreviations expanded
+ assertTrue("Expected 'Halo' capitalization", polished.startsWith("Halo"))
+ assertTrue("Expected 'saya'", polished.contains("saya", ignoreCase = true))
+ assertTrue("Expected 'dengan'", polished.contains("dengan", ignoreCase = true))
+ assertFalse("Comma spacing should be fixed", polished.contains("halo ,"))
+ }
+
+ @Test
+ fun testAiTransformerFormalStyle() {
+ val input = "gua gak bisa dateng hari ini, makasih ya"
+ val formal = AiTextTransformer.transform(input, AiToneStyle.FORMAL)
+
+ assertTrue("Expected formal pronoun 'saya'", formal.contains("saya", ignoreCase = true))
+ assertTrue("Expected formal negation 'tidak'", formal.contains("tidak", ignoreCase = true))
+ assertTrue("Expected formal appreciation 'terima kasih'", formal.contains("terima kasih", ignoreCase = true))
+ assertFalse("Informal 'gua' should be replaced", formal.contains("gua", ignoreCase = true))
+ assertFalse("Informal 'gak' should be replaced", formal.contains("gak", ignoreCase = true))
+ assertTrue("Should end with sentence punctuation", formal.endsWith("."))
+ }
+
+ @Test
+ fun testAiTransformerPoliteStyle() {
+ val input = "kirim laporannya sekarang"
+ val polite = AiTextTransformer.transform(input, AiToneStyle.POLITE)
+
+ assertTrue("Expected polite greeting or prefix", polite.contains("Mohon") || polite.contains("Dengan hormat"))
+ assertTrue("Expected polite closing", polite.contains("Terima kasih"))
+ }
+
+ @Test
+ fun testAiTransformerCasualStyle() {
+ val input = "Saya tidak setuju dengan keputusan ini"
+ val casual = AiTextTransformer.transform(input, AiToneStyle.CASUAL)
+
+ assertTrue("Expected casual pronoun 'aku'", casual.contains("aku", ignoreCase = true))
+ assertTrue("Expected casual negation 'nggak'", casual.contains("nggak", ignoreCase = true))
+ assertTrue("Expected casual particle 'ya!' or exclamation", casual.endsWith("ya!") || casual.endsWith("!"))
+ }
+
+ @Test
+ fun testAiTransformerSlangStyle() {
+ val input = "menurut saya proyek ini sangat keren"
+ val slang = AiTextTransformer.transform(input, AiToneStyle.SLANG)
+
+ assertTrue("Expected slang 'honestly'", slang.lowercase().contains("honestly"))
+ assertTrue("Expected slang 'parah abis' or 'gokil'", slang.contains("parah abis") || slang.contains("gokil"))
+ assertTrue("Expected trailing flame emoji", slang.endsWith("🔥"))
+ }
+
+ @Test
+ fun testAiTransformerSummarizeStyle() {
+ val input = "Pada dasarnya kita harus menyelesaikan modul ini tepat waktu. Sebenarnya ada beberapa kendala kecil namun dapat diatasi."
+ val summary = AiTextTransformer.transform(input, AiToneStyle.SUMMARIZE)
+
+ // Filler words stripped
+ assertFalse("Filler 'Pada dasarnya' should be removed", summary.contains("Pada dasarnya"))
+ assertFalse("Filler 'Sebenarnya' should be removed", summary.contains("Sebenarnya"))
+ assertTrue("Bullet point indicator should be present", summary.startsWith("• "))
+ }
+
+ // -------------------------------------------------------------------------
+ // 2. AC-02: Voice Typing Dictation & State Tests
+ // -------------------------------------------------------------------------
+
+ @Test
+ fun testVoiceTypingHelperStates() {
+ val states = VoiceTypingHelper.State.entries
+ assertEquals(5, states.size)
+ assertTrue(states.contains(VoiceTypingHelper.State.IDLE))
+ assertTrue(states.contains(VoiceTypingHelper.State.INITIALIZING))
+ assertTrue(states.contains(VoiceTypingHelper.State.LISTENING))
+ assertTrue(states.contains(VoiceTypingHelper.State.PROCESSING))
+ assertTrue(states.contains(VoiceTypingHelper.State.ERROR))
+ }
+
+ @Test
+ fun testFeatureTypeAndPanelStateMappings() {
+ // AI Assistant
+ val aiFeature = KeyboardFeatureType.from("menu_ai_assistant")
+ assertEquals(KeyboardFeatureType.AI_ASSISTANT, aiFeature)
+ assertEquals("AI Assistant", aiFeature.text)
+ assertEquals(KeyboardPanelState.AI_ASSISTANT, KeyboardPanelState.fromFeature(aiFeature))
+
+ // Voice Typing
+ val voiceFeature = KeyboardFeatureType.from("menu_voice_typing")
+ assertEquals(KeyboardFeatureType.VOICE_TYPING, voiceFeature)
+ assertEquals("Voice Typing", voiceFeature.text)
+ // Voice typing activates a floating banner, returning null panel state
+ assertNull(KeyboardPanelState.fromFeature(voiceFeature))
+ }
+
+ // -------------------------------------------------------------------------
+ // 3. AC-03: Smart Searchable Clipboard & Filter Engine Tests
+ // -------------------------------------------------------------------------
+
+ @Test
+ fun testClipboardFilterHelperIsNumberOrCode() {
+ // Numbers, codes, OTPs, phone numbers
+ assertTrue(ClipboardFilterHelper.isNumberOrCode("123456"))
+ assertTrue(ClipboardFilterHelper.isNumberOrCode("+6281234567890"))
+ assertTrue(ClipboardFilterHelper.isNumberOrCode("0812-3456-7890"))
+ assertTrue(ClipboardFilterHelper.isNumberOrCode("OTP: 894021"))
+ assertTrue(ClipboardFilterHelper.isNumberOrCode("BCA 8839201928"))
+
+ // Standard narrative sentences
+ assertFalse(ClipboardFilterHelper.isNumberOrCode("Halo, selamat pagi rekan sekalian!"))
+ assertFalse(ClipboardFilterHelper.isNumberOrCode("Rapat koordinasi mingguan besok"))
+ assertFalse(ClipboardFilterHelper.isNumberOrCode(""))
+ }
+
+ @Test
+ fun testClipboardFilterHelperCategoryFiltering() {
+ val sampleItems = listOf(
+ ClipboardItem(id = "1", text = "Halo apa kabar", timestamp = 1000L, isPinned = true),
+ ClipboardItem(id = "2", text = "Kode verifikasi 948201", timestamp = 2000L, isPinned = false),
+ ClipboardItem(id = "3", text = "+6281299887766", timestamp = 3000L, isPinned = true),
+ ClipboardItem(id = "4", text = "Catatan belanja bulanan", timestamp = 4000L, isPinned = false)
+ )
+
+ // 1. ALL
+ val allClips = ClipboardFilterHelper.filterItems(sampleItems, "", ClipboardCategoryFilter.ALL)
+ assertEquals(4, allClips.size)
+
+ // 2. PINNED
+ val pinnedClips = ClipboardFilterHelper.filterItems(sampleItems, "", ClipboardCategoryFilter.PINNED)
+ assertEquals(2, pinnedClips.size)
+ assertTrue(pinnedClips.all { it.isPinned })
+
+ // 3. NUMBER_CODE
+ val codeClips = ClipboardFilterHelper.filterItems(sampleItems, "", ClipboardCategoryFilter.NUMBER_CODE)
+ assertEquals(2, codeClips.size)
+ assertTrue(codeClips.any { it.id == "2" })
+ assertTrue(codeClips.any { it.id == "3" })
+
+ // 4. TEXT
+ val textClips = ClipboardFilterHelper.filterItems(sampleItems, "", ClipboardCategoryFilter.TEXT)
+ assertEquals(2, textClips.size)
+ assertTrue(textClips.any { it.id == "1" })
+ assertTrue(textClips.any { it.id == "4" })
+
+ // 5. Query Search Filter
+ val queryClips = ClipboardFilterHelper.filterItems(sampleItems, "belanja", ClipboardCategoryFilter.ALL)
+ assertEquals(1, queryClips.size)
+ assertEquals("4", queryClips[0].id)
+
+ // 6. Non-matching query
+ val emptyClips = ClipboardFilterHelper.filterItems(sampleItems, "XYZ999", ClipboardCategoryFilter.ALL)
+ assertTrue(emptyClips.isEmpty())
+ }
+}
diff --git a/app/src/test/java/com/frogobox/appkeyboard/Tier4FlagshipEnhancementsTest.kt b/app/src/test/java/com/frogobox/appkeyboard/Tier4FlagshipEnhancementsTest.kt
new file mode 100644
index 0000000..41151ae
--- /dev/null
+++ b/app/src/test/java/com/frogobox/appkeyboard/Tier4FlagshipEnhancementsTest.kt
@@ -0,0 +1,278 @@
+package com.frogobox.appkeyboard
+
+import android.view.KeyEvent
+import android.view.inputmethod.InputConnection
+import com.frogobox.appkeyboard.model.AutoTextEntity
+import com.frogobox.appkeyboard.model.ClipboardItem
+import com.frogobox.appkeyboard.model.KeyboardThemeType
+import com.frogobox.appkeyboard.util.ClipboardSecurityGuard
+import com.frogobox.appkeyboard.util.HapticFeedbackHelper
+import com.frogobox.appkeyboard.util.HardwareKeyboardHelper
+import com.frogobox.appkeyboard.util.InlineTextExpanderHelper
+import com.frogobox.appkeyboard.util.KeyboardAccessibilityHelper
+import com.frogobox.appkeyboard.util.SmartPunctuationHelper
+import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Comprehensive Unit Test Suite verifying Tier 4 Flagship Keyboard Enhancements (TASK-030):
+ * 1. Smart Punctuation (Double-Space Period & Auto-Capitalization)
+ * 2. Inline Text Expander (Teks Pintas in Candidate Strip)
+ * 3. Clipboard Security Guard & Sensitive OTP Hygiene
+ * 4. Hardware/Bluetooth Keyboard Interoperability
+ * 5. TalkBack Phonetic Accessibility
+ * 6. High-Contrast WCAG AAA Theme Tokens
+ * 7. Haptic Intensity Profiles
+ */
+class Tier4FlagshipEnhancementsTest {
+
+ // =========================================================================
+ // 1. SMART PUNCTUATION TESTS
+ // =========================================================================
+
+ @Test
+ fun testDoubleSpacePeriod_withinTimeout_triggersReplacementAndAutoShift() {
+ val now = 10_000L
+ val lastSpaceTime = 9_700L // 300ms elapsed (< 500ms threshold)
+ val textBefore = "Halo "
+
+ val result = SmartPunctuationHelper.checkDoubleSpacePeriod(
+ lastSpaceTime = lastSpaceTime,
+ currentTime = now,
+ textBeforeCursor = textBefore
+ )
+
+ assertTrue("Double space should be handled", result.isHandled)
+ assertEquals(1, result.textToDeleteLength)
+ assertEquals(". ", result.replacementText)
+ assertTrue(result.shouldAutoShift)
+ }
+
+ @Test
+ fun testDoubleSpacePeriod_exceedingTimeout_isNotHandled() {
+ val now = 10_000L
+ val lastSpaceTime = 9_200L // 800ms elapsed (> 500ms threshold)
+ val textBefore = "Halo "
+
+ val result = SmartPunctuationHelper.checkDoubleSpacePeriod(
+ lastSpaceTime = lastSpaceTime,
+ currentTime = now,
+ textBeforeCursor = textBefore
+ )
+
+ assertFalse("Double space exceeding timeout should not trigger", result.isHandled)
+ }
+
+ @Test
+ fun testDoubleSpacePeriod_alreadyFollowsPeriod_doesNotDuplicate() {
+ val now = 10_000L
+ val lastSpaceTime = 9_800L
+ val textBefore = "Halo. " // Already has period and space
+
+ val result = SmartPunctuationHelper.checkDoubleSpacePeriod(
+ lastSpaceTime = lastSpaceTime,
+ currentTime = now,
+ textBeforeCursor = textBefore
+ )
+
+ assertFalse("Should not add another period if already punctuated", result.isHandled)
+ }
+
+ @Test
+ fun testShouldAutoCapitalize_sentenceBoundaries() {
+ // Empty / start of input
+ assertTrue(SmartPunctuationHelper.shouldAutoCapitalize(""))
+ assertTrue(SmartPunctuationHelper.shouldAutoCapitalize(null))
+
+ // After newline
+ assertTrue(SmartPunctuationHelper.shouldAutoCapitalize("Baris pertama\n"))
+
+ // After period and space
+ assertTrue(SmartPunctuationHelper.shouldAutoCapitalize("Selamat pagi. "))
+
+ // After question mark and space
+ assertTrue(SmartPunctuationHelper.shouldAutoCapitalize("Apa kabar? "))
+
+ // After exclamation mark and space
+ assertTrue(SmartPunctuationHelper.shouldAutoCapitalize("Luar biasa! "))
+
+ // Mid-sentence should NOT auto capitalize
+ assertFalse(SmartPunctuationHelper.shouldAutoCapitalize("Sedang mengetik kata "))
+ assertFalse(SmartPunctuationHelper.shouldAutoCapitalize("huruf"))
+ }
+
+ // =========================================================================
+ // 2. INLINE TEXT EXPANDER TESTS
+ // =========================================================================
+
+ @Test
+ fun testInlineTextExpander_builtInShortcuts() {
+ val matchOtw = InlineTextExpanderHelper.findExpansion("otw")
+ assertNotNull(matchOtw)
+ assertEquals("On the way!", matchOtw?.expandedText)
+
+ val matchBrb = InlineTextExpanderHelper.findExpansion("BRB")
+ assertNotNull(matchBrb)
+ assertEquals("Be right back", matchBrb?.expandedText)
+
+ val matchAlamat = InlineTextExpanderHelper.findExpansion("!alamat")
+ assertNotNull(matchAlamat)
+ assertTrue(matchAlamat?.expandedText?.contains("Sudirman") == true)
+ }
+
+ @Test
+ fun testInlineTextExpander_customAutoTextPriority() {
+ val customList = listOf(
+ AutoTextEntity(id = 1, title = "otw", body = "Saya sedang meluncur ke lokasi pertemuan")
+ )
+
+ val match = InlineTextExpanderHelper.findExpansion("otw", customList)
+ assertNotNull(match)
+ assertEquals("Saya sedang meluncur ke lokasi pertemuan", match?.expandedText)
+ }
+
+ @Test
+ fun testInlineTextExpander_unmatchedWord_returnsNull() {
+ val match = InlineTextExpanderHelper.findExpansion("randomkata")
+ assertNull(match)
+ }
+
+ @Test
+ fun testInlineTextExpander_formatChipLabel() {
+ val match = InlineTextExpanderHelper.TextExpansionMatch("otw", "On the way!", "OTW")
+ val label = InlineTextExpanderHelper.formatChipLabel(match)
+ assertTrue(label.contains("OTW"))
+ assertTrue(label.contains("On the way!"))
+ }
+
+ // =========================================================================
+ // 3. CLIPBOARD SECURITY GUARD TESTS
+ // =========================================================================
+
+ @Test
+ fun testClipboardSecurityGuard_detectsSensitiveContent() {
+ // Verification OTPs
+ assertTrue(ClipboardSecurityGuard.isSensitiveContent("123456"))
+ assertTrue(ClipboardSecurityGuard.isSensitiveContent("982301"))
+ assertTrue(ClipboardSecurityGuard.isSensitiveContent("Kode OTP Anda: 7129"))
+
+ // Credit Cards
+ assertTrue(ClipboardSecurityGuard.isSensitiveContent("4532-1234-5678-9012"))
+
+ // Token
+ assertTrue(ClipboardSecurityGuard.isSensitiveContent("Bearer eyJhbGciOiJIUzI1NiJ9"))
+
+ // Normal text should not be flagged as sensitive
+ assertFalse(ClipboardSecurityGuard.isSensitiveContent("Halo semuanya apa kabar"))
+ assertFalse(ClipboardSecurityGuard.isSensitiveContent("https://google.com"))
+ }
+
+ @Test
+ fun testClipboardSecurityGuard_filterExpiredClips_preservesPinnedItems() {
+ val now = 100_000_000L
+ val expiredTimestamp = now - (20 * 60 * 1000L) // 20 minutes old (expired)
+ val freshTimestamp = now - (5 * 60 * 1000L) // 5 minutes old (valid)
+
+ val clips = listOf(
+ ClipboardItem(id = "1", text = "Old Unpinned", timestamp = expiredTimestamp, isPinned = false),
+ ClipboardItem(id = "2", text = "Old But Pinned", timestamp = expiredTimestamp, isPinned = true),
+ ClipboardItem(id = "3", text = "Fresh Unpinned", timestamp = freshTimestamp, isPinned = false)
+ )
+
+ val filtered = ClipboardSecurityGuard.filterExpiredClips(clips, now)
+
+ assertEquals(2, filtered.size)
+ assertTrue(filtered.any { it.id == "2" }) // Pinned is preserved
+ assertTrue(filtered.any { it.id == "3" }) // Fresh is preserved
+ assertFalse(filtered.any { it.id == "1" }) // Expired unpinned is purged
+ }
+
+ @Test
+ fun testClipboardSecurityGuard_maskSensitiveText() {
+ val maskedOtp = ClipboardSecurityGuard.maskSensitiveText("123456")
+ assertEquals("****56", maskedOtp)
+
+ val normalText = ClipboardSecurityGuard.maskSensitiveText("Catatan belanja")
+ assertEquals("Catatan belanja", normalText)
+ }
+
+ // =========================================================================
+ // 4. HARDWARE KEYBOARD INTEROP TESTS
+ // =========================================================================
+
+ @Test
+ fun testHardwareKeyboardHelper_shortcutsAndNavigation() {
+ // Supported Ctrl shortcuts
+ assertTrue(HardwareKeyboardHelper.isShortcutAction(KeyEvent.KEYCODE_C, isCtrl = true))
+ assertTrue(HardwareKeyboardHelper.isShortcutAction(KeyEvent.KEYCODE_V, isCtrl = true))
+ assertTrue(HardwareKeyboardHelper.isShortcutAction(KeyEvent.KEYCODE_X, isCtrl = true))
+ assertTrue(HardwareKeyboardHelper.isShortcutAction(KeyEvent.KEYCODE_A, isCtrl = true))
+ assertTrue(HardwareKeyboardHelper.isShortcutAction(KeyEvent.KEYCODE_Z, isCtrl = true))
+
+ // Without Ctrl, regular letters are not treated as shortcuts
+ assertFalse(HardwareKeyboardHelper.isShortcutAction(KeyEvent.KEYCODE_C, isCtrl = false))
+ assertFalse(HardwareKeyboardHelper.isShortcutAction(KeyEvent.KEYCODE_V, isCtrl = false))
+ assertFalse(HardwareKeyboardHelper.isShortcutAction(KeyEvent.KEYCODE_ENTER, isCtrl = true))
+
+ // Navigation keys
+ assertTrue(HardwareKeyboardHelper.isNavigationKey(KeyEvent.KEYCODE_DPAD_LEFT))
+ assertTrue(HardwareKeyboardHelper.isNavigationKey(KeyEvent.KEYCODE_DPAD_RIGHT))
+ assertTrue(HardwareKeyboardHelper.isNavigationKey(KeyEvent.KEYCODE_FORWARD_DEL))
+ assertFalse(HardwareKeyboardHelper.isNavigationKey(KeyEvent.KEYCODE_SPACE))
+
+ // When InputConnection is null, handleHardwareKeyEvent returns false safely
+ assertFalse(HardwareKeyboardHelper.handleHardwareKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_C), null))
+ }
+
+ // =========================================================================
+ // 5. ACCESSIBILITY PHONETIC TESTS
+ // =========================================================================
+
+ @Test
+ fun testKeyboardAccessibilityHelper_phoneticSpelling() {
+ val descA = KeyboardAccessibilityHelper.getKeyDescription('a'.code)
+ assertTrue(descA.contains("Alfa"))
+
+ val descB = KeyboardAccessibilityHelper.getKeyDescription('b'.code)
+ assertTrue(descB.contains("Bravo"))
+
+ val descDelete = KeyboardAccessibilityHelper.getKeyDescription(ItemMainKeyboard.KEYCODE_DELETE)
+ assertTrue(descDelete.contains("Hapus"))
+
+ val descShift = KeyboardAccessibilityHelper.getKeyDescription(ItemMainKeyboard.KEYCODE_SHIFT, isShifted = true)
+ assertTrue(descShift.contains("Shift"))
+ }
+
+ @Test
+ fun testKeyboardAccessibilityHelper_modeAnnouncements() {
+ val splitAnnounce = KeyboardAccessibilityHelper.getModeAnnouncement("SPLIT")
+ assertTrue(splitAnnounce.contains("terbelah"))
+
+ val incognitoAnnounce = KeyboardAccessibilityHelper.getModeAnnouncement("INCOGNITO")
+ assertTrue(incognitoAnnounce.contains("penyamaran"))
+ }
+
+ // =========================================================================
+ // 6. HIGH-CONTRAST THEME & HAPTIC TESTS
+ // =========================================================================
+
+ @Test
+ fun testHighContrastTheme_isRegisteredAndDark() {
+ val themeType = KeyboardThemeType.HIGH_CONTRAST
+ assertEquals("High Contrast (WCAG AAA)", themeType.label)
+ assertTrue("High contrast theme must be categorized as dark theme", themeType.isDark)
+ }
+
+ @Test
+ fun testHapticFeedbackHelper_intensityProfiles() {
+ assertEquals(HapticFeedbackHelper.HapticIntensity.OFF, HapticFeedbackHelper.fromString("OFF"))
+ assertEquals(HapticFeedbackHelper.HapticIntensity.SUBTLE, HapticFeedbackHelper.fromString("SUBTLE"))
+ assertEquals(HapticFeedbackHelper.HapticIntensity.MEDIUM, HapticFeedbackHelper.fromString("MEDIUM"))
+ assertEquals(HapticFeedbackHelper.HapticIntensity.STRONG, HapticFeedbackHelper.fromString("STRONG"))
+ }
+}
diff --git a/app/src/test/java/com/frogobox/appkeyboard/Tier5FlagshipUltimateSuiteTest.kt b/app/src/test/java/com/frogobox/appkeyboard/Tier5FlagshipUltimateSuiteTest.kt
new file mode 100644
index 0000000..58b72d6
--- /dev/null
+++ b/app/src/test/java/com/frogobox/appkeyboard/Tier5FlagshipUltimateSuiteTest.kt
@@ -0,0 +1,242 @@
+package com.frogobox.appkeyboard
+
+import com.frogobox.appkeyboard.model.AutoTextEntity
+import com.frogobox.appkeyboard.model.ClipboardItem
+import com.frogobox.appkeyboard.ui.keyboard.textedit.TextEditAction
+import com.frogobox.appkeyboard.util.GestureTypingHelper
+import com.frogobox.appkeyboard.util.HapticFeedbackHelper
+import com.frogobox.appkeyboard.util.KeyboardBackupRestoreHelper
+import com.frogobox.appkeyboard.util.KeyboardHeightHelper
+import com.frogobox.appkeyboard.util.KeyboardSetupWizardHelper
+import com.frogobox.appkeyboard.util.SmartCalculatorHelper
+import com.frogobox.appkeyboard.util.UserDictionaryHelper
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * QA verification test suite for Tier 5 Flagship Ultimate Suite (TASK-031).
+ * Tests all Acceptance Criteria across Ergonomics, Onboarding, Backup/Restore,
+ * Gesture Typing, On-Device Learning Dictionary, Inline Math Calculator, and Safety.
+ */
+class Tier5FlagshipUltimateSuiteTest {
+
+ // --- AC-01: Keyboard Height Resizer & Bottom Chin Offset ---
+
+ @Test
+ fun testKeyboardHeightHelper_scaleAndOffsetCalculations() {
+ val baseHeight = 240f
+
+ val compactHeight = KeyboardHeightHelper.calculateEffectiveHeight(
+ baseHeight,
+ KeyboardHeightHelper.HeightScale.COMPACT,
+ KeyboardHeightHelper.ChinOffset.NONE
+ )
+ assertEquals(240f * 0.85f, compactHeight, 0.01f)
+
+ val tallWithChin = KeyboardHeightHelper.calculateEffectiveHeight(
+ baseHeight,
+ KeyboardHeightHelper.HeightScale.TALL,
+ KeyboardHeightHelper.ChinOffset.MEDIUM
+ )
+ assertEquals((240f * 1.15f) + 16f, tallWithChin, 0.01f)
+
+ val extraTallWithLargeChin = KeyboardHeightHelper.calculateEffectiveHeight(
+ baseHeight,
+ KeyboardHeightHelper.HeightScale.EXTRA_TALL,
+ KeyboardHeightHelper.ChinOffset.LARGE
+ )
+ assertEquals((240f * 1.30f) + 24f, extraTallWithLargeChin, 0.01f)
+
+ assertEquals(KeyboardHeightHelper.HeightScale.NORMAL, KeyboardHeightHelper.HeightScale.fromString("invalid"))
+ assertEquals(KeyboardHeightHelper.ChinOffset.LARGE, KeyboardHeightHelper.ChinOffset.fromString("LARGE"))
+ assertEquals(4, KeyboardHeightHelper.HeightScale.entries.size)
+ assertEquals(4, KeyboardHeightHelper.ChinOffset.entries.size)
+ }
+
+ // --- AC-03: Text Editing Palette Undo & Redo ---
+
+ @Test
+ fun testTextEditAction_containsUndoAndRedo() {
+ val actions = TextEditAction.entries
+ assertTrue(actions.contains(TextEditAction.UNDO))
+ assertTrue(actions.contains(TextEditAction.REDO))
+ assertEquals("UNDO", TextEditAction.UNDO.name)
+ assertEquals("REDO", TextEditAction.REDO.name)
+ }
+
+ // --- AC-04: Interactive 3-Step Setup Wizard ---
+
+ @Test
+ fun testKeyboardSetupWizardHelper_stepTransitions() {
+ assertEquals(
+ KeyboardSetupWizardHelper.SetupStep.STEP_ENABLE,
+ KeyboardSetupWizardHelper.getSetupStep(isImeEnabled = false, isImeDefault = false)
+ )
+ assertEquals(
+ KeyboardSetupWizardHelper.SetupStep.STEP_SELECT_DEFAULT,
+ KeyboardSetupWizardHelper.getSetupStep(isImeEnabled = true, isImeDefault = false)
+ )
+ assertEquals(
+ KeyboardSetupWizardHelper.SetupStep.STEP_READY,
+ KeyboardSetupWizardHelper.getSetupStep(isImeEnabled = true, isImeDefault = true)
+ )
+ assertEquals(3, KeyboardSetupWizardHelper.SetupStep.entries.size)
+ }
+
+ // --- AC-05: Backup & Restore JSON Profile & Sanitization ---
+
+ @Test
+ fun testKeyboardBackupRestoreHelper_exportAndImportSanitization() {
+ val autoTexts = listOf(
+ AutoTextEntity(id = 1, title = "Alamat Rumah", body = "Jl. Merdeka No. 45")
+ )
+
+ val clips = listOf(
+ ClipboardItem(id = "1", text = "Public pinned text", timestamp = 100L, isPinned = true),
+ ClipboardItem(id = "2", text = "Temporary clip", timestamp = 200L, isPinned = false),
+ ClipboardItem(id = "3", text = "123456 (Sensitive OTP)", timestamp = 300L, isPinned = true)
+ )
+
+ val settings = mapOf("theme" to "DARK", "language" to "ID")
+
+ val json = KeyboardBackupRestoreHelper.exportToJson(autoTexts, clips, settings)
+ assertTrue(json.contains("Alamat Rumah"))
+ assertTrue(json.contains("Public pinned text"))
+ // Strict security rule: sensitive or unpinned items must NOT be exported
+ assertFalse(json.contains("Temporary clip"))
+ assertFalse(json.contains("123456 (Sensitive OTP)"))
+
+ val parseResult = KeyboardBackupRestoreHelper.importFromJson(json)
+ assertTrue(parseResult.isSuccess)
+ val data = parseResult.getOrThrow()
+ assertEquals(1, data.autoTexts.size)
+ assertEquals("Alamat Rumah", data.autoTexts[0].title)
+ assertEquals(1, data.pinnedClips.size)
+ assertEquals("Public pinned text", data.pinnedClips[0])
+ assertEquals("DARK", data.settings["theme"])
+ }
+
+ @Test
+ fun testKeyboardBackupRestoreHelper_corruptedJsonHandledGracefully() {
+ val result = KeyboardBackupRestoreHelper.importFromJson("{ corrupted json !!")
+ assertTrue(result.isFailure)
+ }
+
+ // --- AC-06: Haptic & Sound Customizer Helper ---
+
+ @Test
+ fun testHapticFeedbackHelper_entriesMigrationAndParsing() {
+ assertEquals(HapticFeedbackHelper.HapticIntensity.SUBTLE, HapticFeedbackHelper.fromString("SUBTLE"))
+ assertEquals(HapticFeedbackHelper.HapticIntensity.MEDIUM, HapticFeedbackHelper.fromString("UNKNOWN"))
+ assertTrue(HapticFeedbackHelper.HapticIntensity.entries.isNotEmpty())
+ assertEquals(4, HapticFeedbackHelper.HapticIntensity.entries.size)
+
+ // Duration mappings
+ assertEquals(15L, HapticFeedbackHelper.HapticIntensity.SUBTLE.durationMs)
+ assertEquals(30L, HapticFeedbackHelper.HapticIntensity.MEDIUM.durationMs)
+ assertEquals(55L, HapticFeedbackHelper.HapticIntensity.STRONG.durationMs)
+ assertEquals(0L, HapticFeedbackHelper.HapticIntensity.OFF.durationMs)
+ }
+
+ // --- AC-07: Glide / Gesture Typing Trajectory & Matching ---
+
+ @Test
+ fun testGestureTypingHelper_trajectoryAndMatching() {
+ val keyH = GestureTypingHelper.KeyBoundingBox('h', 0f, 0f, 50f, 50f)
+ val keyE = GestureTypingHelper.KeyBoundingBox('e', 60f, 0f, 110f, 50f)
+ val keyL = GestureTypingHelper.KeyBoundingBox('l', 120f, 0f, 170f, 50f)
+ val keyO = GestureTypingHelper.KeyBoundingBox('o', 180f, 0f, 230f, 50f)
+ val keys = listOf(keyH, keyE, keyL, keyO)
+
+ val points = listOf(
+ GestureTypingHelper.PathPoint(25f, 25f),
+ GestureTypingHelper.PathPoint(85f, 25f),
+ GestureTypingHelper.PathPoint(145f, 25f),
+ GestureTypingHelper.PathPoint(205f, 25f)
+ )
+
+ val chars = GestureTypingHelper.resolveTrajectoryToChars(points, keys)
+ assertEquals("helo", chars)
+
+ val matches = GestureTypingHelper.matchGesture("helo", listOf("hello", "halo", "help", "world"))
+ assertTrue(matches.contains("hello") || matches.contains("halo"))
+ }
+
+ // --- AC-08: On-Device Adaptive User Dictionary & Privacy Guard ---
+
+ @Test
+ fun testUserDictionaryHelper_privacyGuardAndLearning() {
+ UserDictionaryHelper.inMemoryWordsForTest = mutableMapOf()
+ UserDictionaryHelper.clearLearnedWords(null)
+
+ // 1. Incognito mode MUST block learning
+ val incognitoResult = UserDictionaryHelper.learnWord(null, "rahasia", isIncognito = true, isPassword = false)
+ assertFalse(incognitoResult)
+
+ // 2. Password mode MUST block learning
+ val passwordResult = UserDictionaryHelper.learnWord(null, "myPassword123", isIncognito = false, isPassword = true)
+ assertFalse(passwordResult)
+
+ // 3. Hygiene check: numbers, symbols, too short/long words rejected
+ assertFalse(UserDictionaryHelper.canLearnWord("a", isIncognito = false, isPassword = false))
+ assertFalse(UserDictionaryHelper.canLearnWord("12345", isIncognito = false, isPassword = false))
+ assertFalse(UserDictionaryHelper.canLearnWord("hello_world", isIncognito = false, isPassword = false))
+
+ // 4. Standard valid word is learned
+ val learnedResult = UserDictionaryHelper.learnWord(null, "mantap", isIncognito = false, isPassword = false)
+ assertTrue(learnedResult)
+
+ val words = UserDictionaryHelper.getLearnedWords(null)
+ assertEquals(1, words["mantap"])
+
+ // Increments frequency on repeat
+ UserDictionaryHelper.learnWord(null, "mantap", isIncognito = false, isPassword = false)
+ assertEquals(2, UserDictionaryHelper.getLearnedWords(null)["mantap"])
+
+ val suggestions = UserDictionaryHelper.getSuggestions(null, "man")
+ assertTrue(suggestions.contains("mantap"))
+
+ // Reset memory test holder
+ UserDictionaryHelper.inMemoryWordsForTest = null
+ }
+
+ // --- AC-09: Inline Smart Math / Calculator ---
+
+ @Test
+ fun testSmartCalculatorHelper_basicAndPrecedenceMath() {
+ // Simple multiplication
+ val multResult = SmartCalculatorHelper.evaluateMath("25000*4=")
+ assertNotNull(multResult)
+ assertEquals("100000", multResult?.evaluatedValue)
+ assertEquals("= 100000", multResult?.chipLabel)
+
+ // Simple addition
+ val addResult = SmartCalculatorHelper.evaluateMath("150+350=")
+ assertNotNull(addResult)
+ assertEquals("500", addResult?.evaluatedValue)
+
+ // Operator precedence (multiplication before addition)
+ val precResult = SmartCalculatorHelper.evaluateMath("10+20*3=")
+ assertNotNull(precResult)
+ assertEquals("70", precResult?.evaluatedValue)
+
+ // Trailing math in sentence
+ val sentenceResult = SmartCalculatorHelper.evaluateMath("Total belanjaan 12500*2=")
+ assertNotNull(sentenceResult)
+ assertEquals("25000", sentenceResult?.evaluatedValue)
+
+ // Floating precision formatting
+ val divResult = SmartCalculatorHelper.evaluateMath("10/4=")
+ assertNotNull(divResult)
+ assertEquals("2.5", divResult?.evaluatedValue)
+
+ // Invalid or non-math expressions return null
+ assertNull(SmartCalculatorHelper.evaluateMath("halo dunia="))
+ assertNull(SmartCalculatorHelper.evaluateMath(null))
+ assertNull(SmartCalculatorHelper.evaluateMath(""))
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 4dad54f..36fb938 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,23 +1,31 @@
[versions]
agp = "9.4.1"
-kotlin = "2.4.20"
+kotlin = "2.4.21"
ksp = "2.3.10"
liveCycle = "2.11.0"
roomKtx = "2.8.5"
-work = "2.11.2"
+work = "2.12.0"
hilt = "2.60.1"
-emoji2 = "1.6.0"
-frogoAndroid = "3.0.10"
-frogoconsumeapi = "3.0.0"
+emoji2 = "1.7.0"
+frogoAndroid = "3.0.11"
+frogoconsumeapi = "3.0.1"
composeBom = "2026.09.00"
activityCompose = "1.13.0"
junit = "4.13.2"
+robolectric = "4.17"
+androidxTestCore = "1.7.0"
+kotlinxCoroutines = "1.11.0"
+json = "20260814"
[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
+robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
+androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
+json = { group = "org.json", name = "json", version.ref = "json" }
androidx-work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
androidx-lifecycle-compiler = { group = "androidx.lifecycle", name = "lifecycle-compiler", version.ref = "liveCycle" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "liveCycle" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index f8db6e2..5d11918 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Sun Nov 03 18:01:05 WIB 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/lib-keyboard/build.gradle.kts b/lib-keyboard/build.gradle.kts
index 4425189..ab582c9 100644
--- a/lib-keyboard/build.gradle.kts
+++ b/lib-keyboard/build.gradle.kts
@@ -84,8 +84,8 @@ dependencies {
androidTestImplementation(composeBom)
testImplementation(libs.junit)
- testImplementation("org.robolectric:robolectric:4.16.1")
- testImplementation("androidx.test:core:1.7.0")
+ testImplementation(libs.robolectric)
+ testImplementation(libs.androidx.test.core)
debugImplementation(libs.androidx.compose.ui.tooling)
diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt
index a1e9c8c..b2a871f 100644
--- a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt
+++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt
@@ -145,6 +145,14 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeyboardActionListener,
currentInputConnection?.commitText(text, 1)
}
+ override fun onGestureMatch(word: String, suggestions: List) {
+ currentInputConnection?.commitText("$word ", 1)
+ }
+
+ override fun onSwipeDown() {
+ requestHideSelf(0)
+ }
+
override fun initialSetupKeyboard() {}
override fun setupTheme() {}
diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboardScreen.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboardScreen.kt
index d1111de..e65180f 100644
--- a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboardScreen.kt
+++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboardScreen.kt
@@ -228,12 +228,12 @@ fun EmojiKeyboardScreen(
@Composable
fun EmojiHeaderToolbar(
selectedCategory: EmojiCategoryType,
+ onBackClicked: () -> Unit,
+ modifier: Modifier = Modifier,
isSearchActive: Boolean = false,
searchQuery: String = "",
onSearchQueryChange: (String) -> Unit = {},
onToggleSearch: () -> Unit = {},
- onBackClicked: () -> Unit,
- modifier: Modifier = Modifier,
onDeleteClicked: () -> Unit = {}
) {
Row(
diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt
index 7b3099f..da157e1 100644
--- a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt
+++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt
@@ -49,7 +49,7 @@ class ItemMainKeyboard {
private var mDisplayWidth = 0
/** What icon should we show at Enter key */
- private var mEnterKeyType = IME_ACTION_NONE
+ var mEnterKeyType = IME_ACTION_NONE
/** Keyboard rows */
private val mRows = ArrayList()
@@ -421,10 +421,14 @@ class ItemMainKeyboard {
EditorInfo.IME_ACTION_SEARCH -> R.drawable.ic_keyboard_search
EditorInfo.IME_ACTION_NEXT, EditorInfo.IME_ACTION_GO -> R.drawable.ic_keyboard_arrow_right
EditorInfo.IME_ACTION_SEND -> R.drawable.ic_keyboard_send
+ EditorInfo.IME_ACTION_DONE -> R.drawable.ic_keyboard_done
else -> R.drawable.ic_keyboard_enter
}
- key.icon =
- context.resources.getDrawable(enterResourceId, context.theme)
+ key.icon = androidx.core.content.res.ResourcesCompat.getDrawable(
+ context.resources,
+ enterResourceId,
+ context.theme
+ )
}
currentRow.mKeys.add(key)
}
diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt
index d28fb9a..5def041 100644
--- a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt
+++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt
@@ -9,6 +9,7 @@ import android.graphics.Paint
import android.graphics.Paint.Align
import android.graphics.PorterDuff
import android.graphics.Rect
+import android.graphics.RectF
import android.graphics.Typeface
import android.graphics.drawable.LayerDrawable
import android.media.AudioManager
@@ -58,7 +59,6 @@ import kotlin.math.min
import androidx.core.graphics.createBitmap
import androidx.core.graphics.withSave
-@SuppressLint("UseCompatLoadingForDrawables", "ClickableViewAccessibility")
class MainKeyboard @JvmOverloads constructor(
context: Context,
attrs: AttributeSet?,
@@ -138,6 +138,22 @@ class MainKeyboard @JvmOverloads constructor(
private var mDeleteStartX = 0
private var mIsSlidingDelete = false
+ // Gesture Glide Typing & Swipe Shortcuts
+ var isGestureTypingEnabled: Boolean = true
+ var isSwipeShortcutsEnabled: Boolean = true
+ private var mIsGestureTyping = false
+ private var mIsSwipingDown = false
+ private var mDownKeyCode = 0
+ private var mIsDownOnLetter = false
+ private var mGestureDownX = 0f
+ private var mGestureDownY = 0f
+ private var mGestureDragThreshold = 0f
+ private var mSwipeDownThreshold = 0f
+ private var mSwipeDownMaxHoriz = 0f
+ private val mGesturePoints = ArrayList()
+ private val mGestureTrailPaint = Paint()
+ private val mGesturePath = android.graphics.Path()
+
// Cached drawables for zero per-frame allocation
private var mKeypadDefaultDrawable: android.graphics.drawable.Drawable? = null
private var mKeypadActionDrawable: android.graphics.drawable.Drawable? = null
@@ -205,13 +221,13 @@ class MainKeyboard @JvmOverloads constructor(
mKeyTextSize = defaultKeyTextSize
val attributes =
- context.obtainStyledAttributes(attrs, R.styleable.FrogoKeyboardView, 0, defStyleRes)
+ context.obtainStyledAttributes(attrs, R.styleable.MainKeyboard, 0, defStyleRes)
val indexCnt = attributes.indexCount
try {
for (i in 0 until indexCnt) {
when (val attr = attributes.getIndex(i)) {
- R.styleable.FrogoKeyboardView_keyTextSize -> mKeyTextSize =
+ R.styleable.MainKeyboard_keyTextSize -> mKeyTextSize =
attributes.getDimensionPixelSize(attr, defaultKeyTextSize)
}
}
@@ -260,6 +276,21 @@ class MainKeyboard @JvmOverloads constructor(
mTopSmallNumberMarginWidth = resources.getDimension(R.dimen.top_small_number_margin_width)
mTopSmallNumberMarginHeight = resources.getDimension(R.dimen.top_small_number_margin_height)
+ mGestureDragThreshold = resources.getDimension(R.dimen.key_height) * 0.35f
+ mSwipeDownThreshold = resources.getDimension(R.dimen.key_height) * 0.85f
+ mSwipeDownMaxHoriz = resources.getDimension(R.dimen.key_height) * 0.5f
+
+ val trailColor = context.getColorExt(R.color.color_frogo_primary)
+ mGestureTrailPaint.apply {
+ isAntiAlias = true
+ style = Paint.Style.STROKE
+ strokeCap = Paint.Cap.ROUND
+ strokeJoin = Paint.Join.ROUND
+ strokeWidth = resources.getDimension(R.dimen.key_height) * 0.16f
+ color = trailColor
+ alpha = 190
+ }
+
initCachedDrawables()
}
@@ -310,6 +341,12 @@ class MainKeyboard @JvmOverloads constructor(
mActionTextColor = actionTextColor
mIsDarkTheme = isDark
mPaint.color = textColor
+ mGestureTrailPaint.color = if (isDark) {
+ context.getColorExt(R.color.color_frogo_primary)
+ } else {
+ textColor
+ }
+ mGestureTrailPaint.alpha = 190
mSmallLetterPaint.apply {
set(mPaint)
color = textColor.adjustAlpha(0.8f)
@@ -323,8 +360,8 @@ class MainKeyboard @JvmOverloads constructor(
fun initCachedDrawables() {
val defaultDrawableRes = if (mIsDarkTheme) R.drawable.keypad_default_dark else R.drawable.keypad_default
- mKeypadDefaultDrawable = resources.getDrawable(defaultDrawableRes, context.theme)
- mKeypadActionDrawable = resources.getDrawable(R.drawable.keypad_action, context.theme)
+ mKeypadDefaultDrawable = ResourcesCompat.getDrawable(resources, defaultDrawableRes, context.theme)
+ mKeypadActionDrawable = ResourcesCompat.getDrawable(resources, R.drawable.keypad_action, context.theme)
mShiftOffDrawable = ResourcesCompat.getDrawable(resources, R.drawable.ic_keyboard_caps_outline, null)
mShiftOneCharDrawable = ResourcesCompat.getDrawable(resources, R.drawable.ic_keyboard_caps, null)
mShiftPermDrawable = ResourcesCompat.getDrawable(resources, R.drawable.ic_keyboard_caps_underlined, null)
@@ -508,6 +545,31 @@ class MainKeyboard @JvmOverloads constructor(
onBufferDraw()
}
canvas.drawBitmap(mBuffer!!, 0f, 0f, null)
+
+ if (mIsGestureTyping && mGesturePoints.size >= 2) {
+ drawGestureTrail(canvas)
+ }
+ }
+
+ private fun drawGestureTrail(canvas: Canvas) {
+ if (mGesturePoints.size < 2) return
+ val lastPoint = mGesturePoints.lastOrNull() ?: return
+ val recentPoints = mGesturePoints.filter { it.timestamp >= lastPoint.timestamp - 350L }
+ if (recentPoints.size < 2) return
+
+ mGesturePath.reset()
+ val first = recentPoints.first()
+ mGesturePath.moveTo(first.x, first.y)
+ for (i in 1 until recentPoints.size) {
+ val prev = recentPoints[i - 1]
+ val curr = recentPoints[i]
+ val midX = (prev.x + curr.x) / 2f
+ val midY = (prev.y + curr.y) / 2f
+ mGesturePath.quadTo(prev.x, prev.y, midX, midY)
+ }
+ val last = recentPoints.last()
+ mGesturePath.lineTo(last.x, last.y)
+ canvas.drawPath(mGesturePath, mGestureTrailPaint)
}
@SuppressLint("UseCompatLoadingForDrawables")
@@ -528,9 +590,15 @@ class MainKeyboard @JvmOverloads constructor(
return
}
+ val kb = mKeyboard
+ val scaleX = if (kb != null && kb.mMinWidth > 0 && width > 0) width.toFloat() / kb.mMinWidth.toFloat() else 1.0f
+
mCanvas!!.withSave {
val canvas = mCanvas
canvas!!.clipRect(mDirtyRect)
+ if (scaleX != 1.0f) {
+ canvas.scale(scaleX, 1.0f)
+ }
val paint = mPaint
val keys = mKeys
paint.color = mTextColor
@@ -575,7 +643,12 @@ class MainKeyboard @JvmOverloads constructor(
}
// Switch the character to uppercase if shift is pressed
- val label = adjustCase(key.label)?.toString()
+ val displayLabel = if (code == KEYCODE_SPACE && mIsSlidingSpace) {
+ "‹ KURSOR TRACKPAD ›"
+ } else {
+ adjustCase(key.label)?.toString()
+ }
+
val bounds = keyBackground.bounds
if (key.width != bounds.right || key.height != bounds.bottom) {
keyBackground.setBounds(0, 0, key.width, key.height)
@@ -589,10 +662,25 @@ class MainKeyboard @JvmOverloads constructor(
canvas.translate(key.x.toFloat(), key.y.toFloat())
keyBackground.draw(canvas)
- if (label?.isNotEmpty() == true) {
- // For characters, use large font. For labels like "Done", use small font.
- if (label.length > 1) {
- paint.textSize = mLabelTextSize.toFloat()
+
+ if (code == KEYCODE_SPACE && mIsSlidingSpace) {
+ val highlightPaint = Paint().apply {
+ color = (if (mActionTextColor != 0) mActionTextColor else mTextColor).adjustAlpha(0.20f)
+ style = Paint.Style.FILL
+ }
+ val cornerRadius = key.height * 0.18f
+ val rect = RectF(0f, 0f, key.width.toFloat(), key.height.toFloat())
+ canvas.drawRoundRect(rect, cornerRadius, cornerRadius, highlightPaint)
+ }
+
+ if (displayLabel?.isNotEmpty() == true) {
+ // For characters, use large font. For labels like "Done" or "‹ KURSOR TRACKPAD ›", use small font.
+ if (displayLabel.length > 1) {
+ paint.textSize = if (code == KEYCODE_SPACE && mIsSlidingSpace) {
+ (mLabelTextSize * 0.85f)
+ } else {
+ mLabelTextSize.toFloat()
+ }
paint.typeface = Typeface.DEFAULT_BOLD
} else {
paint.textSize = mKeyTextSize.toFloat()
@@ -602,7 +690,7 @@ class MainKeyboard @JvmOverloads constructor(
paint.color = mTextColor
canvas.drawText(
- label,
+ displayLabel,
(key.width / 2).toFloat(),
key.height / 2 + (paint.textSize - paint.descent()) / 2,
paint
@@ -660,8 +748,11 @@ class MainKeyboard @JvmOverloads constructor(
}
private fun getPressedKeyIndex(x: Int, y: Int): Int {
+ val kb = mKeyboard
+ val scaleX = if (kb != null && kb.mMinWidth > 0 && width > 0) width.toFloat() / kb.mMinWidth.toFloat() else 1.0f
+ val adjustedX = if (scaleX > 0f && scaleX != 1.0f) (x / scaleX).toInt() else x
return mKeys.indexOfFirst {
- it.isInside(x, y)
+ it.isInside(adjustedX, y)
}
}
@@ -765,18 +856,23 @@ class MainKeyboard @JvmOverloads constructor(
val previewBackground = mPreviewText!!.background as LayerDrawable
mPreviewText!!.background = previewBackground
+ val kb = mKeyboard
+ val scaleX = if (kb != null && kb.mMinWidth > 0 && width > 0) width.toFloat() / kb.mMinWidth.toFloat() else 1.0f
+ val scaledKeyX = (key.x * scaleX).toInt()
+ val scaledKeyWidth = (key.width * scaleX).toInt()
+
mPreviewText!!.setTextColor(mTextColor)
mPreviewText!!.measure(
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
)
- val popupWidth = Math.max(mPreviewText!!.measuredWidth, key.width)
+ val popupWidth = Math.max(mPreviewText!!.measuredWidth, scaledKeyWidth)
val popupHeight = mPreviewHeight
val lp = mPreviewText!!.layoutParams
lp?.width = popupWidth
lp?.height = popupHeight
- mPopupPreviewX = key.x
+ mPopupPreviewX = scaledKeyX
mPopupPreviewY = key.y - popupHeight
mHandler!!.removeMessages(MSG_REMOVE_PREVIEW)
@@ -799,10 +895,10 @@ class MainKeyboard @JvmOverloads constructor(
if (mPopupPreviewY + mCoordinates[1] < 0) {
// If the key you're pressing is on the left side of the keyboard, show the popup on
// the right, offset by enough to see at least one key to the left/right.
- if (key.x + key.width <= width / 2) {
- mPopupPreviewX += (key.width * 2.5).toInt()
+ if (scaledKeyX + scaledKeyWidth <= width / 2) {
+ mPopupPreviewX += (scaledKeyWidth * 2.5).toInt()
} else {
- mPopupPreviewX -= (key.width * 2.5).toInt()
+ mPopupPreviewX -= (scaledKeyWidth * 2.5).toInt()
}
mPopupPreviewY += popupHeight
}
@@ -827,7 +923,7 @@ class MainKeyboard @JvmOverloads constructor(
val event = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
AccessibilityEvent(eventType)
} else {
- AccessibilityEvent().apply { this.eventType = eventType }
+ AccessibilityEvent.obtain(eventType)
}
onInitializeAccessibilityEvent(event)
val text: String = when (code) {
@@ -868,9 +964,13 @@ class MainKeyboard @JvmOverloads constructor(
}
val key = mKeys[keyIndex]
+ val kb = mKeyboard
+ val scaleX = if (kb != null && kb.mMinWidth > 0 && width > 0) width.toFloat() / kb.mMinWidth.toFloat() else 1.0f
+ val left = (key.x * scaleX).toInt()
+ val right = ((key.x + key.width) * scaleX).toInt() + 1
mDirtyRect.union(
- key.x, key.y,
- key.x + key.width, key.y + key.height
+ left, key.y,
+ right, key.y + key.height
)
onBufferDraw()
invalidate()
@@ -1143,8 +1243,19 @@ class MainKeyboard @JvmOverloads constructor(
mDownTime = me.eventTime
mLastMoveTime = mDownTime
- val isSpaceKey = mCurrentKey >= 0 && mKeys.getOrNull(mCurrentKey)?.code == KEYCODE_SPACE
- val isDeleteKey = mCurrentKey >= 0 && mKeys.getOrNull(mCurrentKey)?.code == KEYCODE_DELETE
+ mGestureDownX = me.x
+ mGestureDownY = me.y
+ mGesturePoints.clear()
+ mIsGestureTyping = false
+ mIsSwipingDown = false
+
+ val downKeyObj = mKeys.getOrNull(mCurrentKey)
+ mDownKeyCode = downKeyObj?.code ?: 0
+ val downChar = downKeyObj?.label?.firstOrNull()?.lowercaseChar() ?: (downKeyObj?.code?.toChar()?.lowercaseChar())
+ mIsDownOnLetter = downChar != null && downChar.isLetter()
+
+ val isSpaceKey = mDownKeyCode == KEYCODE_SPACE
+ val isDeleteKey = mDownKeyCode == KEYCODE_DELETE
if (isSpaceKey) {
mSpaceStartX = touchX
@@ -1214,8 +1325,43 @@ class MainKeyboard @JvmOverloads constructor(
}
}
- // Instant Gboard spacebar swipe cursor control (without 400ms delay)
- if (mCurrentKey >= 0 && mKeys.getOrNull(mCurrentKey)?.code == KEYCODE_SPACE) {
+ // Swipe down to dismiss keyboard (only allowed if touch did NOT begin on a letter key while gesture typing is active)
+ if (isSwipeShortcutsEnabled && !mIsGestureTyping && !mIsSlidingSpace && !mIsSlidingDelete && (!isGestureTypingEnabled || !mIsDownOnLetter)) {
+ val dy = me.y - mGestureDownY
+ val dx = kotlin.math.abs(me.x - mGestureDownX)
+ if (mGestureDownY < height * 0.45f && dy > mSwipeDownThreshold && dx < mSwipeDownMaxHoriz) {
+ mIsSwipingDown = true
+ mHandler?.removeMessages(MSG_LONGPRESS)
+ mHandler?.removeMessages(MSG_REPEAT)
+ showPreview(NOT_A_KEY)
+ mOnKeyboardActionListener?.onSwipeDown()
+ vibrateIfNeeded()
+ mAbortKey = true
+ return true
+ }
+ }
+
+ // Gesture glide typing across letter keys (anchored to touch onset on a letter)
+ if (isGestureTypingEnabled && mIsDownOnLetter && !mIsSlidingSpace && !mIsSlidingDelete && !mIsSwipingDown) {
+ val distFromDown = kotlin.math.hypot(me.x - mGestureDownX, me.y - mGestureDownY)
+
+ if (!mIsGestureTyping && distFromDown > mGestureDragThreshold) {
+ mIsGestureTyping = true
+ mHandler?.removeMessages(MSG_LONGPRESS)
+ mHandler?.removeMessages(MSG_REPEAT)
+ showPreview(NOT_A_KEY)
+ mGesturePoints.clear()
+ mGesturePoints.add(com.frogobox.libkeyboard.util.GestureTypingHelper.PathPoint(mGestureDownX, mGestureDownY, mDownTime))
+ }
+
+ if (mIsGestureTyping) {
+ mGesturePoints.add(com.frogobox.libkeyboard.util.GestureTypingHelper.PathPoint(me.x, me.y, eventTime))
+ invalidate()
+ }
+ }
+
+ // Instant Gboard spacebar swipe cursor control (only when touch began on spacebar)
+ if (mDownKeyCode == KEYCODE_SPACE) {
val totalDiffX = touchX - mSpaceStartX
if (kotlin.math.abs(totalDiffX) > mSpaceMoveThreshold) {
if (!mIsSlidingSpace) {
@@ -1223,6 +1369,7 @@ class MainKeyboard @JvmOverloads constructor(
mHandler?.removeMessages(MSG_REPEAT)
mHandler?.removeMessages(MSG_LONGPRESS)
showPreview(NOT_A_KEY)
+ invalidateKey(mCurrentKey)
}
val diff = touchX - mLastSpaceMoveX
@@ -1242,7 +1389,7 @@ class MainKeyboard @JvmOverloads constructor(
mLastSpaceMoveX += steps * mSpaceMoveThreshold
}
}
- } else if (mCurrentKey >= 0 && mKeys.getOrNull(mCurrentKey)?.code == KEYCODE_DELETE) {
+ } else if (mDownKeyCode == KEYCODE_DELETE) {
val swipeLeftDist = mDeleteStartX - touchX
if (swipeLeftDist > mDeleteSwipeThreshold) {
if (!mIsSlidingDelete) {
@@ -1269,7 +1416,7 @@ class MainKeyboard @JvmOverloads constructor(
}
mLastSpaceMoveX = mLastX
}
- } else if (!continueLongPress) {
+ } else if (!continueLongPress && !mIsGestureTyping) {
// Cancel old longpress
mHandler!!.removeMessages(MSG_LONGPRESS)
// Start new longpress if key has changed
@@ -1307,11 +1454,83 @@ class MainKeyboard @JvmOverloads constructor(
val wasSlidingSpace = mIsSlidingSpace
val wasSlidingDelete = mIsSlidingDelete
+ val wasGestureTyping = mIsGestureTyping
mIsSlidingSpace = false
mIsSlidingDelete = false
+ mIsGestureTyping = false
+
+ if (wasGestureTyping) {
+ val pointsCopy = ArrayList(mGesturePoints)
+ mGesturePoints.clear()
+ invalidate()
+
+ if (pointsCopy.size >= 2) {
+ val kb = mKeyboard
+ val scaleX = if (kb != null && kb.mMinWidth > 0 && width > 0) {
+ width.toFloat() / kb.mMinWidth.toFloat()
+ } else 1.0f
+
+ val keyBoxes = mKeys.filter { key ->
+ val ch = key.label.firstOrNull()?.lowercaseChar() ?: key.code.toChar().lowercaseChar()
+ ch.isLetter()
+ }.map { key ->
+ val ch = key.label.firstOrNull()?.lowercaseChar() ?: key.code.toChar().lowercaseChar()
+ com.frogobox.libkeyboard.util.GestureTypingHelper.KeyBoundingBox(
+ char = ch,
+ left = key.x.toFloat() * scaleX,
+ top = key.y.toFloat(),
+ right = (key.x + key.width).toFloat() * scaleX,
+ bottom = (key.y + key.height).toFloat()
+ )
+ }
- if (wasSlidingSpace) {
+ val matches = com.frogobox.libkeyboard.util.GestureTypingHelper.resolveAndMatch(pointsCopy, keyBoxes)
+ if (matches.isNotEmpty()) {
+ val bestWord = matches.first()
+ mOnKeyboardActionListener?.onGestureMatch(bestWord, matches)
+ vibrateIfNeeded()
+ showPreview(NOT_A_KEY)
+ invalidateKey(keyIndex)
+ mRepeatKeyIndex = NOT_A_KEY
+ mOnKeyboardActionListener?.onActionUp()
+ mIsLongPressingSpace = false
+ return true
+ } else {
+ val rawChars = com.frogobox.libkeyboard.util.GestureTypingHelper.resolveTrajectoryToChars(pointsCopy, keyBoxes)
+ if (rawChars.length >= 2) {
+ mOnKeyboardActionListener?.onGestureMatch(rawChars, listOf(rawChars))
+ vibrateIfNeeded()
+ showPreview(NOT_A_KEY)
+ invalidateKey(keyIndex)
+ mRepeatKeyIndex = NOT_A_KEY
+ mOnKeyboardActionListener?.onActionUp()
+ mIsLongPressingSpace = false
+ return true
+ } else if (rawChars.length == 1) {
+ val singleChar = rawChars.first()
+ val targetCode = keyBoxes.firstOrNull { it.char == singleChar }?.char?.code ?: singleChar.code
+ mOnKeyboardActionListener?.onKey(targetCode)
+ vibrateIfNeeded()
+ showPreview(NOT_A_KEY)
+ invalidateKey(keyIndex)
+ mRepeatKeyIndex = NOT_A_KEY
+ mOnKeyboardActionListener?.onActionUp()
+ mIsLongPressingSpace = false
+ return true
+ }
+ }
+ }
+ showPreview(NOT_A_KEY)
+ invalidateKey(keyIndex)
+ mRepeatKeyIndex = NOT_A_KEY
+ mOnKeyboardActionListener?.onActionUp()
+ mIsLongPressingSpace = false
+ return true
+ } else if (wasSlidingSpace) {
// Sliding space gesture consumed - do not send space character
+ if (mCurrentKey != NOT_A_KEY) {
+ invalidateKey(mCurrentKey)
+ }
} else if (wasSlidingDelete) {
// Sliding delete gesture consumed - delete words
val swipeLeftDist = mDeleteStartX - touchX
@@ -1332,12 +1551,21 @@ class MainKeyboard @JvmOverloads constructor(
mRepeatKeyIndex = NOT_A_KEY
mOnKeyboardActionListener?.onActionUp()
mIsLongPressingSpace = false
+ performClick()
}
MotionEvent.ACTION_CANCEL -> {
+ val wasSlidingSpaceCancel = mIsSlidingSpace
mIsLongPressingSpace = false
mIsSlidingSpace = false
mIsSlidingDelete = false
+ mIsGestureTyping = false
+ mIsSwipingDown = false
+ mGesturePoints.clear()
+ invalidate()
+ if (wasSlidingSpaceCancel && mCurrentKey != NOT_A_KEY) {
+ invalidateKey(mCurrentKey)
+ }
mLastSpaceMoveX = 0
removeMessages()
dismissPopupKeyboard()
@@ -1389,6 +1617,11 @@ class MainKeyboard @JvmOverloads constructor(
closing()
}
+ override fun performClick(): Boolean {
+ super.performClick()
+ return true
+ }
+
private fun dismissPopupKeyboard() {
if (mPopupKeyboard.isShowing) {
mPopupKeyboard.dismiss()
diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboardScreen.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboardScreen.kt
index e965e65..0b42837 100644
--- a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboardScreen.kt
+++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboardScreen.kt
@@ -4,6 +4,7 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -19,10 +20,12 @@ import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -47,6 +50,8 @@ fun MainKeyboardView(
keyboard: ItemMainKeyboard?,
onActionListener: OnKeyboardActionListener?,
modifier: Modifier = Modifier,
+ isGestureTypingEnabled: Boolean = true,
+ isSwipeShortcutsEnabled: Boolean = true,
textColor: Int? = null,
actionTextColor: Int? = null,
keyColor: Int? = null,
@@ -70,6 +75,8 @@ fun MainKeyboardView(
isDark = isDarkTheme
)
}
+ this.isGestureTypingEnabled = isGestureTypingEnabled
+ this.isSwipeShortcutsEnabled = isSwipeShortcutsEnabled
this.mOnKeyboardActionListener = onActionListener
keyboard?.let { setKeyboard(it) }
onInit(this)
@@ -77,6 +84,8 @@ fun MainKeyboardView(
},
update = { view ->
view.mOnKeyboardActionListener = onActionListener
+ view.isGestureTypingEnabled = isGestureTypingEnabled
+ view.isSwipeShortcutsEnabled = isSwipeShortcutsEnabled
if (textColor != null) {
view.setKeyboardTheme(
textColor = textColor,
@@ -102,7 +111,13 @@ fun MainKeyboardComposable(
onKeyPress: (Int) -> Unit,
modifier: Modifier = Modifier,
onKeyActionUp: () -> Unit = {},
- isDarkTheme: Boolean = false
+ isDarkTheme: Boolean = false,
+ onMoveCursorLeft: () -> Unit = {},
+ onMoveCursorRight: () -> Unit = {},
+ onDeleteWords: (Int) -> Unit = {},
+ isSplitMode: Boolean = false,
+ onSwipeDown: (() -> Unit)? = null,
+ onGestureMatch: ((String, List) -> Unit)? = null
) {
val defaultKeyColor = if (isDarkTheme) KeypadDark else KeypadLight
val actionKeyColor = if (isDarkTheme) KeypadActionDark else KeypadActionLight
@@ -125,13 +140,17 @@ fun MainKeyboardComposable(
for (rowY in rowYPositions) {
val rowKeys = keys.filter { it.y == rowY }.sortedBy { it.x }
val totalRowWidth = rowKeys.sumOf { it.width }.coerceAtLeast(1)
+ val midpoint = rowKeys.size / 2
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(3.dp),
verticalAlignment = Alignment.CenterVertically
) {
- for (key in rowKeys) {
+ for ((index, key) in rowKeys.withIndex()) {
+ if (isSplitMode && index == midpoint) {
+ androidx.compose.foundation.layout.Spacer(modifier = Modifier.weight(0.18f))
+ }
val weight = (key.width.toFloat() / totalRowWidth.toFloat()).coerceAtLeast(0.01f)
val isActionKey = when (key.code) {
ItemMainKeyboard.KEYCODE_SHIFT,
@@ -143,10 +162,19 @@ fun MainKeyboardComposable(
}
val keyBackground = if (isActionKey) actionKeyColor else defaultKeyColor
+ val hint = when {
+ key.topSmallNumber.isNotEmpty() -> key.topSmallNumber
+ key.popupCharacters != null && key.popupCharacters!!.isNotEmpty() -> {
+ val firstChar = key.popupCharacters!!.first().toString()
+ if (firstChar.all { it.isLetterOrDigit() || it in "@#$%&*+-=()!\"':;/?.,~`" }) firstChar else null
+ }
+ else -> null
+ }
KeyItemView(
label = key.label.toString(),
code = key.code,
+ hintLabel = hint,
backgroundColor = keyBackground,
isActionKey = isActionKey,
onClick = {
@@ -156,6 +184,9 @@ fun MainKeyboardComposable(
onLongClick = if (key.popupCharacters != null && key.popupCharacters!!.isNotEmpty()) {
{ activePopupKey = key }
} else null,
+ onDragLeft = onMoveCursorLeft,
+ onDragRight = onMoveCursorRight,
+ onSwipeDelete = onDeleteWords,
modifier = Modifier.weight(weight)
)
}
@@ -200,23 +231,93 @@ private fun KeyItemView(
backgroundColor: Color,
isActionKey: Boolean,
onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ hintLabel: String? = null,
onLongClick: (() -> Unit)? = null,
- modifier: Modifier = Modifier
+ onDragLeft: (() -> Unit)? = null,
+ onDragRight: (() -> Unit)? = null,
+ onSwipeDelete: ((Int) -> Unit)? = null
) {
val interactionSource = remember { MutableInteractionSource() }
+ var isSlidingSpace by remember { mutableStateOf(false) }
+ var accumulatedSpaceDrag by remember { mutableFloatStateOf(0f) }
+ var accumulatedDeleteDrag by remember { mutableFloatStateOf(0f) }
+
+ val gestureModifier = when (code) {
+ ItemMainKeyboard.KEYCODE_SPACE -> {
+ Modifier.pointerInput(Unit) {
+ detectHorizontalDragGestures(
+ onDragStart = {
+ accumulatedSpaceDrag = 0f
+ isSlidingSpace = false
+ },
+ onDragEnd = {
+ isSlidingSpace = false
+ accumulatedSpaceDrag = 0f
+ },
+ onDragCancel = {
+ isSlidingSpace = false
+ accumulatedSpaceDrag = 0f
+ },
+ onHorizontalDrag = { change, dragAmount ->
+ change.consume()
+ accumulatedSpaceDrag += dragAmount
+ val threshold = 35f
+ if (!isSlidingSpace && kotlin.math.abs(accumulatedSpaceDrag) > threshold) {
+ isSlidingSpace = true
+ }
+ if (accumulatedSpaceDrag > threshold) {
+ onDragRight?.invoke()
+ accumulatedSpaceDrag -= threshold
+ } else if (accumulatedSpaceDrag < -threshold) {
+ onDragLeft?.invoke()
+ accumulatedSpaceDrag += threshold
+ }
+ }
+ )
+ }
+ }
+ ItemMainKeyboard.KEYCODE_DELETE -> {
+ Modifier.pointerInput(Unit) {
+ detectHorizontalDragGestures(
+ onDragStart = { accumulatedDeleteDrag = 0f },
+ onDragEnd = {
+ if (accumulatedDeleteDrag < -60f) {
+ val words = kotlin.math.max(1, ((-accumulatedDeleteDrag) / 90f).toInt())
+ onSwipeDelete?.invoke(words)
+ }
+ accumulatedDeleteDrag = 0f
+ },
+ onDragCancel = { accumulatedDeleteDrag = 0f },
+ onHorizontalDrag = { change, dragAmount ->
+ change.consume()
+ accumulatedDeleteDrag += dragAmount
+ }
+ )
+ }
+ }
+ else -> Modifier
+ }
Surface(
modifier = modifier
.height(46.dp)
.clip(RoundedCornerShape(6.dp))
+ .then(gestureModifier)
.combinedClickable(
interactionSource = interactionSource,
indication = ripple(bounded = true),
- onClick = onClick,
+ onClick = {
+ if (!isSlidingSpace) {
+ onClick()
+ }
+ },
onLongClick = onLongClick
),
shape = RoundedCornerShape(6.dp),
- color = backgroundColor,
+ color = if (code == ItemMainKeyboard.KEYCODE_SPACE && isSlidingSpace) {
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f)
+ } else backgroundColor,
border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
shadowElevation = if (isActionKey) 1.dp else 1.5.dp
) {
@@ -224,24 +325,39 @@ private fun KeyItemView(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
- val displayLabel = when (code) {
- ItemMainKeyboard.KEYCODE_SHIFT -> "⇧"
- ItemMainKeyboard.KEYCODE_DELETE -> "⌫"
- ItemMainKeyboard.KEYCODE_ENTER -> "↵"
- ItemMainKeyboard.KEYCODE_SPACE -> " "
- ItemMainKeyboard.KEYCODE_MODE_CHANGE -> "?123"
- ItemMainKeyboard.KEYCODE_EMOJI -> "🙂"
- ItemMainKeyboard.KEYCODE_TAB -> "⇥"
+ val displayLabel = when {
+ code == ItemMainKeyboard.KEYCODE_SPACE && isSlidingSpace -> "‹ KURSOR TRACKPAD ›"
+ code == ItemMainKeyboard.KEYCODE_SHIFT -> "⇧"
+ code == ItemMainKeyboard.KEYCODE_DELETE -> "⌫"
+ code == ItemMainKeyboard.KEYCODE_ENTER -> "↵"
+ code == ItemMainKeyboard.KEYCODE_SPACE -> " "
+ code == ItemMainKeyboard.KEYCODE_MODE_CHANGE -> "?123"
+ code == ItemMainKeyboard.KEYCODE_EMOJI -> "🙂"
+ code == ItemMainKeyboard.KEYCODE_TAB -> "⇥"
else -> label
}
Text(
text = displayLabel,
- fontSize = if (displayLabel.length > 2) 13.sp else 18.sp,
+ fontSize = if (displayLabel.length > 2) 12.sp else 18.sp,
fontWeight = if (displayLabel.length > 2 || isActionKey) FontWeight.SemiBold else FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurface,
+ color = if (code == ItemMainKeyboard.KEYCODE_SPACE && isSlidingSpace) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
+
+ if (!hintLabel.isNullOrBlank() && !isActionKey && code != ItemMainKeyboard.KEYCODE_SPACE) {
+ Text(
+ text = hintLabel,
+ fontSize = 9.sp,
+ fontWeight = FontWeight.Normal,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f),
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(top = 2.dp, end = 3.dp)
+ )
+ }
}
}
}
diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/OnKeyboardActionListener.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/OnKeyboardActionListener.kt
index 48746d7..eaa9ad2 100644
--- a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/OnKeyboardActionListener.kt
+++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/OnKeyboardActionListener.kt
@@ -48,4 +48,14 @@ interface OnKeyboardActionListener {
* Called when the user swipes left on Backspace to delete words
*/
fun deleteWordsBeforeCursor(count: Int) {}
+
+ /**
+ * Called when the user performs a gesture swipe typing that matches words
+ */
+ fun onGestureMatch(word: String, suggestions: List) {}
+
+ /**
+ * Called when the user swipes down on the keyboard to dismiss/hide it
+ */
+ fun onSwipeDown() {}
}
\ No newline at end of file
diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/theme/FrogoLibKeyboardTheme.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/theme/FrogoLibKeyboardTheme.kt
index 1163131..039a6e6 100644
--- a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/theme/FrogoLibKeyboardTheme.kt
+++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/theme/FrogoLibKeyboardTheme.kt
@@ -56,13 +56,30 @@ private val LightColorScheme = lightColorScheme(
outlineVariant = Color(0xFFE2E8F0)
)
+val HighContrastColorScheme = darkColorScheme(
+ primary = Color(0xFFFFE600), // Canary Yellow
+ onPrimary = Color(0xFF000000), // Pure Black
+ primaryContainer = Color(0xFF00E5FF), // Neon Cyan
+ onPrimaryContainer = Color(0xFF000000),
+ surface = Color(0xFF000000), // OLED Pure Black
+ onSurface = Color(0xFFFFFFFF),
+ surfaceVariant = Color(0xFF1A1A1A),
+ onSurfaceVariant = Color(0xFFFFE600),
+ background = Color(0xFF000000),
+ onBackground = Color(0xFFFFFFFF),
+ outline = Color(0xFFFFE600),
+ outlineVariant = Color(0xFF333333)
+)
+
@Composable
fun FrogoLibKeyboardTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
+ isHighContrast: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
+ isHighContrast -> HighContrastColorScheme
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/util/GestureTypingHelper.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/util/GestureTypingHelper.kt
new file mode 100644
index 0000000..e938f97
--- /dev/null
+++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/util/GestureTypingHelper.kt
@@ -0,0 +1,261 @@
+package com.frogobox.libkeyboard.util
+
+import kotlin.math.abs
+import kotlin.math.hypot
+
+/**
+ * Glide / Gesture Typing helper.
+ * Tracks continuous swipe points, extracts key trajectories, and matches against vocabulary words.
+ */
+object GestureTypingHelper {
+
+ data class PathPoint(
+ val x: Float,
+ val y: Float,
+ val timestamp: Long = System.currentTimeMillis()
+ )
+
+ data class KeyBoundingBox(
+ val char: Char,
+ val left: Float,
+ val top: Float,
+ val right: Float,
+ val bottom: Float
+ ) {
+ val centerX: Float get() = (left + right) / 2f
+ val centerY: Float get() = (top + bottom) / 2f
+
+ fun contains(x: Float, y: Float): Boolean {
+ return x in left..right && y in top..bottom
+ }
+
+ fun distanceTo(x: Float, y: Float): Float {
+ return hypot(x - centerX, y - centerY)
+ }
+ }
+
+ private val dynamicVocabulary = java.util.concurrent.ConcurrentHashMap.newKeySet()
+
+ val DEFAULT_VOCABULARY = listOf(
+ // Indonesian Common Words
+ "halo", "hello", "keyboard", "terima", "kasih", "selamat", "pagi", "siang", "sore",
+ "malam", "bagus", "cepat", "bisa", "siap", "oke", "mantap", "sudah", "sedang", "kemarin",
+ "besok", "kamu", "saya", "kita", "kami", "mereka", "dengan", "untuk", "akan", "dalam",
+ "yang", "dan", "dari", "ini", "itu", "ada", "tidak", "bukan", "hanya", "juga",
+ "apa", "siapa", "mana", "kapan", "kenapa", "bagaimana", "rumah", "jalan", "kerja",
+ "makan", "minum", "suka", "mau", "ingin", "tahu", "lihat", "baca", "tulis", "kirim",
+ "pesan", "kabar", "hari", "waktu", "tempat", "orang", "teman", "sahabat", "keluarga",
+ "cinta", "senang", "sehat", "uang", "aplikasi", "tombol", "usap", "ketik", "layar",
+ "fitur", "pintar", "hebat", "luar", "biasa", "buku", "pintu", "buka", "tutup",
+ // English Common Words
+ "good", "great", "thanks", "thank", "please", "yes", "no", "work", "play", "time",
+ "swipe", "gesture", "type", "typing", "quick", "fast", "speed", "smooth", "happy",
+ "today", "tomorrow", "yesterday", "where", "what", "when", "why", "which", "how",
+ "friend", "family", "cool", "nice", "sure", "fine", "need", "want", "know", "see",
+ "look", "think", "come", "make", "take", "like", "have", "would", "could", "should",
+ "there", "their", "about", "after", "before", "with", "from", "this", "that", "here",
+ "well", "done", "best", "android", "mobile", "smart", "write", "read", "book", "door"
+ )
+
+ /**
+ * Add custom or learned words to the dynamic vocabulary.
+ */
+ fun addVocabulary(words: Collection) {
+ words.forEach { word ->
+ val clean = word.lowercase().trim()
+ if (clean.isNotBlank()) {
+ dynamicVocabulary.add(clean)
+ }
+ }
+ }
+
+ /**
+ * Register a single custom or learned word to dynamic vocabulary.
+ */
+ fun registerWord(word: String) {
+ val clean = word.lowercase().trim()
+ if (clean.isNotBlank()) {
+ dynamicVocabulary.add(clean)
+ }
+ }
+
+ /**
+ * Clear dynamic vocabulary.
+ */
+ fun clearDynamicVocabulary() {
+ dynamicVocabulary.clear()
+ }
+
+ /**
+ * Gets combined vocabulary (built-in + dynamically added).
+ */
+ fun getAllVocabulary(): List {
+ val set = LinkedHashSet(DEFAULT_VOCABULARY)
+ set.addAll(dynamicVocabulary)
+ return set.toList()
+ }
+
+ /**
+ * Compresses consecutive duplicate characters (e.g. "hello" -> "helo", "good" -> "god").
+ */
+ fun compressConsecutiveDuplicates(str: String): String {
+ if (str.length <= 1) return str
+ val sb = java.lang.StringBuilder()
+ for (i in str.indices) {
+ if (i == 0 || str[i] != str[i - 1]) {
+ sb.append(str[i])
+ }
+ }
+ return sb.toString()
+ }
+
+ /**
+ * Resolves continuous trajectory points to a sequence of hit characters.
+ * Includes linear interpolation between distant touch points so fast swipes don't skip keys.
+ */
+ fun resolveTrajectoryToChars(points: List, keyBoxes: List): String {
+ if (points.isEmpty() || keyBoxes.isEmpty()) return ""
+
+ val letterBoxes = keyBoxes.filter { it.char.isLetter() }
+ if (letterBoxes.isEmpty()) return ""
+
+ // Adaptive hit distance based on average key width
+ val avgKeyWidth = letterBoxes.map { it.right - it.left }.average().toFloat().takeIf { it > 0f } ?: 80f
+ val maxDist = avgKeyWidth * 0.95f
+
+ // Interpolate points between distant samples on fast swipes
+ val interpolatedPoints = mutableListOf()
+ for (i in points.indices) {
+ val curr = points[i]
+ if (i > 0) {
+ val prev = points[i - 1]
+ val d = hypot(curr.x - prev.x, curr.y - prev.y)
+ if (d > 35f) {
+ val steps = (d / 25f).toInt()
+ for (step in 1 until steps) {
+ val fraction = step.toFloat() / steps
+ interpolatedPoints.add(
+ PathPoint(
+ x = prev.x + (curr.x - prev.x) * fraction,
+ y = prev.y + (curr.y - prev.y) * fraction
+ )
+ )
+ }
+ }
+ }
+ interpolatedPoints.add(curr)
+ }
+
+ val rawChars = mutableListOf()
+ for (pt in interpolatedPoints) {
+ val hitKey = letterBoxes.firstOrNull { it.contains(pt.x, pt.y) }
+ ?: letterBoxes.minByOrNull { it.distanceTo(pt.x, pt.y) }?.takeIf { it.distanceTo(pt.x, pt.y) < maxDist }
+
+ if (hitKey != null) {
+ if (rawChars.isEmpty() || rawChars.last() != hitKey.char) {
+ rawChars.add(hitKey.char)
+ }
+ }
+ }
+ return rawChars.joinToString("")
+ }
+
+ /**
+ * Checks if [wordSequence] characters appear in order within [traversedChars].
+ */
+ fun isSubsequenceInOrder(wordSequence: String, traversedChars: String): Boolean {
+ var tIdx = 0
+ for (wChar in wordSequence) {
+ var found = false
+ while (tIdx < traversedChars.length) {
+ if (traversedChars[tIdx] == wChar) {
+ found = true
+ tIdx++
+ break
+ }
+ tIdx++
+ }
+ if (!found) return false
+ }
+ return true
+ }
+
+ /**
+ * Matches raw traversed characters against a vocabulary list.
+ * Supports duplicate-character resilience (e.g., matching "hello" from "helo")
+ * and edge-hit tolerance for beginning and ending key strokes.
+ */
+ fun matchGesture(
+ traversedChars: String,
+ vocabulary: List = getAllVocabulary(),
+ maxResults: Int = 10
+ ): List {
+ val filteredTraversed = traversedChars.filter { it.isLetter() }.lowercase()
+ if (filteredTraversed.length < 2) return emptyList()
+ val compressedTraversed = compressConsecutiveDuplicates(filteredTraversed)
+
+ // Starting candidate characters: primary is first char, fallback to second if length >= 3
+ val startCandidates = if (compressedTraversed.length >= 3) {
+ listOf(compressedTraversed[0], compressedTraversed[1])
+ } else {
+ listOf(compressedTraversed.first())
+ }
+
+ // Ending candidate characters: primary is last char, fallback to second-to-last if length >= 3
+ val endCandidates = if (compressedTraversed.length >= 3) {
+ listOf(compressedTraversed.last(), compressedTraversed[compressedTraversed.length - 2])
+ } else {
+ listOf(compressedTraversed.last())
+ }
+
+ val matches = mutableListOf>() // Pair of (word, score penalty)
+
+ for (word in vocabulary) {
+ val lowerWord = word.lowercase()
+ if (lowerWord.length < 2 || !lowerWord.all { it.isLetter() }) continue
+
+ val wordFirst = lowerWord.first()
+ val wordLast = lowerWord.last()
+
+ val startIdx = startCandidates.indexOf(wordFirst)
+ val endIdx = endCandidates.indexOf(wordLast)
+
+ if (startIdx == -1 || endIdx == -1) continue
+
+ val penalty = startIdx * 2 + endIdx * 2
+ val compressedWord = compressConsecutiveDuplicates(lowerWord)
+
+ val isMatch = isSubsequenceInOrder(compressedWord, compressedTraversed) ||
+ isSubsequenceInOrder(compressedWord, filteredTraversed) ||
+ isSubsequenceInOrder(lowerWord, filteredTraversed)
+
+ if (isMatch) {
+ matches.add(word to penalty)
+ }
+ }
+
+ // Rank matches: exact start/end match (penalty 0) first, then exact compressed match, then closest length
+ return matches.distinctBy { it.first }.sortedWith(
+ compareBy> { it.second }
+ .thenBy { (word, _) ->
+ val comp = compressConsecutiveDuplicates(word.lowercase())
+ if (comp == compressedTraversed) 0 else 1
+ }
+ .thenBy { (word, _) -> abs(word.length - compressedTraversed.length) }
+ .thenBy { it.first.length }
+ ).map { it.first }.take(maxResults)
+ }
+
+ /**
+ * Complete pipeline: resolves points to chars and matches against vocabulary.
+ */
+ fun resolveAndMatch(
+ points: List,
+ keyBoxes: List,
+ vocabulary: List = getAllVocabulary()
+ ): List {
+ val chars = resolveTrajectoryToChars(points, keyBoxes)
+ if (chars.length < 2) return emptyList()
+ return matchGesture(chars, vocabulary)
+ }
+}
diff --git a/lib-keyboard/src/main/res/drawable/ic_keyboard_done.xml b/lib-keyboard/src/main/res/drawable/ic_keyboard_done.xml
new file mode 100644
index 0000000..f5802cc
--- /dev/null
+++ b/lib-keyboard/src/main/res/drawable/ic_keyboard_done.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/lib-keyboard/src/main/res/values-night/themes.xml b/lib-keyboard/src/main/res/values-night/themes.xml
index 15807fb..25ee39b 100644
--- a/lib-keyboard/src/main/res/values-night/themes.xml
+++ b/lib-keyboard/src/main/res/values-night/themes.xml
@@ -1,4 +1,4 @@
-
+
@@ -25,10 +25,9 @@
- @color/teal_200
- @color/black
- - ?attr/colorPrimaryVariant
+ - ?attr/colorPrimaryVariant
-
\ No newline at end of file
diff --git a/lib-keyboard/src/main/res/values/attrs.xml b/lib-keyboard/src/main/res/values/attrs.xml
index cb2ae55..20bfe3f 100644
--- a/lib-keyboard/src/main/res/values/attrs.xml
+++ b/lib-keyboard/src/main/res/values/attrs.xml
@@ -4,8 +4,14 @@
+
+
-
+
+
+
+
+
diff --git a/lib-keyboard/src/main/res/values/colors.xml b/lib-keyboard/src/main/res/values/colors.xml
index bee3f7b..6c97cdf 100644
--- a/lib-keyboard/src/main/res/values/colors.xml
+++ b/lib-keyboard/src/main/res/values/colors.xml
@@ -6,6 +6,8 @@
#FF3700B3
#FF03DAC5
#FF018786
+ #FF0D9488
+ #FF2DD4BF
diff --git a/lib-keyboard/src/main/res/values/themes.xml b/lib-keyboard/src/main/res/values/themes.xml
index 30fac6b..7aab0e0 100644
--- a/lib-keyboard/src/main/res/values/themes.xml
+++ b/lib-keyboard/src/main/res/values/themes.xml
@@ -1,4 +1,4 @@
-
+
@@ -25,7 +25,7 @@
- @color/teal_700
- @color/black
- - ?attr/colorPrimaryVariant
+ - ?attr/colorPrimaryVariant