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