From 4241cd269f2478271ef2748d25e99d5cf53a573a Mon Sep 17 00:00:00 2001 From: amirisback Date: Mon, 27 Apr 2026 12:01:05 +0700 Subject: [PATCH 1/7] feat: implement custom keyboard layout and infrastructure for FrogoKeyboard module --- .../common/core/BaseKeyboardIME.kt | 14 ++-- .../libkeyboard/common/ext/ContextExt.kt | 74 ++++++++++++------- .../frogobox/libkeyboard/common/ext/IntExt.kt | 10 ++- .../libkeyboard/ui/emoji/EmojiKeyboard.kt | 2 +- .../libkeyboard/ui/main/ItemMainKeyboard.kt | 4 +- .../libkeyboard/ui/main/MainKeyboard.kt | 5 +- 6 files changed, 68 insertions(+), 41 deletions(-) diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt index 54429fd..d2181f6 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt @@ -82,7 +82,8 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) { super.onStartInput(attribute, restarting) - inputTypeClass = attribute!!.inputType and TYPE_MASK_CLASS + if (attribute == null) return + inputTypeClass = attribute.inputType and TYPE_MASK_CLASS enterKeyType = attribute.imeOptions and (IME_MASK_ACTION or IME_FLAG_NO_ENTER_ACTION) val keyboardXml = when (inputTypeClass) { @@ -109,7 +110,7 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo override fun onKey(code: Int) { - var inputConnection = currentInputConnection + val inputConnection = currentInputConnection ?: return onKeyExt(code, inputConnection) } @@ -314,8 +315,8 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo val originalText = inputConnection.getExtractedText(ExtractedTextRequest(), 0)?.text ?: return inputConnection.commitText(codeChar.toString(), 1) - val newText = inputConnection.getExtractedText(ExtractedTextRequest(), 0).text - switchToLetters = originalText != newText + val newText = inputConnection.getExtractedText(ExtractedTextRequest(), 0)?.text + switchToLetters = newText != null && originalText != newText } else { inputConnection.commitText(codeChar.toString(), 1) } @@ -336,10 +337,11 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo val extractedText = currentInputConnection?.getExtractedText(ExtractedTextRequest(), 0) ?: return var newCursorPosition = extractedText.selectionStart + val textLength = extractedText.text?.length ?: return newCursorPosition = if (moveRight) { - newCursorPosition + 1 + (newCursorPosition + 1).coerceAtMost(textLength) } else { - newCursorPosition - 1 + (newCursorPosition - 1).coerceAtLeast(0) } currentInputConnection?.setSelection(newCursorPosition, newCursorPosition) diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt index 85576ae..fd0857a 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt @@ -1,28 +1,46 @@ -package com.frogobox.libkeyboard.common.ext - -import android.content.Context -import android.content.res.Configuration -import android.content.res.Configuration.UI_MODE_NIGHT_YES -import android.graphics.PorterDuff -import android.graphics.drawable.Drawable -import android.widget.ImageView -import com.frogobox.libkeyboard.R -import com.frogobox.sdk.ext.getColorExt - -/** - * Created by Faisal Amir on 24/10/22 - * ----------------------------------------- - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - * Copyright (C) Frogobox ID / amirisback - * All rights reserved - */ - -fun Context.isDarkThemeOn(): Boolean { - return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES -} - -fun Drawable.applyColorFilter(color: Int) = mutate().setColorFilter(color, PorterDuff.Mode.SRC_IN) - -fun ImageView.applyColorFilter(color: Int) = setColorFilter(color, PorterDuff.Mode.SRC_IN) \ No newline at end of file +package com.frogobox.libkeyboard.common.ext + +import android.content.Context +import android.content.res.Configuration +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.graphics.BlendMode +import android.graphics.BlendModeColorFilter +import android.graphics.PorterDuff +import android.graphics.drawable.Drawable +import android.os.Build +import android.widget.ImageView +import com.frogobox.libkeyboard.R +import com.frogobox.sdk.ext.getColorExt + +/** + * Created by Faisal Amir on 24/10/22 + * ----------------------------------------- + * E-mail : faisalamircs@gmail.com + * Github : github.com/amirisback + * ----------------------------------------- + * Copyright (C) Frogobox ID / amirisback + * All rights reserved + */ + +fun Context.isDarkThemeOn(): Boolean { + return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES +} + +fun Drawable.applyColorFilter(color: Int) { + mutate() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + colorFilter = BlendModeColorFilter(color, BlendMode.SRC_IN) + } else { + @Suppress("DEPRECATION") + setColorFilter(color, PorterDuff.Mode.SRC_IN) + } +} + +fun ImageView.applyColorFilter(color: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + colorFilter = BlendModeColorFilter(color, BlendMode.SRC_IN) + } else { + @Suppress("DEPRECATION") + setColorFilter(color, PorterDuff.Mode.SRC_IN) + } +} \ No newline at end of file diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/IntExt.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/IntExt.kt index 0ecf452..a6a2185 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/IntExt.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/IntExt.kt @@ -7,7 +7,12 @@ fun hsl2hsv(hsl: FloatArray): FloatArray { var sat = hsl[1] val light = hsl[2] sat *= if (light < .5) light else 1 - light - return floatArrayOf(hue, 2f * sat / (light + sat), light + sat) + val denominator = light + sat + return if (denominator == 0f) { + floatArrayOf(hue, 0f, 0f) + } else { + floatArrayOf(hue, 2f * sat / denominator, denominator) + } } fun hsv2hsl(hsv: FloatArray): FloatArray { @@ -16,7 +21,8 @@ fun hsv2hsl(hsv: FloatArray): FloatArray { val value = hsv[2] val newHue = (2f - sat) * value - var newSat = sat * value / if (newHue < 1f) newHue else 2f - newHue + val denominator = if (newHue < 1f) newHue else 2f - newHue + var newSat = if (denominator == 0f) 0f else sat * value / denominator if (newSat > 1f) newSat = 1f return floatArrayOf(hue, newSat, newHue / 2f) diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboard.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboard.kt index 5671c71..a8508c8 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboard.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboard.kt @@ -136,7 +136,7 @@ class EmojiKeyboard( position: Int, notifyListener: FrogoRecyclerNotifyListener ) { - mOnKeyboardActionListener!!.onText(data) + mOnKeyboardActionListener?.onText(data) } override fun onItemLongClicked( diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt index a82c06b..ae4575b 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt @@ -1,5 +1,6 @@ package com.frogobox.libkeyboard.ui.main +import android.util.Log import android.annotation.SuppressLint import android.content.Context import android.content.res.Resources @@ -42,7 +43,7 @@ class ItemMainKeyboard { var mMinWidth = 0 /** List of keys in this keyboard */ - var mKeys: MutableList? = null + var mKeys: MutableList? = null /** Width of the screen available to fit the keyboard */ private var mDisplayWidth = 0 @@ -415,6 +416,7 @@ class ItemMainKeyboard { } } } catch (e: Exception) { + Log.e("ItemMainKeyboard", "Error loading keyboard XML", e) } mHeight = y } diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt index 7192413..14e7f7b 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt @@ -1100,9 +1100,8 @@ class MainKeyboard @JvmOverloads constructor( // If we're not on a repeating key (which sends on a DOWN event) if (mRepeatKeyIndex == NOT_A_KEY && !mMiniKeyboardOnScreen && !mAbortKey) { detectAndSendKey(mCurrentKey, touchX, touchY, eventTime) - } - - if (mKeys.getOrNull(mCurrentKey)?.code == KEYCODE_SPACE && !mIsLongPressingSpace) { + } else if (mRepeatKeyIndex != NOT_A_KEY && mKeys.getOrNull(mCurrentKey)?.code == KEYCODE_SPACE && !mIsLongPressingSpace) { + // Space key was in repeat mode but user released before long-press activated detectAndSendKey(mCurrentKey, touchX, touchY, eventTime) } From 66d6f7fee25e656dd08a993961b768443554078c Mon Sep 17 00:00:00 2001 From: amirisback Date: Mon, 27 Apr 2026 16:01:57 +0700 Subject: [PATCH 2/7] feat: implement modular keyboard IME architecture with base support and feature extensions --- .../appkeyboard/services/KeyboardIME.kt | 3 +- .../common/core/BaseKeyboardIME.kt | 71 +++++------- .../libkeyboard/common/ext/ContextExt.kt | 90 ++++++++------- .../frogobox/libkeyboard/common/ext/IntExt.kt | 31 ++--- .../libkeyboard/ui/emoji/EmojiHelper.kt | 75 +++++++------ .../libkeyboard/ui/emoji/EmojiKeyboard.kt | 31 ++++- .../libkeyboard/ui/main/ItemMainKeyboard.kt | 54 ++++++--- .../libkeyboard/ui/main/MainKeyboard.kt | 106 +++++++++++------- 8 files changed, 252 insertions(+), 209 deletions(-) diff --git a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt index 74ccc37..3aac01f 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt @@ -357,7 +357,8 @@ class KeyboardIME : BaseKeyboardIME() { } else { inputConnection = currentInputConnection } - onKeyExt(code, inputConnection) + val ic = inputConnection ?: return + onKeyExt(code, ic) } override fun initView() { diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt index d2181f6..276e133 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt @@ -27,13 +27,15 @@ import com.frogobox.libkeyboard.ui.main.OnKeyboardActionListener // based on https://www.androidauthority.com/lets-build-custom-keyboard-android-832362/ abstract class BaseKeyboardIME : InputMethodService(), OnKeyboardActionListener, IKeyboardIME { - // how quickly do we have to doubletap shift to enable permanent caps lock - var SHIFT_PERM_TOGGLE_SPEED = 500 - val KEYBOARD_LETTERS = 0 - val KEYBOARD_SYMBOLS = 1 - val KEYBOARD_SYMBOLS_SHIFT = 2 - val KEYBOARD_NUMBER = 3 - val KEYCODE_EMOJI = -6 + companion object { + // How quickly do we have to doubletap shift to enable permanent caps lock + const val SHIFT_PERM_TOGGLE_SPEED = 500L + + const val KEYBOARD_LETTERS = 0 + const val KEYBOARD_SYMBOLS = 1 + const val KEYBOARD_SYMBOLS_SHIFT = 2 + const val KEYBOARD_NUMBER = 3 + } var keyboard: ItemMainKeyboard? = null @@ -77,7 +79,6 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo } override fun onPress(primaryCode: Int) { - } override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) { @@ -108,7 +109,6 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo updateShiftKeyState() } - override fun onKey(code: Int) { val inputConnection = currentInputConnection ?: return onKeyExt(code, inputConnection) @@ -121,7 +121,7 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo val editorInfo = currentInputEditorInfo if (editorInfo != null && editorInfo.inputType != InputType.TYPE_NULL && keyboard?.mShiftState != SHIFT_ON_PERMANENT) { - if (currentInputConnection.getCursorCapsMode(editorInfo.inputType) != 0) { + if (currentInputConnection?.getCursorCapsMode(editorInfo.inputType) != 0) { keyboard?.setShifted(SHIFT_ON_ONE_CHAR) } } @@ -140,7 +140,7 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo } override fun onText(text: String) { - currentInputConnection?.commitText(text, 0) + currentInputConnection?.commitText(text, 1) } override fun initialSetupKeyboard() {} @@ -194,7 +194,7 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo if (keyboardMode == KEYBOARD_LETTERS) { val editorInfo = currentInputEditorInfo if (editorInfo != null && editorInfo.inputType != InputType.TYPE_NULL && keyboard?.mShiftState != SHIFT_ON_PERMANENT) { - if (currentInputConnection.getCursorCapsMode(editorInfo.inputType) != 0) { + if (currentInputConnection?.getCursorCapsMode(editorInfo.inputType) != 0) { keyboard?.setShifted(SHIFT_ON_ONE_CHAR) invalidateAllKeys() } @@ -202,11 +202,8 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo } } - override fun onKeyExt(code: Int, inputConnection: InputConnection) { - if (keyboard == null) { - return - } + val kb = keyboard ?: return if (code != ItemMainKeyboard.KEYCODE_SHIFT) { lastShiftPressTS = 0 @@ -214,19 +211,18 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo when (code) { ItemMainKeyboard.KEYCODE_DELETE -> { - if (keyboard!!.mShiftState == SHIFT_ON_ONE_CHAR) { - keyboard!!.mShiftState = SHIFT_OFF + if (kb.mShiftState == SHIFT_ON_ONE_CHAR) { + kb.mShiftState = SHIFT_OFF } val selectedText = inputConnection.getSelectedText(0) if (TextUtils.isEmpty(selectedText)) { inputConnection.sendKeyEvent( - KeyEvent( - KeyEvent.ACTION_DOWN, - KeyEvent.KEYCODE_DEL - ) + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL) + ) + inputConnection.sendKeyEvent( + KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL) ) - inputConnection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL)) } else { inputConnection.commitText("", 1) } @@ -240,14 +236,10 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo ItemMainKeyboard.KEYCODE_SHIFT -> { if (keyboardMode == KEYBOARD_LETTERS) { when { - keyboard!!.mShiftState == SHIFT_ON_PERMANENT -> keyboard!!.mShiftState = - SHIFT_OFF - System.currentTimeMillis() - lastShiftPressTS < SHIFT_PERM_TOGGLE_SPEED -> keyboard!!.mShiftState = - SHIFT_ON_PERMANENT - keyboard!!.mShiftState == SHIFT_ON_ONE_CHAR -> keyboard!!.mShiftState = - SHIFT_OFF - keyboard!!.mShiftState == SHIFT_OFF -> keyboard!!.mShiftState = - SHIFT_ON_ONE_CHAR + kb.mShiftState == SHIFT_ON_PERMANENT -> kb.mShiftState = SHIFT_OFF + System.currentTimeMillis() - lastShiftPressTS < SHIFT_PERM_TOGGLE_SPEED -> kb.mShiftState = SHIFT_ON_PERMANENT + kb.mShiftState == SHIFT_ON_ONE_CHAR -> kb.mShiftState = SHIFT_OFF + kb.mShiftState == SHIFT_OFF -> kb.mShiftState = SHIFT_ON_ONE_CHAR } lastShiftPressTS = System.currentTimeMillis() @@ -270,23 +262,16 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo inputConnection.performEditorAction(imeOptionsActionId) } else { inputConnection.sendKeyEvent( - KeyEvent( - KeyEvent.ACTION_DOWN, - KeyEvent.KEYCODE_ENTER - ) + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER) ) inputConnection.sendKeyEvent( - KeyEvent( - KeyEvent.ACTION_UP, - KeyEvent.KEYCODE_ENTER - ) + KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER) ) } if (inputConnection != currentInputConnection) { inputConnection.commitText("\n", 1) } - } ItemMainKeyboard.KEYCODE_MODE_CHANGE -> { val keyboardXml = if (keyboardMode == KEYBOARD_LETTERS) { @@ -304,7 +289,7 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo } else -> { var codeChar = code.toChar() - if (Character.isLetter(codeChar) && keyboard!!.mShiftState > SHIFT_OFF) { + if (Character.isLetter(codeChar) && kb.mShiftState > SHIFT_OFF) { codeChar = Character.toUpperCase(codeChar) } @@ -321,8 +306,8 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo inputConnection.commitText(codeChar.toString(), 1) } - if (keyboard!!.mShiftState == SHIFT_ON_ONE_CHAR && keyboardMode == KEYBOARD_LETTERS) { - keyboard!!.mShiftState = SHIFT_OFF + if (kb.mShiftState == SHIFT_ON_ONE_CHAR && keyboardMode == KEYBOARD_LETTERS) { + kb.mShiftState = SHIFT_OFF invalidateAllKeys() } } diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt index fd0857a..47d49fd 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt @@ -1,46 +1,44 @@ -package com.frogobox.libkeyboard.common.ext - -import android.content.Context -import android.content.res.Configuration -import android.content.res.Configuration.UI_MODE_NIGHT_YES -import android.graphics.BlendMode -import android.graphics.BlendModeColorFilter -import android.graphics.PorterDuff -import android.graphics.drawable.Drawable -import android.os.Build -import android.widget.ImageView -import com.frogobox.libkeyboard.R -import com.frogobox.sdk.ext.getColorExt - -/** - * Created by Faisal Amir on 24/10/22 - * ----------------------------------------- - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - * Copyright (C) Frogobox ID / amirisback - * All rights reserved - */ - -fun Context.isDarkThemeOn(): Boolean { - return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES -} - -fun Drawable.applyColorFilter(color: Int) { - mutate() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - colorFilter = BlendModeColorFilter(color, BlendMode.SRC_IN) - } else { - @Suppress("DEPRECATION") - setColorFilter(color, PorterDuff.Mode.SRC_IN) - } -} - -fun ImageView.applyColorFilter(color: Int) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - colorFilter = BlendModeColorFilter(color, BlendMode.SRC_IN) - } else { - @Suppress("DEPRECATION") - setColorFilter(color, PorterDuff.Mode.SRC_IN) - } -} \ No newline at end of file +package com.frogobox.libkeyboard.common.ext + +import android.content.Context +import android.content.res.Configuration +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.graphics.BlendMode +import android.graphics.BlendModeColorFilter +import android.graphics.PorterDuff +import android.graphics.drawable.Drawable +import android.os.Build +import android.widget.ImageView + +/** + * Created by Faisal Amir on 24/10/22 + * ----------------------------------------- + * E-mail : faisalamircs@gmail.com + * Github : github.com/amirisback + * ----------------------------------------- + * Copyright (C) Frogobox ID / amirisback + * All rights reserved + */ + +fun Context.isDarkThemeOn(): Boolean { + return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES +} + +fun Drawable.applyColorFilter(color: Int) { + mutate() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + colorFilter = BlendModeColorFilter(color, BlendMode.SRC_IN) + } else { + @Suppress("DEPRECATION") + setColorFilter(color, PorterDuff.Mode.SRC_IN) + } +} + +fun ImageView.applyColorFilter(color: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + colorFilter = BlendModeColorFilter(color, BlendMode.SRC_IN) + } else { + @Suppress("DEPRECATION") + setColorFilter(color, PorterDuff.Mode.SRC_IN) + } +} \ No newline at end of file diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/IntExt.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/IntExt.kt index a6a2185..bdb1602 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/IntExt.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/IntExt.kt @@ -1,6 +1,7 @@ package com.frogobox.libkeyboard.common.ext import android.graphics.Color +import kotlin.math.roundToInt fun hsl2hsv(hsl: FloatArray): FloatArray { val hue = hsl[0] @@ -28,39 +29,30 @@ fun hsv2hsl(hsv: FloatArray): FloatArray { return floatArrayOf(hue, newSat, newHue / 2f) } -fun Int.lightenColor(factor: Int = 8): Int { +/** + * Adjusts the lightness of a color by a given factor (in percent). + * Positive factor lightens, negative factor darkens. + */ +private fun Int.adjustLightness(factor: Int): Int { if (this == Color.WHITE || this == Color.BLACK) { return this } - val LIGHT_FACTOR = factor var hsv = FloatArray(3) Color.colorToHSV(this, hsv) val hsl = hsv2hsl(hsv) - hsl[2] += LIGHT_FACTOR / 100f - if (hsl[2] < 0) hsl[2] = 0f + hsl[2] = (hsl[2] + factor / 100f).coerceIn(0f, 1f) hsv = hsl2hsv(hsl) return Color.HSVToColor(hsv) } -// taken from https://stackoverflow.com/a/40964456/1967672 -fun Int.darkenColor(factor: Int = 8): Int { - if (this == Color.WHITE || this == Color.BLACK) { - return this - } +fun Int.lightenColor(factor: Int = 8): Int = adjustLightness(factor) - val DARK_FACTOR = factor - var hsv = FloatArray(3) - Color.colorToHSV(this, hsv) - val hsl = hsv2hsl(hsv) - hsl[2] -= DARK_FACTOR / 100f - if (hsl[2] < 0) hsl[2] = 0f - hsv = hsl2hsv(hsl) - return Color.HSVToColor(hsv) -} +// taken from https://stackoverflow.com/a/40964456/1967672 +fun Int.darkenColor(factor: Int = 8): Int = adjustLightness(-factor) fun Int.adjustAlpha(factor: Float): Int { - val alpha = Math.round(Color.alpha(this) * factor) + val alpha = (Color.alpha(this) * factor).roundToInt() val red = Color.red(this) val green = Color.green(this) val blue = Color.blue(this) @@ -72,4 +64,3 @@ fun Int.getContrastColor(): Int { val y = (299 * Color.red(this) + 587 * Color.green(this) + 114 * Color.blue(this)) / 1000 return if (y >= 149 && this != Color.BLACK) DARK_GREY else Color.WHITE } - diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiHelper.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiHelper.kt index f9c9a3f..878fef5 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiHelper.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiHelper.kt @@ -1,9 +1,12 @@ package com.frogobox.libkeyboard.ui.emoji import android.content.Context +import android.util.Log + +private const val TAG = "EmojiHelper" /** - * Reads the emoji list at the given [path] and returns an parsed [MutableList]. If the + * Reads the emoji list at the given [path] and returns a parsed [MutableList]. If the * given file path does not exist, an empty [MutableList] is returned. * * @param context The initiating view's context. @@ -17,54 +20,52 @@ fun parseRawEmojiSpecsFile(context: Context, path: String): MutableList fun commitEmojiEditorList() { emojiEditorList?.let { // add only the base emoji for now, ignore the variations - emojis.add(it.first()) + if (it.isNotEmpty()) { + emojis.add(it.first()) + } } emojiEditorList = null } - context.assets.open(path).bufferedReader().useLines { lines -> - for (line in lines) { - if (line.startsWith("#")) { - // Comment line - } else if (line.startsWith("[")) { - commitEmojiEditorList() - } else if (line.trim().isEmpty()) { - // Empty line - continue - } else { - if (!line.startsWith("\t")) { + try { + context.assets.open(path).bufferedReader().useLines { lines -> + for (line in lines) { + if (line.startsWith("#")) { + // Comment line + } else if (line.startsWith("[")) { commitEmojiEditorList() - } + } else if (line.trim().isEmpty()) { + // Empty line + continue + } else { + if (!line.startsWith("\t")) { + commitEmojiEditorList() + } - // Assume it is a data line - val data = line.split(";") - if (data.size == 3) { - val emoji = data[0].trim() - if (emojiEditorList != null) { - emojiEditorList!!.add(emoji) - } else { - emojiEditorList = mutableListOf(emoji) + // Assume it is a data line + val data = line.split(";") + if (data.size == 3) { + val emoji = data[0].trim() + emojiEditorList?.add(emoji) ?: run { + emojiEditorList = mutableListOf(emoji) + } } } } + commitEmojiEditorList() } - commitEmojiEditorList() + } catch (e: Exception) { + Log.e(TAG, "Failed to parse emoji file: $path", e) } - + return emojis } -fun getEmojiCategory() : List { - return listOf( - EmojiCategory(EmojiCategoryType.GENERAL.name, EmojiCategoryType.GENERAL.icon, EmojiCategoryType.GENERAL.path), - EmojiCategory(EmojiCategoryType.SMILEYS_EMOTION.name, EmojiCategoryType.SMILEYS_EMOTION.icon, EmojiCategoryType.SMILEYS_EMOTION.path), - EmojiCategory(EmojiCategoryType.PEOPLE_BODY.name, EmojiCategoryType.PEOPLE_BODY.icon, EmojiCategoryType.PEOPLE_BODY.path), - EmojiCategory(EmojiCategoryType.ACTIVITIES.name, EmojiCategoryType.ACTIVITIES.icon, EmojiCategoryType.ACTIVITIES.path), - EmojiCategory(EmojiCategoryType.ANIMAL_NATURE.name, EmojiCategoryType.ANIMAL_NATURE.icon, EmojiCategoryType.ANIMAL_NATURE.path), - EmojiCategory(EmojiCategoryType.FOOD_DRINK.name, EmojiCategoryType.FOOD_DRINK.icon, EmojiCategoryType.FOOD_DRINK.path), - EmojiCategory(EmojiCategoryType.FLAG.name, EmojiCategoryType.FLAG.icon, EmojiCategoryType.FLAG.path), - EmojiCategory(EmojiCategoryType.OBJECTS.name, EmojiCategoryType.OBJECTS.icon, EmojiCategoryType.OBJECTS.path), - EmojiCategory(EmojiCategoryType.TRAVEL_PLACES.name, EmojiCategoryType.TRAVEL_PLACES.icon, EmojiCategoryType.TRAVEL_PLACES.path), - EmojiCategory(EmojiCategoryType.SYMBOLS.name, EmojiCategoryType.SYMBOLS.icon, EmojiCategoryType.SYMBOLS.path) - ) +/** + * Returns all emoji categories auto-generated from [EmojiCategoryType] enum entries. + */ +fun getEmojiCategory(): List { + return EmojiCategoryType.entries.map { type -> + EmojiCategory(name = type.name, icon = type.icon, path = type.path) + } } \ No newline at end of file diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboard.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboard.kt index a8508c8..ed19597 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboard.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboard.kt @@ -36,6 +36,14 @@ class EmojiKeyboard( private var emojiCompatMetadataVersion = 0 + /** Cache parsed emoji lists to avoid re-parsing files on category switch */ + private val emojiCache = mutableMapOf>() + + /** Reusable Paint for glyph checking — avoid allocating every time */ + private val systemFontPaint = Paint().apply { + typeface = Typeface.DEFAULT + } + var mOnKeyboardActionListener: OnKeyboardActionListener? = null override fun setupViewBinding(inflater: LayoutInflater, parent: LinearLayout): KeyboardEmojiBinding { @@ -50,6 +58,11 @@ class EmojiKeyboard( setupEmojiCategory() } + /** Clears the emoji cache. Call this if emoji assets are updated. */ + fun clearEmojiCache() { + emojiCache.clear() + } + private fun setupEmojiCategory() { val adapterCallback = object : IFrogoBindingAdapter { @@ -106,11 +119,15 @@ class EmojiKeyboard( } private fun setupEmojis(path: String) { + // Check cache first + val cachedEmojis = emojiCache[path] + if (cachedEmojis != null) { + setupEmojiAdapter(cachedEmojis) + return + } + ensureBackgroundThread { val fullEmojiList = parseRawEmojiSpecsFile(context, path) - val systemFontPaint = Paint().apply { - typeface = Typeface.DEFAULT - } val emojis = fullEmojiList.filter { emoji -> systemFontPaint.hasGlyph(emoji) || (EmojiCompat.get().loadState == EmojiCompat.LOAD_STATE_SUCCEEDED @@ -120,10 +137,12 @@ class EmojiKeyboard( ) == EmojiCompat.EMOJI_SUPPORTED) } + // Store in cache + emojiCache[path] = emojis + Handler(Looper.getMainLooper()).post { setupEmojiAdapter(emojis) } - } } @@ -145,7 +164,7 @@ class EmojiKeyboard( position: Int, notifyListener: FrogoRecyclerNotifyListener ) { - mOnKeyboardActionListener!!.onText(data) + mOnKeyboardActionListener?.onText(data) } override fun areContentsTheSame(oldItem: String, newItem: String): Boolean { @@ -153,7 +172,7 @@ class EmojiKeyboard( } override fun areItemsTheSame(oldItem: String, newItem: String): Boolean { - return oldItem.hashCode() == newItem.hashCode() + return oldItem == newItem } override fun setViewBinding(parent: ViewGroup): ItemKeyboardEmojiBinding { diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt index ae4575b..816d3d4 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt @@ -1,12 +1,12 @@ package com.frogobox.libkeyboard.ui.main -import android.util.Log import android.annotation.SuppressLint import android.content.Context import android.content.res.Resources import android.content.res.TypedArray import android.content.res.XmlResourceParser import android.graphics.drawable.Drawable +import android.util.Log import android.util.TypedValue import android.util.Xml import android.view.inputmethod.EditorInfo @@ -43,7 +43,7 @@ class ItemMainKeyboard { var mMinWidth = 0 /** List of keys in this keyboard */ - var mKeys: MutableList? = null + val mKeys = mutableListOf() /** Width of the screen available to fit the keyboard */ private var mDisplayWidth = 0 @@ -52,9 +52,10 @@ class ItemMainKeyboard { private var mEnterKeyType = IME_ACTION_NONE /** Keyboard rows */ - private val mRows = ArrayList() + private val mRows = ArrayList() companion object { + private const val TAG = "ItemMainKeyboard" private const val TAG_KEYBOARD = "Keyboard" private const val TAG_ROW = "Row" private const val TAG_KEY = "Key" @@ -68,7 +69,12 @@ class ItemMainKeyboard { const val KEYCODE_SPACE = 32 const val KEYCODE_EMOJI = -6 - const val VIBRATE_ON_KEYPRESS = false + /** Whether haptic feedback is enabled on key press. Set via [MainKeyboard.vibrateOnKeypress]. */ + var VIBRATE_ON_KEYPRESS = false + + /** Whether key sound feedback is enabled on key press. Set via [MainKeyboard.soundOnKeypress]. */ + var SOUND_ON_KEYPRESS = false + const val SHOW_POPUP_ON_KEYPRESS = true const val SHIFT_OFF = 0 @@ -86,10 +92,7 @@ class ItemMainKeyboard { val value = a.peekValue(index) ?: return defValue return when (value.type) { TypedValue.TYPE_DIMENSION -> a.getDimensionPixelOffset(index, defValue) - TypedValue.TYPE_FRACTION -> Math.round(a.getFraction(index, - base, - base, - defValue.toFloat())) + TypedValue.TYPE_FRACTION -> a.getFraction(index, base, base, defValue.toFloat()).roundToInt() else -> defValue } } @@ -234,7 +237,9 @@ class ItemMainKeyboard { repeatable = a.getBoolean(R.styleable.FrogoKeyboard_Key_isRepeatable, false) edgeFlags = a.getInt(R.styleable.FrogoKeyboard_Key_keyEdgeFlags, 0) icon = a.getDrawable(R.styleable.FrogoKeyboard_Key_keyIcon) - icon?.setBounds(0, 0, icon!!.intrinsicWidth, icon!!.intrinsicHeight) + icon?.let { ic -> + ic.setBounds(0, 0, ic.intrinsicWidth, ic.intrinsicHeight) + } label = a.getText(R.styleable.FrogoKeyboard_Key_keyLabel) ?: "" topSmallNumber = a.getString(R.styleable.FrogoKeyboard_Key_topSmallNumber) ?: "" @@ -262,10 +267,9 @@ class ItemMainKeyboard { fun isInside(x: Int, y: Int): Boolean { val leftEdge = edgeFlags and EDGE_LEFT > 0 val rightEdge = edgeFlags and EDGE_RIGHT > 0 - return ((x >= this.x || leftEdge && x <= this.x + width) - && (x < this.x + width || rightEdge && x >= this.x) - && (y >= this.y && y <= this.y + height) - && (y < this.y + height && y >= this.y)) + val isInsideX = (x >= this.x || leftEdge) && (x < this.x + width || rightEdge) + val isInsideY = y >= this.y && y < this.y + height + return isInsideX && isInsideY } } @@ -281,7 +285,23 @@ class ItemMainKeyboard { mDefaultWidth = mDisplayWidth / 10 mDefaultHeight = mDefaultWidth mKeyboardHeightMultiplier = getKeyboardHeightMultiplier() - mKeys = ArrayList() + mEnterKeyType = enterKeyType + loadKeyboard(context, context.resources.getXml(xmlLayoutResId)) + } + + /** + * Creates a keyboard from the given xml key layout file with a custom height multiplier. + * @param context the application or service context + * @param xmlLayoutResId the resource file that contains the keyboard layout and keys. + * @param enterKeyType determines what icon should we show on Enter key + * @param heightMultiplier multiplier for keyboard height (1.0 = default, 1.2 = 20% taller) + */ + constructor(context: Context, @XmlRes xmlLayoutResId: Int, enterKeyType: Int, heightMultiplier: Float) { + mDisplayWidth = context.resources.displayMetrics.widthPixels + mDefaultHorizontalGap = 0 + mDefaultWidth = mDisplayWidth / 10 + mDefaultHeight = mDefaultWidth + mKeyboardHeightMultiplier = heightMultiplier mEnterKeyType = enterKeyType loadKeyboard(context, context.resources.getXml(xmlLayoutResId)) } @@ -327,7 +347,7 @@ class ItemMainKeyboard { key.code = character.code column++ x += key.width + key.gap - mKeys!!.add(key) + mKeys.add(key) row.mKeys.add(key) if (x > mMinWidth) { mMinWidth = x @@ -384,7 +404,7 @@ class ItemMainKeyboard { TAG_KEY -> { inKey = true key = createKeyFromXml(res, currentRow!!, x, y, parser) - mKeys!!.add(key) + mKeys.add(key) if (key.code == KEYCODE_ENTER) { val enterResourceId = when (mEnterKeyType) { EditorInfo.IME_ACTION_SEARCH -> R.drawable.ic_keyboard_search @@ -416,7 +436,7 @@ class ItemMainKeyboard { } } } catch (e: Exception) { - Log.e("ItemMainKeyboard", "Error loading keyboard XML", e) + Log.e(TAG, "Error loading keyboard XML", e) } mHeight = y } diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt index 14e7f7b..f4bffa1 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt @@ -11,12 +11,15 @@ import android.graphics.PorterDuff import android.graphics.Rect import android.graphics.Typeface import android.graphics.drawable.LayerDrawable +import android.media.AudioManager import android.os.Handler +import android.os.Looper import android.os.Message import android.util.AttributeSet import android.util.TypedValue import android.view.Gravity import android.view.HapticFeedbackConstants +import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent import android.view.View @@ -116,6 +119,22 @@ class MainKeyboard @JvmOverloads constructor( private val mSpaceMoveThreshold: Int private var ignoreTouches = false + /** Reusable Paint for top small numbers — avoids per-frame allocation */ + private val mSmallLetterPaint = Paint() + + /** Audio manager for key click sound feedback */ + private val mAudioManager: AudioManager? + + /** Whether haptic feedback is enabled — can be toggled at runtime */ + var vibrateOnKeypress: Boolean + get() = ItemMainKeyboard.VIBRATE_ON_KEYPRESS + set(value) { ItemMainKeyboard.VIBRATE_ON_KEYPRESS = value } + + /** Whether key click sound is enabled — can be toggled at runtime */ + var soundOnKeypress: Boolean + get() = ItemMainKeyboard.SOUND_ON_KEYPRESS + set(value) { ItemMainKeyboard.SOUND_ON_KEYPRESS = value } + // For multi-tap private var mLastTapTime = 0L @@ -205,6 +224,7 @@ class MainKeyboard @JvmOverloads constructor( mMiniKeyboardCache = HashMap() mAccessibilityManager = (context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager) + mAudioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager mPopupMaxMoveDistance = resources.getDimension(R.dimen.popup_max_move_distance) mTopSmallNumberSize = resources.getDimension(com.frogobox.ui.R.dimen.frogo_dimen_font_10sp) @@ -212,14 +232,13 @@ class MainKeyboard @JvmOverloads constructor( mTopSmallNumberMarginHeight = resources.getDimension(R.dimen.top_small_number_margin_height) } - @SuppressLint("HandlerLeak") override fun onAttachedToWindow() { super.onAttachedToWindow() if (mHandler == null) { - mHandler = object : Handler() { + mHandler = object : Handler(Looper.getMainLooper()) { override fun handleMessage(msg: Message) { when (msg.what) { - MSG_REMOVE_PREVIEW -> mPreviewText!!.visibility = INVISIBLE + MSG_REMOVE_PREVIEW -> mPreviewText?.visibility = INVISIBLE MSG_REPEAT -> if (repeatKey(false)) { val repeat = Message.obtain(this, MSG_REPEAT) sendMessageDelayed(repeat, REPEAT_INTERVAL.toLong()) @@ -267,8 +286,7 @@ class MainKeyboard @JvmOverloads constructor( } removeMessages() mKeyboard = keyboard - val keys = mKeyboard!!.mKeys - mKeys = keys!!.toMutableList() as ArrayList + mKeys = ArrayList(keyboard.mKeys) requestLayout() mKeyboardChanged = true invalidateAllKeys() @@ -282,10 +300,14 @@ class MainKeyboard @JvmOverloads constructor( fun vibrateIfNeeded() { if (ItemMainKeyboard.VIBRATE_ON_KEYPRESS) { - performHapticFeedback( - HapticFeedbackConstants.VIRTUAL_KEY, - HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING - ) + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + } + } + + /** Plays a standard key click sound if sound feedback is enabled. */ + fun playSoundIfNeeded() { + if (ItemMainKeyboard.SOUND_ON_KEYPRESS) { + mAudioManager?.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD) } } @@ -317,8 +339,9 @@ class MainKeyboard @JvmOverloads constructor( } private fun adjustCase(label: CharSequence): CharSequence? { + val kb = mKeyboard ?: return label var newLabel: CharSequence? = label - if (!newLabel.isNullOrEmpty() && mKeyboard!!.mShiftState > SHIFT_OFF && newLabel.length < 3 && Character.isLowerCase( + if (!newLabel.isNullOrEmpty() && kb.mShiftState > SHIFT_OFF && newLabel.length < 3 && Character.isLowerCase( newLabel[0] ) ) { @@ -328,14 +351,15 @@ class MainKeyboard @JvmOverloads constructor( } public override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - if (mKeyboard == null) { + val kb = mKeyboard + if (kb == null) { setMeasuredDimension(0, 0) } else { - var width = mKeyboard!!.mMinWidth + var width = kb.mMinWidth if (MeasureSpec.getSize(widthMeasureSpec) < width + 10) { width = MeasureSpec.getSize(widthMeasureSpec) } - setMeasuredDimension(width, mKeyboard!!.mHeight) + setMeasuredDimension(width, kb.mHeight) } } @@ -397,12 +421,14 @@ class MainKeyboard @JvmOverloads constructor( val paint = mPaint val keys = mKeys paint.color = mTextColor - val smallLetterPaint = Paint().apply { + // Reuse class-level paint instead of allocating each frame + mSmallLetterPaint.apply { set(paint) color = paint.color.adjustAlpha(0.8f) textSize = mTopSmallNumberSize typeface = Typeface.DEFAULT } + val smallLetterPaint = mSmallLetterPaint canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) @@ -468,8 +494,9 @@ class MainKeyboard @JvmOverloads constructor( // Turn off drop shadow paint.setShadowLayer(0f, 0f, 0f, 0) } else if (key.icon != null && mKeyboard != null) { + val kb = mKeyboard!! if (code == KEYCODE_SHIFT) { - val drawableId = when (mKeyboard!!.mShiftState) { + val drawableId = when (kb.mShiftState) { SHIFT_OFF -> R.drawable.ic_keyboard_caps_outline SHIFT_ON_ONE_CHAR -> R.drawable.ic_keyboard_caps else -> R.drawable.ic_keyboard_caps_underlined @@ -477,16 +504,18 @@ class MainKeyboard @JvmOverloads constructor( key.icon = ResourcesCompat.getDrawable(resources, drawableId, null) } - if (code == KEYCODE_DELETE || code == KEYCODE_SHIFT || code == KEYCODE_EMOJI || code == KEYCODE_ENTER) { - key.icon!!.applyColorFilter(mTextColor) - } + key.icon?.let { icon -> + if (code == KEYCODE_DELETE || code == KEYCODE_SHIFT || code == KEYCODE_EMOJI || code == KEYCODE_ENTER) { + icon.applyColorFilter(mTextColor) + } - val drawableX = (key.width - key.icon!!.intrinsicWidth) / 2 - val drawableY = (key.height - key.icon!!.intrinsicHeight) / 2 - canvas.translate(drawableX.toFloat(), drawableY.toFloat()) - key.icon!!.setBounds(0, 0, key.icon!!.intrinsicWidth, key.icon!!.intrinsicHeight) - key.icon!!.draw(canvas) - canvas.translate(-drawableX.toFloat(), -drawableY.toFloat()) + val drawableX = (key.width - icon.intrinsicWidth) / 2 + val drawableY = (key.height - icon.intrinsicHeight) / 2 + canvas.translate(drawableX.toFloat(), drawableY.toFloat()) + icon.setBounds(0, 0, icon.intrinsicWidth, icon.intrinsicHeight) + icon.draw(canvas) + canvas.translate(-drawableX.toFloat(), -drawableY.toFloat()) + } } canvas.translate(-key.x.toFloat(), -key.y.toFloat()) } @@ -512,7 +541,9 @@ class MainKeyboard @JvmOverloads constructor( if (index != NOT_A_KEY && index < mKeys.size) { val key = mKeys[index] getPressedKeyIndex(x, y) - mOnKeyboardActionListener!!.onKey(key.code) + vibrateIfNeeded() + playSoundIfNeeded() + mOnKeyboardActionListener?.onKey(key.code) mLastTapTime = eventTime } } @@ -704,10 +735,7 @@ class MainKeyboard @JvmOverloads constructor( key.x + key.width, key.y + key.height ) onBufferDraw() - invalidate( - key.x, key.y, - key.x + key.width, key.y + key.height - ) + invalidate() } private fun openPopupIfRequired(me: MotionEvent): Boolean { @@ -756,28 +784,28 @@ class MainKeyboard @JvmOverloads constructor( mMiniKeyboard!!.mOnKeyboardActionListener = object : OnKeyboardActionListener { override fun onKey(code: Int) { - mOnKeyboardActionListener!!.onKey(code) + mOnKeyboardActionListener?.onKey(code) dismissPopupKeyboard() } override fun onPress(primaryCode: Int) { - mOnKeyboardActionListener!!.onPress(primaryCode) + mOnKeyboardActionListener?.onPress(primaryCode) } override fun onActionUp() { - mOnKeyboardActionListener!!.onActionUp() + mOnKeyboardActionListener?.onActionUp() } override fun moveCursorLeft() { - mOnKeyboardActionListener!!.moveCursorLeft() + mOnKeyboardActionListener?.moveCursorLeft() } override fun moveCursorRight() { - mOnKeyboardActionListener!!.moveCursorRight() + mOnKeyboardActionListener?.moveCursorRight() } override fun onText(text: String) { - mOnKeyboardActionListener!!.onText(text) + mOnKeyboardActionListener?.onText(text) } } @@ -918,7 +946,7 @@ class MainKeyboard @JvmOverloads constructor( MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { mMiniKeyboard?.mKeys?.firstOrNull { it.focused }?.apply { - mOnKeyboardActionListener!!.onKey(code) + mOnKeyboardActionListener?.onKey(code) } mMiniKeyboardSelectedKeyIndex = -1 dismissPopupKeyboard() @@ -967,7 +995,7 @@ class MainKeyboard @JvmOverloads constructor( val secondKeyCode = mKeys.getOrNull(secondKeyIndex)?.code if (secondKeyCode != null) { - mOnKeyboardActionListener!!.onPress(secondKeyCode) + mOnKeyboardActionListener?.onPress(secondKeyCode) } showPreview(NOT_A_KEY) @@ -992,7 +1020,7 @@ class MainKeyboard @JvmOverloads constructor( 0 } - mOnKeyboardActionListener!!.onPress(onPressKey) + mOnKeyboardActionListener?.onPress(onPressKey) var wasHandled = false if (mCurrentKey >= 0 && mKeys[mCurrentKey].repeatable) { @@ -1107,7 +1135,7 @@ class MainKeyboard @JvmOverloads constructor( invalidateKey(keyIndex) mRepeatKeyIndex = NOT_A_KEY - mOnKeyboardActionListener!!.onActionUp() + mOnKeyboardActionListener?.onActionUp() mIsLongPressingSpace = false } From 5f09d4272d9516407b02d8026724081c2a850d3b Mon Sep 17 00:00:00 2001 From: amirisback Date: Mon, 27 Apr 2026 17:25:56 +0700 Subject: [PATCH 3/7] feat: initialize IME service, Room database, and UI components for keyboard features and auto-text management --- .../java/com/frogobox/appkeyboard/MainApp.kt | 2 + .../appkeyboard/common/ext/DateExt.kt | 43 ++- .../appkeyboard/data/local/db/AppDatabase.kt | 2 +- .../appkeyboard/services/KeyboardIME.kt | 258 +++++++++--------- .../ui/autotext/AutoTextDetailActivity.kt | 2 +- .../ui/autotext/AutoTextEditorActivity.kt | 2 +- .../appkeyboard/ui/main/MainActivity.kt | 4 +- 7 files changed, 167 insertions(+), 146 deletions(-) diff --git a/app/src/main/java/com/frogobox/appkeyboard/MainApp.kt b/app/src/main/java/com/frogobox/appkeyboard/MainApp.kt index cc39066..b040949 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/MainApp.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/MainApp.kt @@ -31,6 +31,7 @@ class MainApp : FrogoApplication() { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { instance.resources.configuration.locales[0] } else { + @Suppress("DEPRECATION") instance.resources.configuration.locale } } @@ -43,6 +44,7 @@ class MainApp : FrogoApplication() { setupEmojiCompat() } + @Suppress("DEPRECATION") private fun setupEmojiCompat() { val config = BundledEmojiCompatConfig(this) EmojiCompat.init(config) diff --git a/app/src/main/java/com/frogobox/appkeyboard/common/ext/DateExt.kt b/app/src/main/java/com/frogobox/appkeyboard/common/ext/DateExt.kt index 7fad923..c4521a7 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/common/ext/DateExt.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/common/ext/DateExt.kt @@ -66,9 +66,10 @@ const val DAY_WITH_DATE_TIME_LOCALE_FULL = "EEEE, dd MMMM yyyy HH:mm" // Senin, if (date != null) { try { - timestamp = df.parse(date).time / 1000 + timestamp = df.parse(date)?.time?.div(1000) ?: (Date().time / 1000) } catch (e: ParseException) { e.printStackTrace() + timestamp = Date().time / 1000 } } else { @@ -98,25 +99,39 @@ const val DAY_WITH_DATE_TIME_LOCALE_FULL = "EEEE, dd MMMM yyyy HH:mm" // Senin, } fun convertDateNewFormat(string: String?): String { - val formatter = SimpleDateFormat(DATE_ENGLISH_YYYY_MM_DD, Locale.getDefault()) - val date = formatter.parse(string) as Date - val newFormat = SimpleDateFormat(DATE_DD_MM_YYYY, Locale("EN")) - return newFormat.format(date) + if (string == null) return "" + return try { + val formatter = SimpleDateFormat(DATE_ENGLISH_YYYY_MM_DD, Locale.getDefault()) + val date = formatter.parse(string) ?: return "" + val newFormat = SimpleDateFormat(DATE_DD_MM_YYYY, Locale.ENGLISH) + newFormat.format(date) + } catch (e: Exception) { + "" + } } fun convertLongDateNewFormat(string: String?): String { - val formatter = SimpleDateFormat(DATE_TIME_STANDARD, Locale.getDefault()) - val date = formatter.parse(string) as Date - val newFormat = SimpleDateFormat("dd-MM-yy HH:mm:ss", Locale("EN")) - return newFormat.format(date) + if (string == null) return "" + return try { + val formatter = SimpleDateFormat(DATE_TIME_STANDARD, Locale.getDefault()) + val date = formatter.parse(string) ?: return "" + val newFormat = SimpleDateFormat("dd-MM-yy HH:mm:ss", Locale.ENGLISH) + newFormat.format(date) + } catch (e: Exception) { + "" + } } fun revertFromLongDateNewFormat(string: String?): String { - val formatter = SimpleDateFormat("dd-MM-yy HH:mm:ss", Locale("EN")) - val date = formatter.parse(string) as Date - val newFormat = SimpleDateFormat(DATE_TIME_STANDARD, Locale.getDefault()) - val finalString = newFormat.format(date) - return finalString + if (string == null) return "" + return try { + val formatter = SimpleDateFormat("dd-MM-yy HH:mm:ss", Locale.ENGLISH) + val date = formatter.parse(string) ?: return "" + val newFormat = SimpleDateFormat(DATE_TIME_STANDARD, Locale.getDefault()) + newFormat.format(date) + } catch (e: Exception) { + "" + } } fun convertTargetDate(string: String?): String { diff --git a/app/src/main/java/com/frogobox/appkeyboard/data/local/db/AppDatabase.kt b/app/src/main/java/com/frogobox/appkeyboard/data/local/db/AppDatabase.kt index 89ff9c9..2b8e0c5 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/data/local/db/AppDatabase.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/data/local/db/AppDatabase.kt @@ -40,7 +40,7 @@ abstract class AppDatabase : RoomDatabase() { private fun buildDatabase(context: Context): AppDatabase { return if (BuildConfig.DEBUG) { Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, DATABASE_NAME) - .fallbackToDestructiveMigration() // FOR DEVELOPMENT ONLY !!!! + .fallbackToDestructiveMigration(dropAllTables = true) // FOR DEVELOPMENT ONLY !!!! .build() } else { Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, DATABASE_NAME) diff --git a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt index 3aac01f..6851839 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt @@ -40,6 +40,134 @@ class KeyboardIME : BaseKeyboardIME() { return KeyboardImeBinding.inflate(LayoutInflater.from(this), null, false) } + private val featureKeyboardCallback = object : + IFrogoBindingAdapter { + + override fun areContentsTheSame( + oldItem: KeyboardFeatureModel, + newItem: KeyboardFeatureModel + ): Boolean { + return oldItem == newItem + } + + override fun areItemsTheSame( + oldItem: KeyboardFeatureModel, + newItem: KeyboardFeatureModel + ): Boolean { + return oldItem.id == newItem.id + } + + override fun setViewBinding(parent: ViewGroup): ItemKeyboardHeaderBinding { + return ItemKeyboardHeaderBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + } + + override fun setupInitComponent( + binding: ItemKeyboardHeaderBinding, + data: KeyboardFeatureModel, + position: Int, + notifyListener: FrogoRecyclerNotifyListener, + ) { + binding.ivIcon.setImageResource(data.icon) + binding.tvTitle.text = data.text + + if (getStateToggle(data.id)) { + binding.root.visible() + } else { + binding.root.gone() + } + + } + + override fun onItemClicked( + binding: ItemKeyboardHeaderBinding, + data: KeyboardFeatureModel, + position: Int, + notifyListener: FrogoRecyclerNotifyListener, + ) { + + when (KeyboardFeatureType.from(data.id)) { + KeyboardFeatureType.NEWS -> { + hideMainKeyboard() + this@KeyboardIME.binding?.keyboardNews?.visible() + } + + KeyboardFeatureType.MOVIE -> { + hideMainKeyboard() + this@KeyboardIME.binding?.keyboardMoview?.visible() + } + + KeyboardFeatureType.WEB -> { + this@KeyboardIME.binding?.keyboardHeader?.gone() + this@KeyboardIME.binding?.keyboardWebview?.visible() + } + + KeyboardFeatureType.FORM -> { + this@KeyboardIME.binding?.keyboardHeader?.gone() + this@KeyboardIME.binding?.keyboardForm?.visible() + this@KeyboardIME.binding?.keyboardForm?.binding?.etText?.showKeyboardExt() + this@KeyboardIME.binding?.keyboardForm?.binding?.etText2?.showKeyboardExt() + this@KeyboardIME.binding?.keyboardForm?.binding?.etText3?.showKeyboardExt() + + this@KeyboardIME.binding?.keyboardForm?.setOnClickListener { + hideOnlyKeyboard() + } + } + + KeyboardFeatureType.AUTO_TEXT -> { + hideMainKeyboard() + this@KeyboardIME.binding?.keyboardAutotext?.visible() + } + + KeyboardFeatureType.TEMPLATE_TEXT_GAME -> { + hideMainKeyboard() + this@KeyboardIME.binding?.keyboardTemplateText?.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_GAME) + this@KeyboardIME.binding?.keyboardTemplateText?.visible() + } + + KeyboardFeatureType.TEMPLATE_TEXT_APP -> { + hideMainKeyboard() + this@KeyboardIME.binding?.keyboardTemplateText?.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_APP) + this@KeyboardIME.binding?.keyboardTemplateText?.visible() + } + + KeyboardFeatureType.TEMPLATE_TEXT_SALE -> { + hideMainKeyboard() + this@KeyboardIME.binding?.keyboardTemplateText?.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_SALE) + this@KeyboardIME.binding?.keyboardTemplateText?.visible() + } + + KeyboardFeatureType.TEMPLATE_TEXT_LOVE -> { + hideMainKeyboard() + this@KeyboardIME.binding?.keyboardTemplateText?.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_LOVE) + this@KeyboardIME.binding?.keyboardTemplateText?.visible() + } + + KeyboardFeatureType.TEMPLATE_TEXT_GREETING -> { + hideMainKeyboard() + this@KeyboardIME.binding?.keyboardTemplateText?.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_GREETING) + this@KeyboardIME.binding?.keyboardTemplateText?.visible() + } + + KeyboardFeatureType.CHANGE_KEYBOARD -> { + (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker() + } + + KeyboardFeatureType.SETTING -> { + binding.root.context.startActivity(Intent( + binding.root.context, MainActivity::class.java + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }) + } + + } + + } + + } + override fun setupTheme() { binding?.apply { @@ -197,133 +325,9 @@ class KeyboardIME : BaseKeyboardIME() { } else { keyboardHeader.visible() keyboardHeader.injectorBinding() - .addData(keyboardUtil.menuKeyboard()).addCallback(object : - IFrogoBindingAdapter { - - override fun areContentsTheSame( - oldItem: KeyboardFeatureModel, - newItem: KeyboardFeatureModel - ): Boolean { - return oldItem == newItem - } - - override fun areItemsTheSame( - oldItem: KeyboardFeatureModel, - newItem: KeyboardFeatureModel - ): Boolean { - return oldItem.id == newItem.id - } - - override fun setViewBinding(parent: ViewGroup): ItemKeyboardHeaderBinding { - return ItemKeyboardHeaderBinding.inflate( - LayoutInflater.from(parent.context), parent, false - ) - } - - override fun setupInitComponent( - binding: ItemKeyboardHeaderBinding, - data: KeyboardFeatureModel, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - binding.ivIcon.setImageResource(data.icon) - binding.tvTitle.text = data.text - - if (getStateToggle(data.id)) { - binding.root.visible() - } else { - binding.root.gone() - } - - } - - override fun onItemClicked( - binding: ItemKeyboardHeaderBinding, - data: KeyboardFeatureModel, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - - when (KeyboardFeatureType.from(data.id)) { - KeyboardFeatureType.NEWS -> { - hideMainKeyboard() - keyboardNews.visible() - } - - KeyboardFeatureType.MOVIE -> { - hideMainKeyboard() - keyboardMoview.visible() - } - - KeyboardFeatureType.WEB -> { - keyboardHeader.gone() - keyboardWebview.visible() - } - - KeyboardFeatureType.FORM -> { - keyboardHeader.gone() - keyboardForm.visible() - keyboardForm.binding.etText.showKeyboardExt() - keyboardForm.binding.etText2.showKeyboardExt() - keyboardForm.binding.etText3.showKeyboardExt() - - keyboardForm.setOnClickListener { - hideOnlyKeyboard() - } - } - - KeyboardFeatureType.AUTO_TEXT -> { - hideMainKeyboard() - keyboardAutotext.visible() - } - - KeyboardFeatureType.TEMPLATE_TEXT_GAME -> { - hideMainKeyboard() - keyboardTemplateText.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_GAME) - keyboardTemplateText.visible() - } - - KeyboardFeatureType.TEMPLATE_TEXT_APP -> { - hideMainKeyboard() - keyboardTemplateText.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_APP) - keyboardTemplateText.visible() - } - - KeyboardFeatureType.TEMPLATE_TEXT_SALE -> { - hideMainKeyboard() - keyboardTemplateText.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_SALE) - keyboardTemplateText.visible() - } - - KeyboardFeatureType.TEMPLATE_TEXT_LOVE -> { - hideMainKeyboard() - keyboardTemplateText.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_LOVE) - keyboardTemplateText.visible() - } - - KeyboardFeatureType.TEMPLATE_TEXT_GREETING -> { - hideMainKeyboard() - keyboardTemplateText.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_GREETING) - keyboardTemplateText.visible() - } - - KeyboardFeatureType.CHANGE_KEYBOARD -> { - (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker() - } - - KeyboardFeatureType.SETTING -> { - binding.root.context.startActivity(Intent( - binding.root.context, MainActivity::class.java - ).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - }) - } - - } - - } - - }).createLayoutGrid(gridSize).build() + .addData(keyboardUtil.menuKeyboard()) + .addCallback(featureKeyboardCallback) + .createLayoutGrid(gridSize).build() } } } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailActivity.kt index 654dbf5..8e75ac2 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailActivity.kt @@ -67,7 +67,7 @@ class AutoTextDetailActivity : BaseActivity() { Gson().fromJson( intent.extras?.getString(EXTRA_AUTO_TEXT), AutoTextEntity::class.java - ) + ) ?: AutoTextEntity() } else { AutoTextEntity() } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorActivity.kt index 719f80f..3ba3df0 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorActivity.kt @@ -82,7 +82,7 @@ class AutoTextEditorActivity : BaseActivity() { return Gson().fromJson( intent.extras?.getString(EXTRA_AUTO_TEXT_EDIT), AutoTextEntity::class.java - ) + ) ?: AutoTextEntity() } private fun hasExtraDetail() : Boolean { 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 ea37ace..9fc25b8 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 @@ -54,9 +54,9 @@ class MainActivity : BaseMainActivity() { override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) - if (mState === PICKING) { + if (mState == PICKING) { mState = CHOSEN - } else if (mState === CHOSEN) { + } else if (mState == CHOSEN) { handlingState() } } From eb3f1d12460698a134d8b021743fd3a06033aecd Mon Sep 17 00:00:00 2001 From: amirisback Date: Sat, 19 Sep 2026 14:27:57 +0700 Subject: [PATCH 4/7] refactor: migrate to coroutines and Flow in AutoText repository and ViewModel, remove RxJava dependencies --- .gitignore | 9 +- app/build.gradle.kts | 29 +- .../java/com/frogobox/appkeyboard/MainApp.kt | 13 +- .../common/base/BaseComposeActivity.kt | 31 ++ .../data/local/autotext/AutoTextDao.kt | 36 +-- .../appkeyboard/data/remote/ApiService.kt | 2 - .../repository/autotext/AutoTextRepository.kt | 24 +- .../autotext/AutoTextRepositoryImpl.kt | 270 ++++++++++++++++-- .../ui/autotext/AutoTextViewModel.kt | 120 +++----- .../autotext/AutoTextKeyboardViewModel.kt | 30 +- .../ui/keyboard/form/FormKeyboard.kt | 1 - .../ui/keyboard/movie/MovieKeyboard.kt | 4 +- .../appkeyboard/ui/test/TestActivity.kt | 50 ++-- .../appkeyboard/ui/test/TestScreen.kt | 177 ++++++++++++ .../appkeyboard/ui/theme/compose/Color.kt | 27 ++ .../appkeyboard/ui/theme/compose/Theme.kt | 48 ++++ .../appkeyboard/ui/theme/compose/Type.kt | 45 +++ app/src/main/res/layout/activity_test.xml | 87 ------ build.gradle.kts | 3 +- buildSrc/src/main/kotlin/ProjectSetting.kt | 6 +- frogo-keyboard/build.gradle.kts | 10 +- .../libkeyboard/common/ext/ContextExt.kt | 20 +- .../libkeyboard/ui/main/MainKeyboard.kt | 4 +- gradle.properties | 23 +- gradle/libs.versions.toml | 38 ++- gradle/wrapper/gradle-wrapper.properties | 2 +- 26 files changed, 789 insertions(+), 320 deletions(-) create mode 100644 app/src/main/java/com/frogobox/appkeyboard/common/base/BaseComposeActivity.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/test/TestScreen.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Color.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Theme.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Type.kt delete mode 100644 app/src/main/res/layout/activity_test.xml diff --git a/.gitignore b/.gitignore index 06b4d63..9365c95 100644 --- a/.gitignore +++ b/.gitignore @@ -50,14 +50,14 @@ app.*.map.json /android/app/release # Miscellaneous -build application files +# build application files *.apk *.ap_ *.aab build/ -Local configuration file (sdk path, etc) +# Local configuration file (sdk path, etc) app/version.properties # Flutter Modules @@ -79,10 +79,13 @@ build /captures .externalNativeBuild -generated files +# generated files bin/ gen/ .externalNativeBuild/ # SonarQube .sonar/ +buildSrc/.kotlin +.kotlin +workflow \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2e5c938..590a675 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,10 +1,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import java.text.SimpleDateFormat -import java.util.Date plugins { alias(libs.plugins.android.application) - alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.jetbrains.kotlin.compose) alias(libs.plugins.hilt) alias(libs.plugins.ksp) id("kotlin-parcelize") @@ -15,15 +13,14 @@ ksp { } base { - // Naming APK // AAB - val timestamp = SimpleDateFormat("dd-MM-yyyy_hh-mm").format(Date()) - archivesName = "${ProjectSetting.NAME_APK}-${ProjectSetting.PROJECT_VERSION_CODE}-[${ProjectSetting.PROJECT_VERSION_NAME}]-$timestamp" + // Naming APK // AAB (stable name avoids invalidating incremental tasks every minute) + archivesName = "${ProjectSetting.NAME_APK}-${ProjectSetting.PROJECT_VERSION_CODE}-[${ProjectSetting.PROJECT_VERSION_NAME}]" } android { namespace = ProjectSetting.PROJECT_NAME_SPACE_APP - compileSdk = ProjectSetting.PROJECT_TARGET_SDK + compileSdk = ProjectSetting.PROJECT_COMPILE_SDK defaultConfig { applicationId = ProjectSetting.PROJECT_APP_ID @@ -97,6 +94,8 @@ android { buildFeatures { buildConfig = true viewBinding = true + compose = true + resValues = true } compileOptions { @@ -115,11 +114,27 @@ kotlin { dependencies { + val composeBom = platform(libs.androidx.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(project(DependencyGradle.MODULE_LIB_FROGO_KEYBOARD)) implementation(libs.androidx.work.ktx) implementation(libs.google.hilt) + // Room & Coroutines + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.github.glide.compiler) ksp(libs.google.hilt.compiler) ksp(libs.androidx.room.compiler) diff --git a/app/src/main/java/com/frogobox/appkeyboard/MainApp.kt b/app/src/main/java/com/frogobox/appkeyboard/MainApp.kt index b040949..68cfb5e 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/MainApp.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/MainApp.kt @@ -1,12 +1,13 @@ package com.frogobox.appkeyboard import android.content.Context -import android.os.Build +import androidx.core.os.ConfigurationCompat import androidx.emoji2.bundled.BundledEmojiCompatConfig import androidx.emoji2.text.EmojiCompat import com.frogobox.sdk.FrogoApplication import dagger.hilt.android.HiltAndroidApp import java.util.Locale +import java.util.concurrent.Executors /** * Created by Faisal Amir on 24/10/22 @@ -28,12 +29,7 @@ class MainApp : FrogoApplication() { fun getContext(): Context = instance.applicationContext fun getCurrentLocale(): Locale? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - instance.resources.configuration.locales[0] - } else { - @Suppress("DEPRECATION") - instance.resources.configuration.locale - } + return ConfigurationCompat.getLocales(instance.resources.configuration)[0] } } @@ -44,9 +40,8 @@ class MainApp : FrogoApplication() { setupEmojiCompat() } - @Suppress("DEPRECATION") private fun setupEmojiCompat() { - val config = BundledEmojiCompatConfig(this) + val config = BundledEmojiCompatConfig(this, Executors.newSingleThreadExecutor()) EmojiCompat.init(config) } diff --git a/app/src/main/java/com/frogobox/appkeyboard/common/base/BaseComposeActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/common/base/BaseComposeActivity.kt new file mode 100644 index 0000000..f93185e --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/common/base/BaseComposeActivity.kt @@ -0,0 +1,31 @@ +package com.frogobox.appkeyboard.common.base + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import com.frogobox.appkeyboard.BuildConfig +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.sdk.view.FrogoActivity + +/** + * BaseComposeActivity provides a clean, unified base for Jetpack Compose screens + * while maintaining compatibility with Frogo SDK's toolbar & debug setup. + */ +abstract class BaseComposeActivity : FrogoActivity() { + + override fun setupDebugMode(): Boolean { + return BuildConfig.DEBUG + } + + override fun onCreateExt(savedInstanceState: Bundle?) { + super.onCreateExt(savedInstanceState) + setContent { + FrogoKeyboardTheme { + Content() + } + } + } + + @Composable + abstract fun Content() +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/data/local/autotext/AutoTextDao.kt b/app/src/main/java/com/frogobox/appkeyboard/data/local/autotext/AutoTextDao.kt index 1fa19d7..af93df6 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/data/local/autotext/AutoTextDao.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/data/local/autotext/AutoTextDao.kt @@ -8,50 +8,50 @@ package com.frogobox.appkeyboard.data.local.autotext import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert +import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import com.frogobox.appkeyboard.model.AutoTextEntity import com.frogobox.appkeyboard.model.AutoTextLabelType -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.flow.Flow @Dao interface AutoTextDao { - @Insert - fun insert(autoText: AutoTextEntity) : Completable + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(autoText: AutoTextEntity) - @Insert - fun insert(autoTexts: List) : Completable + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(autoTexts: List) @Update - fun update(autoText: AutoTextEntity) : Completable + suspend fun update(autoText: AutoTextEntity) @Delete - fun delete(autoText: AutoTextEntity) : Completable + suspend fun delete(autoText: AutoTextEntity) - @Query("DELETE FROM auto_text WHERE id in (:idList)") - fun delete(idList: List): Completable + @Query("DELETE FROM auto_text WHERE id IN (:idList)") + suspend fun delete(idList: List) @Query("DELETE FROM auto_text") - fun nukeData(): Completable + suspend fun nukeData() @Query("SELECT * FROM auto_text ORDER BY id ASC") - fun getAll() : Single> + fun getAll(): Flow> @Query("SELECT * FROM auto_text WHERE id = :id") - fun getById(id: Int) : Single> + fun getById(id: Int): Flow @Query("SELECT * FROM auto_text WHERE title LIKE '%' || :search || '%' ORDER BY id ASC") - fun getByTitle(search: String): Single> + fun getByTitle(search: String): Flow> - @Query("SELECT * FROM auto_text WHERE label = :search ORDER BY id ASC") - fun getByLabel(search: AutoTextLabelType) : Single> + @Query("SELECT * FROM auto_text WHERE label = :label ORDER BY id ASC") + fun getByLabel(label: AutoTextLabelType): Flow> @Query("SELECT * FROM auto_text WHERE body LIKE '%' || :search || '%' ORDER BY id ASC") - fun getByBody(search: String) : Single> + fun getByBody(search: String): Flow> @Query("SELECT * FROM auto_text WHERE (title LIKE '%' || :search || '%' OR body LIKE '%' || :search || '%') ORDER BY id ASC") - fun getByTitleOrBody(search: String): Single> + fun getByTitleOrBody(search: String): Flow> } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/data/remote/ApiService.kt b/app/src/main/java/com/frogobox/appkeyboard/data/remote/ApiService.kt index 54b8348..5f92e83 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/data/remote/ApiService.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/data/remote/ApiService.kt @@ -7,7 +7,6 @@ import com.chuckerteam.chucker.api.RetentionManager import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit -import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit @@ -54,7 +53,6 @@ object ApiService { return Retrofit.Builder() .baseUrl(baseUrl) .addConverterFactory(GsonConverterFactory.create()) - .addCallAdapterFactory(RxJava3CallAdapterFactory.create()) .client(client) .build() .create(T::class.java) diff --git a/app/src/main/java/com/frogobox/appkeyboard/repository/autotext/AutoTextRepository.kt b/app/src/main/java/com/frogobox/appkeyboard/repository/autotext/AutoTextRepository.kt index 526afe4..7097785 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/repository/autotext/AutoTextRepository.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/repository/autotext/AutoTextRepository.kt @@ -4,7 +4,7 @@ import com.frogobox.appkeyboard.common.callback.DataResponseCallback import com.frogobox.appkeyboard.common.callback.StateResponseCallback import com.frogobox.appkeyboard.model.AutoTextEntity import com.frogobox.appkeyboard.model.AutoTextLabelType - +import kotlinx.coroutines.flow.Flow /** * Created by Faisal Amir on 25/11/22 @@ -13,6 +13,28 @@ import com.frogobox.appkeyboard.model.AutoTextLabelType interface AutoTextRepository { + fun getAutoText(): Flow> + + fun getAutoTextByLabel(label: AutoTextLabelType): Flow> + + fun getAutoTextByTitle(title: String): Flow> + + fun getAutoTextByBody(body: String): Flow> + + fun getAutoTextByTitleOrBody(keyword: String): Flow> + + suspend fun insertAutoText(autoText: AutoTextEntity) + + suspend fun insertAutoText(autoTexts: List) + + suspend fun updateAutoText(autoText: AutoTextEntity) + + suspend fun deleteAutoText(autoText: AutoTextEntity) + + suspend fun deleteAutoText(idList: List) + + suspend fun nukeAutoText() + fun getAutoText(callback: DataResponseCallback>) fun getAutoTextByLabel(label: AutoTextLabelType, callback: DataResponseCallback>) diff --git a/app/src/main/java/com/frogobox/appkeyboard/repository/autotext/AutoTextRepositoryImpl.kt b/app/src/main/java/com/frogobox/appkeyboard/repository/autotext/AutoTextRepositoryImpl.kt index 0c23b0a..5386eb8 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/repository/autotext/AutoTextRepositoryImpl.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/repository/autotext/AutoTextRepositoryImpl.kt @@ -5,10 +5,12 @@ import com.frogobox.appkeyboard.common.callback.StateResponseCallback import com.frogobox.appkeyboard.data.local.autotext.AutoTextDao import com.frogobox.appkeyboard.model.AutoTextEntity import com.frogobox.appkeyboard.model.AutoTextLabelType -import com.frogobox.sdk.ext.executeRoomDB -import com.frogobox.sdk.ext.fetchRoomDB -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.disposables.Disposable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject /** @@ -16,24 +18,84 @@ import javax.inject.Inject * https://github.com/amirisback */ - class AutoTextRepositoryImpl @Inject constructor( private val dao: AutoTextDao, ) : AutoTextRepository { - private val compositeDisposable: CompositeDisposable by lazy { CompositeDisposable() } + override fun getAutoText(): Flow> { + return dao.getAll().flowOn(Dispatchers.IO) + } + + override fun getAutoTextByLabel(label: AutoTextLabelType): Flow> { + return dao.getByLabel(label).flowOn(Dispatchers.IO) + } + + override fun getAutoTextByTitle(title: String): Flow> { + return dao.getByTitle(title).flowOn(Dispatchers.IO) + } + + override fun getAutoTextByBody(body: String): Flow> { + return dao.getByBody(body).flowOn(Dispatchers.IO) + } + + override fun getAutoTextByTitleOrBody(keyword: String): Flow> { + return dao.getByTitleOrBody(keyword).flowOn(Dispatchers.IO) + } + + override suspend fun insertAutoText(autoText: AutoTextEntity) { + withContext(Dispatchers.IO) { + dao.insert(autoText) + } + } + + override suspend fun insertAutoText(autoTexts: List) { + withContext(Dispatchers.IO) { + dao.insert(autoTexts) + } + } + + override suspend fun updateAutoText(autoText: AutoTextEntity) { + withContext(Dispatchers.IO) { + dao.update(autoText) + } + } - fun onClearDisposables() { - compositeDisposable.clear() + override suspend fun deleteAutoText(autoText: AutoTextEntity) { + withContext(Dispatchers.IO) { + dao.delete(autoText) + } } - fun addSubscribe(disposable: Disposable) { - compositeDisposable.add(disposable) + override suspend fun deleteAutoText(idList: List) { + withContext(Dispatchers.IO) { + dao.delete(idList) + } + } + + override suspend fun nukeAutoText() { + withContext(Dispatchers.IO) { + dao.nukeData() + } } override fun getAutoText(callback: DataResponseCallback>) { - dao.getAll().fetchRoomDB(callback) { - addSubscribe(it) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.getAll().collect { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess(it) + callback.onFinish() + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } } } @@ -41,8 +103,23 @@ class AutoTextRepositoryImpl @Inject constructor( label: AutoTextLabelType, callback: DataResponseCallback>, ) { - dao.getByLabel(label).fetchRoomDB(callback) { - addSubscribe(it) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.getByLabel(label).collect { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess(it) + callback.onFinish() + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } } } @@ -50,8 +127,23 @@ class AutoTextRepositoryImpl @Inject constructor( title: String, callback: DataResponseCallback>, ) { - dao.getByTitle(title).fetchRoomDB(callback) { - addSubscribe(it) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.getByTitle(title).collect { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess(it) + callback.onFinish() + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } } } @@ -59,8 +151,23 @@ class AutoTextRepositoryImpl @Inject constructor( body: String, callback: DataResponseCallback>, ) { - dao.getByBody(body).fetchRoomDB(callback) { - addSubscribe(it) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.getByBody(body).collect { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess(it) + callback.onFinish() + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } } } @@ -68,33 +175,144 @@ class AutoTextRepositoryImpl @Inject constructor( keyword: String, callback: DataResponseCallback>, ) { - dao.getByTitleOrBody(keyword).fetchRoomDB(callback) { - addSubscribe(it) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.getByTitleOrBody(keyword).collect { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess(it) + callback.onFinish() + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } } } override fun insertAutoText(autoText: AutoTextEntity, callback: StateResponseCallback) { - dao.insert(autoText).executeRoomDB(callback) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.insert(autoText) + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess() + callback.onFinish() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } + } } override fun insertAutoText(autoTexts: List, callback: StateResponseCallback) { - dao.insert(autoTexts).executeRoomDB(callback) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.insert(autoTexts) + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess() + callback.onFinish() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } + } } override fun updateAutoText(autoText: AutoTextEntity, callback: StateResponseCallback) { - dao.update(autoText).executeRoomDB(callback) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.update(autoText) + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess() + callback.onFinish() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } + } } override fun deleteAutoText(autoText: AutoTextEntity, callback: StateResponseCallback) { - dao.delete(autoText).executeRoomDB(callback) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.delete(autoText) + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess() + callback.onFinish() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } + } } override fun deleteAutoText(idList: List, callback: StateResponseCallback) { - dao.delete(idList).executeRoomDB(callback) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.delete(idList) + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess() + callback.onFinish() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } + } } override fun nukeAutoText(callback: StateResponseCallback) { - dao.nukeData().executeRoomDB(callback) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.nukeData() + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess() + callback.onFinish() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextViewModel.kt index 79692da..f53c14a 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextViewModel.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextViewModel.kt @@ -2,15 +2,17 @@ package com.frogobox.appkeyboard.ui.autotext import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope import com.frogobox.appkeyboard.common.base.BaseViewModel -import com.frogobox.appkeyboard.common.callback.DataResponseCallback -import com.frogobox.appkeyboard.common.callback.StateResponseCallback import com.frogobox.appkeyboard.common.ext.getTimeNow import com.frogobox.appkeyboard.model.AutoTextEntity import com.frogobox.appkeyboard.model.AutoTextLabelType import com.frogobox.appkeyboard.repository.autotext.AutoTextRepository import com.frogobox.coresdk.source.Resource import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -53,23 +55,18 @@ class AutoTextViewModel @Inject constructor( fun getAutoText() { - repository.getAutoText(object : DataResponseCallback> { - override fun onFinish() {} - override fun onHideProgress() {} - - override fun onFailed(statusCode: Int, errorMessage: String) { - _autoText.postValue(Resource.Error(statusCode, errorMessage)) - } - - override fun onShowProgress() { - _autoText.postValue(Resource.Loading()) - } - - override fun onSuccess(data: List) { - _autoText.postValue(Resource.Success(data)) - } - - }) + viewModelScope.launch { + repository.getAutoText() + .onStart { + _autoText.postValue(Resource.Loading()) + } + .catch { e -> + _autoText.postValue(Resource.Error(-1, e.localizedMessage ?: "Unknown Error")) + } + .collect { data -> + _autoText.postValue(Resource.Success(data)) + } + } } fun insertAutoText(title: String, body: String) { @@ -80,54 +77,35 @@ class AutoTextViewModel @Inject constructor( body = body, isActive = true ) - repository.insertAutoText(data, object : StateResponseCallback { - override fun onFailed(statusCode: Int, errorMessage: String) { - _eventFailed.postValue(errorMessage) - } - - override fun onFinish() { - _eventFinishState.postValue(true) - } - - override fun onHideProgress() { + viewModelScope.launch { + _eventShowProgressState.postValue(true) + try { + repository.insertAutoText(data) _eventShowProgressState.postValue(false) - - } - - override fun onShowProgress() { - _eventShowProgressState.postValue(true) - - } - - override fun onSuccess() { _eventSuccessState.postValue(true) + _eventFinishState.postValue(true) + } catch (e: Exception) { + _eventShowProgressState.postValue(false) + _eventFailed.postValue(e.localizedMessage ?: "Failed to insert AutoText") + _eventFinishState.postValue(true) } } - ) } fun deleteAutoText(data: AutoTextEntity) { - repository.deleteAutoText(data, object : StateResponseCallback { - override fun onFailed(statusCode: Int, errorMessage: String) { - _eventFailed.postValue(errorMessage) - } - - override fun onFinish() { - _eventFinishState.postValue(true) - } - - override fun onHideProgress() { + viewModelScope.launch { + _eventShowProgressState.postValue(true) + try { + repository.deleteAutoText(data) _eventShowProgressState.postValue(false) - } - - override fun onShowProgress() { - _eventShowProgressState.postValue(true) - } - - override fun onSuccess() { _eventSuccessState.postValue(true) + _eventFinishState.postValue(true) + } catch (e: Exception) { + _eventShowProgressState.postValue(false) + _eventFailed.postValue(e.localizedMessage ?: "Failed to delete AutoText") + _eventFinishState.postValue(true) } - }) + } } fun updateAutoText(id: Int, title: String, body: String) { @@ -139,27 +117,19 @@ class AutoTextViewModel @Inject constructor( body = body, isActive = true ) - repository.updateAutoText(data, object : StateResponseCallback { - override fun onFailed(statusCode: Int, errorMessage: String) { - _eventFailed.postValue(errorMessage) - } - - override fun onFinish() { - _eventFinishState.postValue(true) - } - - override fun onHideProgress() { + viewModelScope.launch { + _eventShowProgressState.postValue(true) + try { + repository.updateAutoText(data) _eventShowProgressState.postValue(false) - } - - override fun onShowProgress() { - _eventShowProgressState.postValue(true) - } - - override fun onSuccess() { _eventSuccessState.postValue(true) + _eventFinishState.postValue(true) + } catch (e: Exception) { + _eventShowProgressState.postValue(false) + _eventFailed.postValue(e.localizedMessage ?: "Failed to update AutoText") + _eventFinishState.postValue(true) } - }) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardViewModel.kt index 1c001b3..c9c6758 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardViewModel.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardViewModel.kt @@ -1,34 +1,42 @@ package com.frogobox.appkeyboard.ui.keyboard.autotext import android.content.Context -import com.frogobox.appkeyboard.common.callback.DataResponseCallback import com.frogobox.appkeyboard.data.local.db.AppDatabase import com.frogobox.appkeyboard.model.AutoTextEntity import com.frogobox.appkeyboard.repository.autotext.AutoTextRepository import com.frogobox.appkeyboard.repository.autotext.AutoTextRepositoryImpl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Created by Faisal Amir on 11/03/23 * https://github.com/amirisback */ - class AutoTextKeyboardViewModel(val context: Context) { - private fun getRepository() : AutoTextRepository { + private fun getRepository(): AutoTextRepository { return AutoTextRepositoryImpl(AppDatabase.newInstance(context).autoTextDao()) } + fun getAutoTextFlow(): Flow> { + return getRepository().getAutoText() + } + fun getAutoText(onSuccessData: (List) -> Unit) { - getRepository().getAutoText(object : DataResponseCallback> { - override fun onFailed(statusCode: Int, errorMessage: String) {} - override fun onFinish() {} - override fun onHideProgress() {} - override fun onShowProgress() {} - override fun onSuccess(data: List) { - onSuccessData(data) + CoroutineScope(Dispatchers.IO).launch { + try { + getRepository().getAutoText().collect { data -> + withContext(Dispatchers.Main) { + onSuccessData(data) + } + } + } catch (_: Exception) { } - }) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/form/FormKeyboard.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/form/FormKeyboard.kt index f3fb5b2..3a253e8 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/form/FormKeyboard.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/form/FormKeyboard.kt @@ -5,7 +5,6 @@ import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout import com.frogobox.appkeyboard.databinding.KeyboardFormBinding -import com.frogobox.appkeyboard.ui.keyboard.autotext.AutoTextKeyboardViewModel import com.frogobox.appkeyboard.ui.keyboard.movie.MovieKeyboard import com.frogobox.appkeyboard.util.KeyboardNavigationHelper import com.frogobox.libkeyboard.common.core.BaseKeyboard diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/movie/MovieKeyboard.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/movie/MovieKeyboard.kt index 5844d78..6f3a610 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/movie/MovieKeyboard.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/movie/MovieKeyboard.kt @@ -87,7 +87,7 @@ class MovieKeyboard( val output = "Movie: ${data.title} - ${data.release_date} \n " + "Overview: ${data.overview} \n " + "Vote: ${data.vote_average} \n " + - "${MovieUrl.BASE_URL_IMAGE_ORIGNAL}${data.poster_path}" + + "${MovieUrl.BASE_URL_IMAGE_ORIGINAL}${data.poster_path}" + "\n" + "Terima Kasih" currentInputConnection?.commitText(output, 1) @@ -131,7 +131,7 @@ class MovieKeyboard( frogoRvGridType6TvTitle.text = data.title frogoRvGridType6TvSubtitle.text = data.release_date frogoRvGridType6TvDesc.text = data.overview - Glide.with(context).load("${MovieUrl.BASE_URL_IMAGE_ORIGNAL}${data.poster_path}").into(frogoRvGridType6CivPoster) + Glide.with(context).load("${MovieUrl.BASE_URL_IMAGE_ORIGINAL}${data.poster_path}").into(frogoRvGridType6CivPoster) } } } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestActivity.kt index fc3c713..80b48cb 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestActivity.kt @@ -2,15 +2,13 @@ package com.frogobox.appkeyboard.ui.test import android.os.Bundle import android.util.Log -import android.widget.ArrayAdapter import androidx.activity.viewModels -import com.frogobox.appkeyboard.R -import com.frogobox.appkeyboard.common.base.BaseActivity -import com.frogobox.appkeyboard.databinding.ActivityTestBinding +import androidx.compose.runtime.Composable +import com.frogobox.appkeyboard.common.base.BaseComposeActivity import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint -class TestActivity : BaseActivity() { +class TestActivity : BaseComposeActivity() { companion object { private val TAG: String = TestActivity::class.java.simpleName @@ -18,16 +16,16 @@ class TestActivity : BaseActivity() { private val viewModel: TestViewModel by viewModels() - override fun setupViewBinding(): ActivityTestBinding { - return ActivityTestBinding.inflate(layoutInflater) - } - - override fun setupViewModel() { - super.setupViewModel() - viewModel.apply { - - } - } + private val dummyOptions = listOf( + "Kuningan", + "Menteng", + "Menten213g", + "Men123teng", + "Mw23423", + "Me123nteng", + "Mente234234234ng", + "Pegangsaan" + ) override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) @@ -36,25 +34,13 @@ class TestActivity : BaseActivity() { // Call View Model Here Log.d(TAG, "View Model : ${viewModel::class.java.simpleName}") } - // TODO : Add your code here - } - override fun initView() { - super.initView() - binding.apply { - val dummy = listOf("Kuningan", - "Menteng", - "Menten213g", - "Men123teng", - "Mw23423", - "Me123nteng", - "Mente234234234ng", - "Pegangsaan" - ) - val adapterS = ArrayAdapter(this@TestActivity, R.layout.item_spinner, R.id.tv_text, dummy) - etTextAuto.setAdapter(adapterS) - } + @Composable + override fun Content() { + TestScreen( + dummyOptions = dummyOptions + ) } } \ No newline at end of file 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 new file mode 100644 index 0000000..7f9451c --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestScreen.kt @@ -0,0 +1,177 @@ +package com.frogobox.appkeyboard.ui.test + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.OutlinedTextField +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.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme + +/** + * TestScreen Composable + * Migrated from activity_test.xml + * Provides testing input fields (multiline text, numeric input, and auto-complete dropdown). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TestScreen( + dummyOptions: List = emptyList(), + modifier: Modifier = Modifier +) { + var fullText by remember { mutableStateOf("") } + var numberText by remember { mutableStateOf("") } + var autoText by remember { mutableStateOf("") } + var dropdownExpanded by remember { mutableStateOf(false) } + + val filteredOptions = remember(autoText, dummyOptions) { + if (autoText.isBlank()) { + dummyOptions + } else { + dummyOptions.filter { it.contains(autoText, ignoreCase = true) } + } + } + + val scrollState = rememberScrollState() + + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .verticalScroll(scrollState) + .padding(start = 30.dp, end = 30.dp, top = 16.dp, bottom = 16.dp) + ) { + Text( + text = "Let's Do Some Test", + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + fontSize = 20.sp + ), + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(16.dp)) + + HorizontalDivider( + thickness = 2.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Full Text (Multiline) + OutlinedTextField( + value = fullText, + onValueChange = { fullText = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Full Text") }, + placeholder = { Text("Full Text") }, + minLines = 3, + shape = RoundedCornerShape(16.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Number Input + OutlinedTextField( + value = numberText, + onValueChange = { numberText = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Number") }, + placeholder = { Text("Number") }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // AutoComplete Dropdown + ExposedDropdownMenuBox( + expanded = dropdownExpanded, + onExpandedChange = { dropdownExpanded = it }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = autoText, + onValueChange = { + autoText = it + dropdownExpanded = true + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(type = ExposedDropdownMenuAnchorType.PrimaryEditable, enabled = true), + label = { Text("Full Text Auto") }, + placeholder = { Text("Start typing city...") }, + shape = RoundedCornerShape(16.dp), + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded) + }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors() + ) + + if (filteredOptions.isNotEmpty()) { + ExposedDropdownMenu( + expanded = dropdownExpanded, + onDismissRequest = { dropdownExpanded = false } + ) { + filteredOptions.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + autoText = option + dropdownExpanded = false + } + ) + } + } + } + } + } +} + +@Preview(showBackground = true, name = "TestScreen Light") +@Composable +fun TestScreenPreview() { + FrogoKeyboardTheme(darkTheme = false) { + TestScreen( + dummyOptions = listOf("Kuningan", "Menteng", "Pegangsaan") + ) + } +} + +@Preview(showBackground = true, name = "TestScreen Dark") +@Composable +fun TestScreenDarkPreview() { + FrogoKeyboardTheme(darkTheme = true) { + TestScreen( + dummyOptions = listOf("Kuningan", "Menteng", "Pegangsaan") + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Color.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Color.kt new file mode 100644 index 0000000..c50fb02 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Color.kt @@ -0,0 +1,27 @@ +package com.frogobox.appkeyboard.ui.theme.compose + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) + +// Frogo Keyboard Specific Colors +val FrogoPrimary = Color(0xFF6200EE) +val FrogoPrimaryVariant = Color(0xFF3700B3) +val FrogoSecondary = Color(0xFF03DAC5) +val FrogoBackground = Color(0xFFFCFCFF) +val FrogoText = Color(0xFF000000) +val FrogoTextDark = Color(0xFFFFFFFF) +val FrogoGrey = Color(0xFF424242) +val FrogoDivider = Color(0xFFE0E0E0) +val FrogoBorder = Color(0xFFBDBDBD) + +// Status Colors +val FrogoStatusSuccess = Color(0xFF41AF60) +val FrogoStatusWarning = Color(0xFFF57C00) +val FrogoStatusFailed = Color(0xFFC53741) diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Theme.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Theme.kt new file mode 100644 index 0000000..805fb5b --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Theme.kt @@ -0,0 +1,48 @@ +package com.frogobox.appkeyboard.ui.theme.compose + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, + background = Color(0xFF1C1B1F), + surface = Color(0xFF1C1B1F), + onPrimary = Color(0xFF381E72), + onSecondary = Color(0xFF332D41), + onTertiary = Color(0xFF492532), + onBackground = Color(0xFFE6E1E5), + onSurface = Color(0xFFE6E1E5), +) + +private val LightColorScheme = lightColorScheme( + primary = FrogoPrimary, + secondary = FrogoSecondary, + tertiary = FrogoPrimaryVariant, + background = FrogoBackground, + surface = Color.White, + onPrimary = Color.White, + onSecondary = Color.Black, + onTertiary = Color.White, + onBackground = FrogoText, + onSurface = FrogoText, +) + +@Composable +fun FrogoKeyboardTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Type.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Type.kt new file mode 100644 index 0000000..1be1f70 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Type.kt @@ -0,0 +1,45 @@ +package com.frogobox.appkeyboard.ui.theme.compose + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ) +) diff --git a/app/src/main/res/layout/activity_test.xml b/app/src/main/res/layout/activity_test.xml deleted file mode 100644 index 24deb49..0000000 --- a/app/src/main/res/layout/activity_test.xml +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 47d56ba..2d79dba 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,12 +2,13 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false - alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.jetbrains.kotlin.jvm) apply false + alias(libs.plugins.jetbrains.kotlin.compose) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false } tasks.register("clean", Delete::class) { + description = "" delete(rootProject.layout.buildDirectory) } \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/ProjectSetting.kt b/buildSrc/src/main/kotlin/ProjectSetting.kt index 337ef85..5e783c2 100644 --- a/buildSrc/src/main/kotlin/ProjectSetting.kt +++ b/buildSrc/src/main/kotlin/ProjectSetting.kt @@ -27,9 +27,9 @@ object ProjectSetting { // --------------------------------------------------------------------------------------------- - const val PROJECT_MIN_SDK = 24 - const val PROJECT_COMPILE_SDK = 36 - const val PROJECT_TARGET_SDK = PROJECT_COMPILE_SDK + const val PROJECT_MIN_SDK = 25 + const val PROJECT_COMPILE_SDK = 37 + const val PROJECT_TARGET_SDK = 36 // --------------------------------------------------------------------------------------------- diff --git a/frogo-keyboard/build.gradle.kts b/frogo-keyboard/build.gradle.kts index 28265bd..c8439dd 100644 --- a/frogo-keyboard/build.gradle.kts +++ b/frogo-keyboard/build.gradle.kts @@ -2,14 +2,13 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.library) - alias(libs.plugins.jetbrains.kotlin.android) `maven-publish` } android { namespace = ProjectSetting.PROJECT_NAME_SPACE_LIB_FROGO_KEYBOARD - compileSdk = ProjectSetting.PROJECT_TARGET_SDK + compileSdk = ProjectSetting.PROJECT_COMPILE_SDK defaultConfig { @@ -40,6 +39,13 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + } + } kotlin { diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt index 47d49fd..d00900e 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt @@ -3,12 +3,10 @@ package com.frogobox.libkeyboard.common.ext import android.content.Context import android.content.res.Configuration import android.content.res.Configuration.UI_MODE_NIGHT_YES -import android.graphics.BlendMode -import android.graphics.BlendModeColorFilter -import android.graphics.PorterDuff import android.graphics.drawable.Drawable -import android.os.Build import android.widget.ImageView +import androidx.core.graphics.BlendModeColorFilterCompat +import androidx.core.graphics.BlendModeCompat /** * Created by Faisal Amir on 24/10/22 @@ -26,19 +24,9 @@ fun Context.isDarkThemeOn(): Boolean { fun Drawable.applyColorFilter(color: Int) { mutate() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - colorFilter = BlendModeColorFilter(color, BlendMode.SRC_IN) - } else { - @Suppress("DEPRECATION") - setColorFilter(color, PorterDuff.Mode.SRC_IN) - } + colorFilter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN) } fun ImageView.applyColorFilter(color: Int) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - colorFilter = BlendModeColorFilter(color, BlendMode.SRC_IN) - } else { - @Suppress("DEPRECATION") - setColorFilter(color, PorterDuff.Mode.SRC_IN) - } + colorFilter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN) } \ No newline at end of file diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt index f4bffa1..013ef81 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt @@ -695,7 +695,9 @@ class MainKeyboard @JvmOverloads constructor( private fun sendAccessibilityEventForUnicodeCharacter(eventType: Int, code: Int) { if (mAccessibilityManager.isEnabled) { - val event = AccessibilityEvent.obtain(eventType) + val event = AccessibilityEvent().apply { + this.eventType = eventType + } onInitializeAccessibilityEvent(event) val text: String = when (code) { KEYCODE_DELETE -> context.getString(R.string.keycode_delete) diff --git a/gradle.properties b/gradle.properties index b5b0a35..c2cb610 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,19 +6,22 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx4608m -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -org.gradle.daemon=false -org.gradle.parallel=false +org.gradle.jvmargs=-Xmx4608m -XX:+UseParallelGC -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 +# Gradle Performance Flags +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.caching=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true +# Automatically convert third-party libraries to use AndroidX (disabled because all libs are modern AndroidX) +android.enableJetifier=false # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -android.nonTransitiveRClass=false -android.nonFinalResIds=false +android.nonTransitiveRClass=true +ksp.incremental=true +kotlin.incremental=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 224b4be..2ce52ef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,24 +1,38 @@ [versions] -agp = "8.13.0" -kotlin = "2.2.21" -ksp = "2.2.21-2.0.4" -liveCycle = "2.9.4" +agp = "9.4.1" +kotlin = "2.4.20" +ksp = "2.3.10" +liveCycle = "2.11.0" -roomKtx = "2.8.3" -work = "2.11.0" -glideCompiler = "5.0.5" +roomKtx = "2.8.5" +work = "2.11.2" +glideCompiler = "5.0.9" -hilt = "2.57.2" +hilt = "2.60.1" emoji2 = "1.6.0" -frogoAndroid = "2.3.6" -frogoconsumeapi = "2.6.1" +frogoAndroid = "3.0.10" +frogoconsumeapi = "3.0.0" + +composeBom = "2026.09.00" +activityCompose = "1.13.0" [libraries] 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-emoji2 = { group = "androidx.emoji2", name = "emoji2-bundled", version.ref = "emoji2"} +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "roomKtx" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "roomKtx" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomKtx" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "liveCycle" } + google-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } google-hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } @@ -31,7 +45,7 @@ frogo-consume-api = { group = "com.github.frogobox", name = "frogo-consume-api", [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp"} -jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin"} +jetbrains-kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp"} -hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 46fd8dc..f8db6e2 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.0.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 91f0288fefe6cbd4fe04fb740fa910c966cd90b4 Mon Sep 17 00:00:00 2001 From: amirisback Date: Sat, 19 Sep 2026 18:10:51 +0700 Subject: [PATCH 5/7] refactor: migrate to coroutines and Flow in AutoText repository and ViewModel, remove RxJava dependencies --- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 2 +- .../common/base/BaseComposeMainActivity.kt | 31 + .../appkeyboard/services/KeyboardIME.kt | 14 +- .../ui/autotext/AutoTextActivity.kt | 161 ++--- .../ui/autotext/AutoTextDetailActivity.kt | 92 ++- .../ui/autotext/AutoTextDetailScreen.kt | 212 +++++++ .../ui/autotext/AutoTextEditorActivity.kt | 103 ++-- .../ui/autotext/AutoTextEditorScreen.kt | 155 +++++ .../appkeyboard/ui/autotext/AutoTextScreen.kt | 181 ++++++ .../ui/language/KeyboardLanguageActivity.kt | 143 +---- .../ui/language/KeyboardLanguageScreen.kt | 184 ++++++ .../appkeyboard/ui/main/MainActivity.kt | 129 ++-- .../appkeyboard/ui/main/MainScreen.kt | 463 +++++++++++++++ .../appkeyboard/ui/test/TestActivity.kt | 4 +- .../appkeyboard/ui/test/TestScreen.kt | 28 +- .../appkeyboard/ui/theme/ThemeActivity.kt | 160 +---- .../appkeyboard/ui/theme/ThemeScreen.kt | 272 +++++++++ .../appkeyboard/ui/theme/ThemeViewModel.kt | 11 +- .../ui/theme/compose/FrogoEmptyView.kt | 67 +++ .../ui/theme/compose/FrogoTopAppBar.kt | 56 ++ .../appkeyboard/ui/toggle/ToggleActivity.kt | 114 +--- .../appkeyboard/ui/toggle/ToggleScreen.kt | 550 ++++++++++++++++++ .../main/res/layout/item_keyboard_header.xml | 3 +- .../com/frogobox/appkeyboard/ThemeTest.kt | 87 +++ gradle/libs.versions.toml | 3 + 26 files changed, 2558 insertions(+), 669 deletions(-) create mode 100644 app/src/main/java/com/frogobox/appkeyboard/common/base/BaseComposeMainActivity.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailScreen.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorScreen.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextScreen.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageScreen.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/main/MainScreen.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeScreen.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/FrogoEmptyView.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/FrogoTopAppBar.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt create mode 100644 app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 590a675..89e6f12 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -123,6 +123,7 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) @@ -139,5 +140,6 @@ dependencies { ksp(libs.google.hilt.compiler) ksp(libs.androidx.room.compiler) ksp(libs.androidx.lifecycle.compiler) + testImplementation(libs.junit) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 625d548..397c09e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,7 +14,7 @@ android:label="@string/app_name" android:roundIcon="@drawable/ic_frogobox" android:supportsRtl="true" - android:theme="@style/Theme.Research" + android:theme="@style/Theme.ResearchNoActionBar" tools:targetApi="tiramisu"> () { R.color.color_bg_keyboard_default ) - val backgroundType = ThemeType.valueOf( - pref.getPrefString( - KeyboardUtil.KEYBOARD_COLOR_TYPE, - ThemeType.COLOR.name - ) + val typeString = pref.getPrefString( + KeyboardUtil.KEYBOARD_COLOR_TYPE, + ThemeType.COLOR.name ) + val backgroundType = runCatching { + ThemeType.valueOf(typeString) + }.getOrDefault(ThemeType.COLOR) + when (backgroundType) { ThemeType.COLOR -> { + ivBackgroundKeyboard.setImageDrawable(null) ivBackgroundKeyboard.setBackgroundColor(getColorExt(background)) } ThemeType.IMAGE -> { + ivBackgroundKeyboard.setBackgroundColor(android.graphics.Color.TRANSPARENT) ivBackgroundKeyboard.setImageResource(background) } } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextActivity.kt index 11d60cd..12712f6 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextActivity.kt @@ -2,141 +2,76 @@ package com.frogobox.appkeyboard.ui.autotext import android.content.Intent import android.os.Bundle -import android.view.ViewGroup -import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import com.frogobox.appkeyboard.common.base.BaseActivity -import com.frogobox.appkeyboard.databinding.ActivityAutotextBinding -import com.frogobox.appkeyboard.databinding.ItemAutotextBinding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.frogobox.appkeyboard.common.base.BaseComposeActivity import com.frogobox.appkeyboard.model.AutoTextEntity import com.frogobox.coresdk.source.Resource -import com.frogobox.recycler.core.FrogoRecyclerNotifyListener -import com.frogobox.recycler.core.IFrogoBindingAdapter -import com.frogobox.recycler.ext.injectorBinding -import com.frogobox.sdk.ext.gone -import com.frogobox.sdk.ext.showLogD -import com.frogobox.sdk.ext.visible import com.google.gson.Gson import dagger.hilt.android.AndroidEntryPoint -/** - * Created by Faisal Amir on 11/03/23 - * https://github.com/amirisback - */ - - @AndroidEntryPoint -class AutoTextActivity : BaseActivity() { +class AutoTextActivity : BaseComposeActivity() { private val viewModel: AutoTextViewModel by viewModels() - override fun setupViewBinding(): ActivityAutotextBinding { - return ActivityAutotextBinding.inflate(layoutInflater) - } - - override fun setupViewModel() { - viewModel.apply { - autoText.observe(this@AutoTextActivity) { - - showLogD("${it}", "AutoText") - - when (it) { - is Resource.Error -> {} - is Resource.Loading -> {} - is Resource.Success -> { - if (it.result.isEmpty()) { - binding.emptyView.root.visible() - binding.rvAutotext.gone() - } else { - binding.emptyView.root.gone() - binding.rvAutotext.visible() - setupRvAutoText(it.result) - } - } - } - } - } - } - - override fun onCreateExt(savedInstanceState: Bundle?) { - super.onCreateExt(savedInstanceState) - setupDetailActivity("Auto Text") - setupUI() - viewModel.getAutoText() - } - - override fun setupActivityResultExt(result: ActivityResult) { - super.setupActivityResultExt(result) + private var autoTextList by mutableStateOf>(emptyList()) + private var isLoading by mutableStateOf(false) + private val activityResultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> when (result.resultCode) { - AutoTextDetailActivity.RESULT_CODE_DELETE, AutoTextEditorActivity.RESULT_CODE_UPDATE, - AutoTextEditorActivity.RESULT_CODE_ADD, - -> { + AutoTextEditorActivity.RESULT_CODE_ADD -> { viewModel.getAutoText() } - } } - private fun setupUI() { - binding.apply { - btnAdd.setOnClickListener { - startActivityResultExt(Intent(this@AutoTextActivity, AutoTextEditorActivity::class.java)) - } - } - } - - private fun setupRvAutoText(data: List) { - binding.rvAutotext.injectorBinding() - .addData(data) - .addCallback(object : IFrogoBindingAdapter { - override fun areContentsTheSame( - oldItem: AutoTextEntity, - newItem: AutoTextEntity, - ): Boolean { - return oldItem == newItem - } - - override fun areItemsTheSame( - oldItem: AutoTextEntity, - newItem: AutoTextEntity, - ): Boolean { - return oldItem.id == newItem.id - } - - override fun setViewBinding(parent: ViewGroup): ItemAutotextBinding { - return ItemAutotextBinding.inflate(layoutInflater, parent, false) + override fun onCreateExt(savedInstanceState: Bundle?) { + super.onCreateExt(savedInstanceState) + viewModel.autoText.observe(this) { resource -> + when (resource) { + is Resource.Loading -> { + isLoading = true } - - override fun setupInitComponent( - binding: ItemAutotextBinding, - data: AutoTextEntity, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - binding.tvAutotextTitle.text = data.title - binding.tvAutotextContent.text = data.body + is Resource.Success -> { + isLoading = false + autoTextList = resource.result } - - override fun onItemClicked( - binding: ItemAutotextBinding, - data: AutoTextEntity, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - val extra = Gson().toJson(data) - startActivityResultExt( - Intent(this@AutoTextActivity, AutoTextDetailActivity::class.java).apply { - putExtra(AutoTextDetailActivity.EXTRA_AUTO_TEXT, extra) - } - ) + is Resource.Error -> { + isLoading = false } - - }) - .createLayoutLinearVertical(false) - .build() + } + } + viewModel.getAutoText() } + @Composable + override fun Content() { + AutoTextScreen( + autoTextList = autoTextList, + isLoading = isLoading, + onItemClick = { data -> + val extra = Gson().toJson(data) + activityResultLauncher.launch( + Intent(this@AutoTextActivity, AutoTextDetailActivity::class.java).apply { + putExtra(AutoTextDetailActivity.EXTRA_AUTO_TEXT, extra) + } + ) + }, + onAddClick = { + activityResultLauncher.launch( + Intent(this@AutoTextActivity, AutoTextEditorActivity::class.java) + ) + }, + onBackClick = { finish() } + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailActivity.kt index 8e75ac2..c3537da 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailActivity.kt @@ -2,22 +2,19 @@ package com.frogobox.appkeyboard.ui.autotext import android.content.Intent import android.os.Bundle -import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import com.frogobox.appkeyboard.common.base.BaseActivity -import com.frogobox.appkeyboard.databinding.ActivityAutotextDetailBinding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.frogobox.appkeyboard.common.base.BaseComposeActivity import com.frogobox.appkeyboard.model.AutoTextEntity import com.google.gson.Gson import dagger.hilt.android.AndroidEntryPoint -/** - * Created by Faisal Amir on 11/03/23 - * https://github.com/amirisback - */ - - @AndroidEntryPoint -class AutoTextDetailActivity : BaseActivity() { +class AutoTextDetailActivity : BaseComposeActivity() { companion object { const val EXTRA_AUTO_TEXT = "EXTRA_AUTO_TEXT" @@ -26,33 +23,29 @@ class AutoTextDetailActivity : BaseActivity() { private val viewModel: AutoTextViewModel by viewModels() - override fun setupViewModel() { - super.setupViewModel() - viewModel.eventSuccessState.observe(this) { - setResult(RESULT_CODE_DELETE) - finish() - } - } + private var currentData by mutableStateOf(AutoTextEntity()) - override fun setupViewBinding(): ActivityAutotextDetailBinding { - return ActivityAutotextDetailBinding.inflate(layoutInflater) + private val editLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == AutoTextEditorActivity.RESULT_CODE_UPDATE) { + val json = result.data?.getStringExtra(AutoTextEditorActivity.EXTRA_AUTO_TEXT_EDIT_RESULT) + if (!json.isNullOrBlank()) { + val updatedData = Gson().fromJson(json, AutoTextEntity::class.java) + if (updatedData != null) { + currentData = updatedData + } + } + } } override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) - setupDetailActivity("Detail Auto Text") - setupUI(autoText()) - } + currentData = extractAutoText() - override fun setupActivityResultExt(result: ActivityResult) { - super.setupActivityResultExt(result) - if (result.resultCode == AutoTextEditorActivity.RESULT_CODE_UPDATE) { - result.data?.getStringExtra(AutoTextEditorActivity.EXTRA_AUTO_TEXT_EDIT_RESULT) - val data = Gson().fromJson( - result.data?.getStringExtra(AutoTextEditorActivity.EXTRA_AUTO_TEXT_EDIT_RESULT), - AutoTextEntity::class.java - ) - setupUI(data) + viewModel.eventSuccessState.observe(this) { + setResult(RESULT_CODE_DELETE) + finish() } } @@ -61,8 +54,7 @@ class AutoTextDetailActivity : BaseActivity() { super.doOnBackPressedExt() } - - private fun autoText(): AutoTextEntity { + private fun extractAutoText(): AutoTextEntity { return if (intent.hasExtra(EXTRA_AUTO_TEXT)) { Gson().fromJson( intent.extras?.getString(EXTRA_AUTO_TEXT), @@ -73,30 +65,26 @@ class AutoTextDetailActivity : BaseActivity() { } } - private fun setupUI(data: AutoTextEntity) { - binding.apply { - - tvTitle.text = data.title - tvContent.text = data.body - - btnDelete.setOnClickListener { - viewModel.deleteAutoText(data) - } - - btnEdit.setOnClickListener { - startActivityResultExt( - Intent( - this@AutoTextDetailActivity, - AutoTextEditorActivity::class.java - ).apply { + @Composable + override fun Content() { + AutoTextDetailScreen( + data = currentData, + onEditClick = { + editLauncher.launch( + Intent(this@AutoTextDetailActivity, AutoTextEditorActivity::class.java).apply { putExtra( AutoTextEditorActivity.EXTRA_AUTO_TEXT_EDIT, - Gson().toJson(data) + Gson().toJson(currentData) ) } ) + }, + onDeleteClick = { + viewModel.deleteAutoText(currentData) + }, + onBackClick = { + doOnBackPressedExt() } - } + ) } - } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailScreen.kt new file mode 100644 index 0000000..8795c05 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailScreen.kt @@ -0,0 +1,212 @@ +package com.frogobox.appkeyboard.ui.autotext + +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.model.AutoTextEntity +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusFailed +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar + +@Composable +fun AutoTextDetailScreen( + data: AutoTextEntity, + onEditClick: () -> Unit, + onDeleteClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + var showDeleteDialog by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Detail Auto Text", + onBackClick = onBackClick + ) + }, + bottomBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Edit Button + Button( + onClick = onEditClick, + modifier = Modifier + .weight(1f) + .height(52.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = FrogoPrimary + ) + ) { + Text( + text = "Edit", + style = MaterialTheme.typography.labelLarge.copy( + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ), + color = Color.White + ) + } + + // Delete Button + OutlinedButton( + onClick = { showDeleteDialog = true }, + modifier = Modifier + .weight(1f) + .height(52.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = FrogoStatusFailed + ) + ) { + Text( + text = "Delete", + style = MaterialTheme.typography.labelLarge.copy( + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ), + color = FrogoStatusFailed + ) + } + } + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + Text( + text = data.title, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + fontSize = 20.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = data.body, + style = MaterialTheme.typography.bodyLarge.copy( + lineHeight = 24.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { + Text( + text = "Delete Auto Text", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold + ) + ) + }, + text = { + Text("Are you sure you want to delete this auto text item?") + }, + confirmButton = { + TextButton( + onClick = { + showDeleteDialog = false + onDeleteClick() + } + ) { + Text( + text = "Delete", + color = FrogoStatusFailed, + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + TextButton( + onClick = { showDeleteDialog = false } + ) { + Text("Cancel") + } + }, + shape = RoundedCornerShape(16.dp) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun AutoTextDetailScreenPreview() { + FrogoKeyboardTheme(darkTheme = false) { + AutoTextDetailScreen( + data = AutoTextEntity( + id = 1, + title = "Greeting Message", + body = "Hello! Thanks for reaching out. How can I assist you today?" + ), + onEditClick = {}, + onDeleteClick = {}, + onBackClick = {} + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorActivity.kt index 3ba3df0..5728ddd 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorActivity.kt @@ -3,21 +3,14 @@ package com.frogobox.appkeyboard.ui.autotext import android.content.Intent import android.os.Bundle import androidx.activity.viewModels -import com.frogobox.appkeyboard.common.base.BaseActivity -import com.frogobox.appkeyboard.databinding.ActivityAutotextEditorBinding +import androidx.compose.runtime.Composable +import com.frogobox.appkeyboard.common.base.BaseComposeActivity import com.frogobox.appkeyboard.model.AutoTextEntity -import com.frogobox.sdk.ext.toText import com.google.gson.Gson import dagger.hilt.android.AndroidEntryPoint -/** - * Created by Faisal Amir on 11/03/23 - * https://github.com/amirisback - */ - - @AndroidEntryPoint -class AutoTextEditorActivity : BaseActivity() { +class AutoTextEditorActivity : BaseComposeActivity() { companion object { const val EXTRA_AUTO_TEXT_EDIT = "EXTRA_AUTO_TEXT_EDIT" @@ -28,77 +21,59 @@ class AutoTextEditorActivity : BaseActivity() { private val viewModel: AutoTextViewModel by viewModels() - override fun setupViewBinding(): ActivityAutotextEditorBinding { - return ActivityAutotextEditorBinding.inflate(layoutInflater) - } - - override fun setupViewModel() { - super.setupViewModel() - viewModel.apply { - eventSuccessState.observe(this@AutoTextEditorActivity) { - if (hasExtraDetail()) { - setResult(RESULT_CODE_UPDATE, Intent().apply { - val extra = AutoTextEntity( - id = autoText().id, - title = binding.etTitle.toText(), - body = binding.etContent.toText() - ) - putExtra(EXTRA_AUTO_TEXT_EDIT_RESULT, Gson().toJson(extra)) - }) - } else { - setResult(RESULT_CODE_ADD) - } - finish() - } - } - } + private var lastSavedTitle: String = "" + private var lastSavedBody: String = "" override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) - setupDetailActivity("Editor Auto Text") - setupUI() - } - - private fun setupUI() { - binding.apply { + viewModel.eventSuccessState.observe(this) { if (hasExtraDetail()) { - btnSave.text = "Update" - etTitle.setText(autoText().title) - etContent.setText(autoText().body) - } - - btnSave.setOnClickListener { - if (hasExtraDetail()) { - setupUpdate() - } else { - setupInsert() - } + setResult(RESULT_CODE_UPDATE, Intent().apply { + val extra = AutoTextEntity( + id = autoText().id, + title = lastSavedTitle, + body = lastSavedBody + ) + putExtra(EXTRA_AUTO_TEXT_EDIT_RESULT, Gson().toJson(extra)) + }) + } else { + setResult(RESULT_CODE_ADD) } + finish() } } - private fun autoText() : AutoTextEntity { + private fun autoText(): AutoTextEntity { return Gson().fromJson( intent.extras?.getString(EXTRA_AUTO_TEXT_EDIT), AutoTextEntity::class.java ) ?: AutoTextEntity() } - private fun hasExtraDetail() : Boolean { + private fun hasExtraDetail(): Boolean { return intent.hasExtra(EXTRA_AUTO_TEXT_EDIT) } - private fun setupInsert() { - val title = binding.etTitle.text.toString() - val body = binding.etContent.text.toString() - viewModel.insertAutoText(title, body) - } - - private fun setupUpdate() { - val title = binding.etTitle.toText() - val body = binding.etContent.toText() - viewModel.updateAutoText(autoText().id, title, body) + @Composable + override fun Content() { + val detail = autoText() + val isEdit = hasExtraDetail() + + AutoTextEditorScreen( + initialTitle = if (isEdit) detail.title else "", + initialBody = if (isEdit) detail.body else "", + isEditMode = isEdit, + onSave = { title, body -> + lastSavedTitle = title + lastSavedBody = body + if (isEdit) { + viewModel.updateAutoText(detail.id, title, body) + } else { + viewModel.insertAutoText(title, body) + } + }, + onBackClick = { finish() } + ) } - } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorScreen.kt new file mode 100644 index 0000000..cf403ab --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorScreen.kt @@ -0,0 +1,155 @@ +package com.frogobox.appkeyboard.ui.autotext + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar + +@Composable +fun AutoTextEditorScreen( + initialTitle: String, + initialBody: String, + isEditMode: Boolean, + onSave: (title: String, body: String) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + var title by remember { mutableStateOf(initialTitle) } + var body by remember { mutableStateOf(initialBody) } + val isFormValid = title.isNotBlank() && body.isNotBlank() + val scrollState = rememberScrollState() + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Editor Auto Text", + onBackClick = onBackClick + ) + }, + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Button( + onClick = { + if (isFormValid) { + onSave(title.trim(), body.trim()) + } + }, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(16.dp), + enabled = isFormValid, + colors = ButtonDefaults.buttonColors( + containerColor = FrogoPrimary + ) + ) { + Text( + text = if (isEditMode) "Update" else "Save", + style = MaterialTheme.typography.labelLarge.copy( + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ), + color = Color.White + ) + } + } + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(scrollState) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + // Title Input + OutlinedTextField( + value = title, + onValueChange = { title = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Title") }, + placeholder = { Text("Enter template title") }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = FrogoPrimary, + focusedLabelColor = FrogoPrimary + ) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Body / Content Input + OutlinedTextField( + value = body, + onValueChange = { body = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Content") }, + placeholder = { Text("Enter auto text body...") }, + minLines = 6, + shape = RoundedCornerShape(16.dp), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = FrogoPrimary, + focusedLabelColor = FrogoPrimary + ) + ) + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Preview(showBackground = true) +@Composable +fun AutoTextEditorScreenPreview() { + FrogoKeyboardTheme(darkTheme = false) { + AutoTextEditorScreen( + initialTitle = "Quick Greeting", + initialBody = "Hello there! Wishing you a great day ahead.", + isEditMode = true, + onSave = { _, _ -> }, + onBackClick = {} + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextScreen.kt new file mode 100644 index 0000000..8e010de --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextScreen.kt @@ -0,0 +1,181 @@ +package com.frogobox.appkeyboard.ui.autotext + +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.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +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.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.model.AutoTextEntity +import com.frogobox.appkeyboard.ui.theme.compose.FrogoEmptyView +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar + +@Composable +fun AutoTextScreen( + autoTextList: List, + isLoading: Boolean, + onItemClick: (AutoTextEntity) -> Unit, + onAddClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Auto Text", + onBackClick = onBackClick + ) + }, + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Button( + onClick = onAddClick, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = FrogoPrimary + ) + ) { + Text( + text = "Add Auto Text", + style = MaterialTheme.typography.labelLarge.copy( + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ), + color = Color.White + ) + } + } + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + when { + isLoading && autoTextList.isEmpty() -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = FrogoPrimary + ) + } + autoTextList.isEmpty() -> { + FrogoEmptyView( + title = "NO AUTO TEXT FOUND", + subtitle = "Tap 'Add Auto Text' below to create a quick template.", + modifier = Modifier.align(Alignment.Center) + ) + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(autoTextList, key = { it.id }) { item -> + AutoTextCard( + item = item, + onClick = { onItemClick(item) } + ) + } + } + } + } + } + } +} + +@Composable +private fun AutoTextCard( + item: AutoTextEntity, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = item.body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun AutoTextScreenPreview() { + FrogoKeyboardTheme(darkTheme = false) { + AutoTextScreen( + autoTextList = listOf( + AutoTextEntity(id = 1, title = "Greeting", body = "Hello, good morning!"), + AutoTextEntity(id = 2, title = "Account Number", body = "BCA: 1234567890 a/n Faisal") + ), + isLoading = false, + onItemClick = {}, + onAddClick = {}, + onBackClick = {} + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageActivity.kt index 50ae054..9912d6a 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageActivity.kt @@ -1,135 +1,46 @@ package com.frogobox.appkeyboard.ui.language import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup import androidx.activity.viewModels -import com.frogobox.appkeyboard.common.base.BaseActivity -import com.frogobox.appkeyboard.databinding.ActivityKeyboardLanguageBinding -import com.frogobox.appkeyboard.databinding.ItemLanguageKeyboardBinding -import com.frogobox.recycler.core.FrogoRecyclerNotifyListener -import com.frogobox.recycler.core.IFrogoBindingAdapter -import com.frogobox.recycler.ext.injectorBinding -import com.frogobox.sdk.ext.gone +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.frogobox.appkeyboard.common.base.BaseComposeActivity import com.frogobox.sdk.ext.showToast -import com.frogobox.sdk.ext.visible -import com.frogobox.sdk.util.FrogoFunc -import com.frogobox.sdk.util.SimpleDialogUtil import dagger.hilt.android.AndroidEntryPoint -/** - * Created by Faisal Amir on 24/10/22 - * ----------------------------------------- - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - * Copyright (C) Frogobox ID / amirisback - * All rights reserved - */ - @AndroidEntryPoint -class KeyboardLanguageActivity : BaseActivity(), - IFrogoBindingAdapter { +class KeyboardLanguageActivity : BaseComposeActivity() { private val viewModel: KeyboardLanguageViewModel by viewModels() - override fun setupViewBinding(): ActivityKeyboardLanguageBinding { - return ActivityKeyboardLanguageBinding.inflate(layoutInflater) - } - - override fun setupViewModel() { - super.setupViewModel() - viewModel.apply { - keyboardLanguage.observe(this@KeyboardLanguageActivity) { - setupRV(it) - } - } - } + private var languageList by mutableStateOf>(emptyList()) + private var activeLanguageXml by mutableStateOf(0) override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) - if (savedInstanceState == null) { - viewModel.getKeyboardLanguage(this) - } - } - - override fun initView() { - super.initView() - setupDetailActivity("Keyboard Language") - binding.apply { - + viewModel.keyboardLanguage.observe(this) { + languageList = it + val active = it.firstOrNull { lang -> viewModel.checkKeyboardType(lang.xml) } + activeLanguageXml = active?.xml ?: 0 } - } - - override fun areContentsTheSame(oldItem: KeyboardLanguage, newItem: KeyboardLanguage): Boolean { - return oldItem == newItem - } - - override fun areItemsTheSame(oldItem: KeyboardLanguage, newItem: KeyboardLanguage): Boolean { - return oldItem.xml == newItem.xml - } - - override fun setViewBinding(parent: ViewGroup): ItemLanguageKeyboardBinding { - return ItemLanguageKeyboardBinding.inflate(LayoutInflater.from(parent.context), parent, false) - } - - override fun onItemClicked( - binding: ItemLanguageKeyboardBinding, - data: KeyboardLanguage, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) { - applyLanguage(data) - } - - override fun onItemLongClicked( - binding: ItemLanguageKeyboardBinding, - data: KeyboardLanguage, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) {} - - override fun setupInitComponent( - binding: ItemLanguageKeyboardBinding, - data: KeyboardLanguage, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) { - binding.apply { - tvItemKeyboardMain.text = data.name - if (viewModel.checkKeyboardType(data.xml)) { - this.ivIcon.visible() - } else { - this.ivIcon.gone() - } - } - } - - private fun setupRV(data: List) { - binding.rvKeyboardLanguage - .injectorBinding() - .addData(data) - .addCallback(this) - .createLayoutLinearVertical(false) - .build() - } - - private fun applyLanguage(data: KeyboardLanguage) { - SimpleDialogUtil.create( - this@KeyboardLanguageActivity, - "Change Theme", - "Are you sure you want to change keyboard theme?", - object : SimpleDialogUtil.OnDialogClickListener { - - override fun negativeButton() {} - override fun positiveButton() { - viewModel.setKeyboard(data.xml) { - showToast("${data.name} Language Applied") - viewModel.getKeyboardLanguage(this@KeyboardLanguageActivity) - } + viewModel.getKeyboardLanguage(this) + } + + @Composable + override fun Content() { + KeyboardLanguageScreen( + languageList = languageList, + checkIsSelected = { xml -> xml == activeLanguageXml }, + onApplyLanguage = { language -> + viewModel.setKeyboard(language.xml) { + activeLanguageXml = language.xml + showToast("${language.name} Language Applied") + viewModel.getKeyboardLanguage(this@KeyboardLanguageActivity) } - } + }, + onBackClick = { finish() } ) } - } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageScreen.kt new file mode 100644 index 0000000..a3e785c --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageScreen.kt @@ -0,0 +1,184 @@ +package com.frogobox.appkeyboard.ui.language + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +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.padding +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.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 com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar + +@Composable +fun KeyboardLanguageScreen( + languageList: List, + checkIsSelected: (Int) -> Boolean, + onApplyLanguage: (KeyboardLanguage) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + var selectedLanguageForDialog by remember { mutableStateOf(null) } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Keyboard Language", + onBackClick = onBackClick + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(languageList, key = { it.name + it.xml }) { language -> + LanguageCard( + language = language, + isSelected = checkIsSelected(language.xml), + onClick = { + selectedLanguageForDialog = language + } + ) + } + } + } + + selectedLanguageForDialog?.let { language -> + AlertDialog( + onDismissRequest = { selectedLanguageForDialog = null }, + title = { + Text( + text = "Change Keyboard Layout", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold + ) + ) + }, + text = { + Text("Are you sure you want to change keyboard layout to ${language.name}?") + }, + confirmButton = { + TextButton( + onClick = { + val current = language + selectedLanguageForDialog = null + onApplyLanguage(current) + } + ) { + Text( + text = "Apply", + color = FrogoPrimary, + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + TextButton( + onClick = { selectedLanguageForDialog = null } + ) { + Text("Cancel") + } + }, + shape = RoundedCornerShape(16.dp) + ) + } +} + +@Composable +private fun LanguageCard( + language: KeyboardLanguage, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = language.name, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (isSelected) { + Spacer(modifier = Modifier.width(12.dp)) + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Active Language", + tint = FrogoPrimary, + modifier = Modifier.size(24.dp) + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun KeyboardLanguageScreenPreview() { + FrogoKeyboardTheme(darkTheme = false) { + KeyboardLanguageScreen( + languageList = listOf( + KeyboardLanguage("English (QWERTY)", 1), + KeyboardLanguage("German", 2) + ), + checkIsSelected = { it == 1 }, + onApplyLanguage = {}, + onBackClick = {} + ) + } +} 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 9fc25b8..a51455f 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 @@ -3,53 +3,41 @@ package com.frogobox.appkeyboard.ui.main import android.content.Intent import android.os.Bundle import android.provider.Settings -import android.util.Log import android.view.inputmethod.InputMethodManager import androidx.activity.viewModels -import com.frogobox.appkeyboard.databinding.ActivityMainBinding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.frogobox.appkeyboard.common.base.BaseComposeMainActivity import com.frogobox.appkeyboard.ui.autotext.AutoTextActivity import com.frogobox.appkeyboard.ui.language.KeyboardLanguageActivity import com.frogobox.appkeyboard.ui.test.TestActivity import com.frogobox.appkeyboard.ui.theme.ThemeActivity import com.frogobox.appkeyboard.ui.toggle.ToggleActivity -import com.frogobox.sdk.ext.getColorExt import com.frogobox.sdk.ext.startActivityExt import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint -class MainActivity : BaseMainActivity() { +class MainActivity : BaseComposeMainActivity() { + + private val viewModel: MainViewModel by viewModels() private val NONE = 0 private val PICKING = 1 private val CHOSEN = 2 - private var mState = 0 - companion object { - private val TAG: String = MainActivity::class.java.simpleName - } - - private val viewModel: MainViewModel by viewModels() - - override fun setupViewBinding(): ActivityMainBinding { - return ActivityMainBinding.inflate(layoutInflater) - } - - override fun setupViewModel() { - super.setupViewModel() - viewModel.apply { - - } - } + private var keyboardStatus by mutableStateOf(KeyboardStatus.NOT_ENABLED) override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) - if (savedInstanceState == null) { - // Call View Model Here - Log.d(TAG, "View Model : ${viewModel::class.java.simpleName}") - } - // TODO : Add your code here - initView() + updateKeyboardStatus() + } + + override fun onResume() { + super.onResume() + updateKeyboardStatus() } override fun onWindowFocusChanged(hasFocus: Boolean) { @@ -57,52 +45,7 @@ class MainActivity : BaseMainActivity() { if (mState == PICKING) { mState = CHOSEN } else if (mState == CHOSEN) { - handlingState() - } - } - - override fun onResume() { - super.onResume() - handlingState() - } - - override fun initView() { - super.initView() - binding.apply { - handlingState() - - btnChangeKeyboard.setOnClickListener { - (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker() - mState = PICKING - } - - btnGoToSetting.setOnClickListener { - Intent(Settings.ACTION_INPUT_METHOD_SETTINGS).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(this) - } - } - - btnDoSomeTest.setOnClickListener { - startActivityExt() - } - - btnAutoText.setOnClickListener { - startActivityExt() - } - - btnToggle.setOnClickListener { - startActivityExt() - } - - btnMultiLanguage.setOnClickListener { - startActivityExt() - } - - btnTheme.setOnClickListener { - startActivityExt() - } - + updateKeyboardStatus() } } @@ -120,21 +63,33 @@ class MainActivity : BaseMainActivity() { } } - private fun handlingState() { - binding.titleState.apply { - if (!isKeyboardEnabled()) { - text = "Frogo Keyboard Not Active" - setTextColor(getColorExt(com.frogobox.libkeyboard.R.color.status_failed)) - } else { - if (isUsingKeyboard()) { - text = "Frogo Keyboard Active" - setTextColor(getColorExt(com.frogobox.libkeyboard.R.color.status_success)) - } else { - text = "Not Using Frogo Keyboard" - setTextColor(getColorExt(com.frogobox.libkeyboard.R.color.status_warning)) - } - } + private fun updateKeyboardStatus() { + keyboardStatus = when { + !isKeyboardEnabled() -> KeyboardStatus.NOT_ENABLED + isUsingKeyboard() -> KeyboardStatus.ACTIVE + else -> KeyboardStatus.NOT_DEFAULT } } + @Composable + override fun Content() { + MainScreen( + status = keyboardStatus, + onGoToSettings = { + Intent(Settings.ACTION_INPUT_METHOD_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(this) + } + }, + onChangeKeyboard = { + (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker() + mState = PICKING + }, + onNavigateAutoText = { startActivityExt() }, + onNavigateToggle = { startActivityExt() }, + onNavigateLanguage = { startActivityExt() }, + onNavigateTheme = { startActivityExt() }, + onNavigateTest = { startActivityExt() } + ) + } } \ 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 new file mode 100644 index 0000000..95063d5 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainScreen.kt @@ -0,0 +1,463 @@ +package com.frogobox.appkeyboard.ui.main + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +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.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.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.Info +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.Scaffold +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +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 com.frogobox.appkeyboard.R +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusFailed +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusSuccess +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusWarning +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar + +enum class KeyboardStatus( + val title: String, + val description: String, + val badge: String, + val color: Color, + val icon: ImageVector +) { + ACTIVE( + title = "Frogo Keyboard Active", + description = "Keyboard is activated and set as default input method.", + badge = "ACTIVE", + color = FrogoStatusSuccess, + icon = Icons.Default.CheckCircle + ), + NOT_DEFAULT( + title = "Not Default Keyboard", + description = "Keyboard is enabled, but another keyboard is currently selected.", + badge = "SELECT FROGO", + color = FrogoStatusWarning, + icon = Icons.Default.Warning + ), + NOT_ENABLED( + title = "Frogo Keyboard Inactive", + description = "Enable Frogo Keyboard in Android system settings to get started.", + badge = "NOT ENABLED", + color = FrogoStatusFailed, + icon = Icons.Default.Info + ) +} + +@Composable +fun MainScreen( + status: KeyboardStatus, + onGoToSettings: () -> Unit, + onChangeKeyboard: () -> Unit, + onNavigateAutoText: () -> Unit, + onNavigateToggle: () -> Unit, + onNavigateLanguage: () -> Unit, + onNavigateTheme: () -> Unit, + onNavigateTest: () -> Unit, + modifier: Modifier = Modifier +) { + val scrollState = rememberScrollState() + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Frogo Keyboard" + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(paddingValues) + .padding(horizontal = 20.dp, vertical = 12.dp), + horizontalAlignment = Alignment.Start + ) { + // Header Description + Text( + text = "Enable and set as default keyboard to start typing with custom features and personalized tools.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Reactive Status Card + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = status.color.copy(alpha = 0.12f) + ), + border = BorderStroke(1.dp, status.color.copy(alpha = 0.35f)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(42.dp) + .clip(CircleShape) + .background(status.color.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = status.icon, + contentDescription = null, + tint = status.color, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = status.title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 15.sp + ), + color = status.color + ) + + Surface( + shape = RoundedCornerShape(6.dp), + color = status.color.copy(alpha = 0.2f) + ) { + Text( + text = status.badge, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp + ), + color = status.color, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = status.description, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Activation Steps Row (Step 1: Settings, Step 2: Switch Keyboard) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + ActivationCard( + stepNumber = "1", + title = "Enable Keyboard", + subtitle = "System Settings", + iconRes = R.drawable.ic_menu_setting, + onClick = onGoToSettings, + modifier = Modifier.weight(1f) + ) + + ActivationCard( + stepNumber = "2", + title = "Set Default", + subtitle = "Switch Keyboard", + iconRes = R.drawable.ic_menu_keyboard, + onClick = onChangeKeyboard, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Section Header + Text( + text = "Features & Customization", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ), + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Feature Menu List (Material 3 Cards with Icon, Subtitle, and Chevron) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + MainMenuItemCard( + title = "Auto Text", + subtitle = "Quick snippets & repetitive text templates", + iconRes = R.drawable.ic_menu_auto_text, + onClick = onNavigateAutoText + ) + + MainMenuItemCard( + title = "Toggle Function", + subtitle = "Sound, vibration & auto-capitalization switches", + iconRes = R.drawable.ic_menu_form, + onClick = onNavigateToggle + ) + + MainMenuItemCard( + title = "Keyboard Language", + subtitle = "Multilingual input languages & layouts", + iconRes = R.drawable.ic_menu_website, + onClick = onNavigateLanguage + ) + + MainMenuItemCard( + title = "Keyboard Theme", + subtitle = "Personalized colors, themes & wallpapers", + iconRes = R.drawable.ic_menu_ps_game, + onClick = onNavigateTheme + ) + + MainMenuItemCard( + title = "Do Some Test", + subtitle = "Interactive typing test & keyboard sandbox", + iconRes = R.drawable.ic_menu_keyboard, + onClick = onNavigateTest + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun ActivationCard( + stepNumber: String, + title: String, + subtitle: String, + iconRes: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 1.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), + horizontalAlignment = Alignment.Start + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = iconRes), + contentDescription = title, + modifier = Modifier.size(36.dp) + ) + + Surface( + shape = RoundedCornerShape(6.dp), + color = FrogoPrimary.copy(alpha = 0.1f) + ) { + Text( + text = "STEP $stepNumber", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp + ), + color = FrogoPrimary, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = title, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun MainMenuItemCard( + title: String, + subtitle: String, + iconRes: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = iconRes), + contentDescription = title, + modifier = Modifier.size(36.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(20.dp) + ) + } + } +} + +@Preview(name = "Light Mode - Active", showBackground = true) +@Composable +fun MainScreenActivePreview() { + FrogoKeyboardTheme(darkTheme = false) { + MainScreen( + status = KeyboardStatus.ACTIVE, + onGoToSettings = {}, + onChangeKeyboard = {}, + onNavigateAutoText = {}, + onNavigateToggle = {}, + onNavigateLanguage = {}, + onNavigateTheme = {}, + onNavigateTest = {} + ) + } +} + +@Preview(name = "Dark Mode - Inactive", showBackground = true) +@Composable +fun MainScreenInactiveDarkPreview() { + FrogoKeyboardTheme(darkTheme = true) { + MainScreen( + status = KeyboardStatus.NOT_ENABLED, + onGoToSettings = {}, + onChangeKeyboard = {}, + onNavigateAutoText = {}, + onNavigateToggle = {}, + onNavigateLanguage = {}, + onNavigateTheme = {}, + onNavigateTest = {} + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestActivity.kt index 80b48cb..4cfb17d 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestActivity.kt @@ -29,7 +29,6 @@ class TestActivity : BaseComposeActivity() { override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) - setupDetailActivity("Test Area") if (savedInstanceState == null) { // Call View Model Here Log.d(TAG, "View Model : ${viewModel::class.java.simpleName}") @@ -39,7 +38,8 @@ class TestActivity : BaseComposeActivity() { @Composable override fun Content() { TestScreen( - dummyOptions = dummyOptions + dummyOptions = dummyOptions, + onBackClick = { finish() } ) } 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 7f9451c..b265dec 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 @@ -27,11 +27,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.material3.Scaffold import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar /** * TestScreen Composable @@ -42,6 +44,7 @@ import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme @Composable fun TestScreen( dummyOptions: List = emptyList(), + onBackClick: (() -> Unit)? = null, modifier: Modifier = Modifier ) { var fullText by remember { mutableStateOf("") } @@ -59,13 +62,23 @@ fun TestScreen( val scrollState = rememberScrollState() - Column( - modifier = modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .verticalScroll(scrollState) - .padding(start = 30.dp, end = 30.dp, top = 16.dp, bottom = 16.dp) - ) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Test Area", + onBackClick = onBackClick + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(scrollState) + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 16.dp) + ) { Text( text = "Let's Do Some Test", style = MaterialTheme.typography.titleLarge.copy( @@ -155,6 +168,7 @@ fun TestScreen( } } } +} @Preview(showBackground = true, name = "TestScreen Light") @Composable diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeActivity.kt index 0218a1c..236555b 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeActivity.kt @@ -1,150 +1,50 @@ package com.frogobox.appkeyboard.ui.theme import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup import androidx.activity.viewModels -import com.frogobox.appkeyboard.common.base.BaseActivity -import com.frogobox.appkeyboard.databinding.ActivityThemeBinding -import com.frogobox.appkeyboard.databinding.ItemThemeBinding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.frogobox.appkeyboard.common.base.BaseComposeActivity import com.frogobox.appkeyboard.model.KeyboardThemeModel -import com.frogobox.recycler.core.FrogoRecyclerNotifyListener -import com.frogobox.recycler.core.IFrogoBindingAdapter -import com.frogobox.recycler.ext.injectorBinding -import com.frogobox.sdk.ext.gone import com.frogobox.sdk.ext.showToast -import com.frogobox.sdk.ext.visible -import com.frogobox.sdk.util.FrogoFunc -import com.frogobox.sdk.util.SimpleDialogUtil import dagger.hilt.android.AndroidEntryPoint -/** - * Created by faisalamircs on 16/02/2024 - * ----------------------------------------- - * Name : Muhammad Faisal Amir - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - */ - - @AndroidEntryPoint -class ThemeActivity : BaseActivity(), - IFrogoBindingAdapter { - - companion object { - private val TAG: String = ThemeActivity::class.java.simpleName - } +class ThemeActivity : BaseComposeActivity() { private val viewModel: ThemeViewModel by viewModels() - override fun setupViewBinding(): ActivityThemeBinding { - return ActivityThemeBinding.inflate(layoutInflater) - } - - override fun setupViewModel() { - super.setupViewModel() - viewModel.keyboardThemeState.observe(this) { - setupRV(it) - } - } - + private var themeList by mutableStateOf>(emptyList()) + private var activeThemeVersion by mutableIntStateOf(0) override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) - setupDetailActivity("Setup Theme") - if (savedInstanceState == null) { - // Call View Model Here - viewModel.getThemeData() - } - // TODO : Add your code here - - } - - override fun initView() { - super.initView() - binding.apply { - - } - } - - override fun onItemClicked( - binding: ItemThemeBinding, - data: KeyboardThemeModel, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) { - applyTheme(data) - } - - override fun onItemLongClicked( - binding: ItemThemeBinding, - data: KeyboardThemeModel, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) {} - - override fun areContentsTheSame( - oldItem: KeyboardThemeModel, - newItem: KeyboardThemeModel - ): Boolean { - return oldItem == newItem - } - - override fun areItemsTheSame( - oldItem: KeyboardThemeModel, - newItem: KeyboardThemeModel - ): Boolean { - return oldItem.name == newItem.name - } - - override fun setViewBinding(parent: ViewGroup): ItemThemeBinding { - return ItemThemeBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - } - - override fun setupInitComponent( - binding: ItemThemeBinding, - data: KeyboardThemeModel, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) { - binding.ivBackground.setImageResource(data.background) - binding.tvTitle.text = data.name - binding.tvDesc.text = data.description - if (viewModel.getThemeColor() == data.background) { - binding.ivIcon.visible() - } else { - binding.ivIcon.gone() + viewModel.keyboardThemeState.observe(this) { + themeList = it + activeThemeVersion++ } - } - - private fun setupRV(data: List) { - binding.root.injectorBinding() - .addData(data) - .addCallback(this) - .createLayoutLinearVertical(false) - .build() - } - - private fun applyTheme(data: KeyboardThemeModel) { - SimpleDialogUtil.create( - this@ThemeActivity, - "Change Theme", - "Are you sure you want to change keyboard theme?", - object : SimpleDialogUtil.OnDialogClickListener { - override fun negativeButton() {} - override fun positiveButton() { - viewModel.setThemeColor(data) { - viewModel.getThemeData() - showToast("${data.name} Theme Applied") - } + viewModel.getThemeData() + } + + @Composable + override fun Content() { + val version = activeThemeVersion + ThemeScreen( + themeList = themeList, + checkIsActive = { theme -> + version >= 0 && viewModel.isThemeActive(theme) + }, + onApplyTheme = { theme -> + viewModel.setThemeColor(theme) { + activeThemeVersion++ + viewModel.getThemeData() + showToast("${theme.name} Theme Applied") } - } + }, + onBackClick = { finish() } ) } - } \ No newline at end of file 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 new file mode 100644 index 0000000..ab0d9ed --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeScreen.kt @@ -0,0 +1,272 @@ +package com.frogobox.appkeyboard.ui.theme + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.layout.ContentScale +import androidx.compose.ui.res.colorResource +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 com.frogobox.appkeyboard.R +import com.frogobox.appkeyboard.model.KeyboardThemeModel +import com.frogobox.appkeyboard.model.ThemeType +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar + +@Composable +fun ThemeScreen( + themeList: List, + checkIsActive: (KeyboardThemeModel) -> Boolean, + onApplyTheme: (KeyboardThemeModel) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + var selectedThemeForDialog by remember { mutableStateOf(null) } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Setup Theme", + onBackClick = onBackClick + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(themeList, key = { it.name }) { theme -> + ThemeCard( + theme = theme, + isSelected = checkIsActive(theme), + onClick = { + selectedThemeForDialog = theme + } + ) + } + } + } + + selectedThemeForDialog?.let { theme -> + AlertDialog( + onDismissRequest = { selectedThemeForDialog = null }, + title = { + Text( + text = "Change Theme", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold + ) + ) + }, + text = { + Text("Are you sure you want to change keyboard theme to ${theme.name}?") + }, + confirmButton = { + TextButton( + onClick = { + val current = theme + selectedThemeForDialog = null + onApplyTheme(current) + } + ) { + Text( + text = "Apply", + color = FrogoPrimary, + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + TextButton( + onClick = { selectedThemeForDialog = null } + ) { + Text("Cancel") + } + }, + shape = RoundedCornerShape(16.dp) + ) + } +} + +@Composable +fun ThemeScreen( + themeList: List, + activeThemeColor: Int, + onApplyTheme: (KeyboardThemeModel) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + ThemeScreen( + themeList = themeList, + checkIsActive = { it.background == activeThemeColor }, + onApplyTheme = onApplyTheme, + onBackClick = onBackClick, + modifier = modifier + ) +} + +@Composable +private fun ThemeCard( + theme: KeyboardThemeModel, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Circular Thumbnail Preview based on ThemeType + when (theme.themType) { + ThemeType.COLOR -> { + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(colorResource(id = theme.background)) + .border( + width = 1.5.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + shape = CircleShape + ) + ) + } + ThemeType.IMAGE -> { + Image( + painter = painterResource(id = theme.background), + contentDescription = theme.name, + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .border( + width = 1.5.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + shape = CircleShape + ), + contentScale = ContentScale.Crop + ) + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = theme.name, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = theme.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + if (isSelected) { + Spacer(modifier = Modifier.width(12.dp)) + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Active Theme", + tint = FrogoPrimary, + modifier = Modifier.size(28.dp) + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun ThemeScreenPreview() { + FrogoKeyboardTheme(darkTheme = false) { + ThemeScreen( + themeList = listOf( + KeyboardThemeModel( + name = "Default", + themType = ThemeType.COLOR, + background = R.color.color_bg_keyboard_default, + description = "Default standard keyboard theme" + ), + KeyboardThemeModel( + name = "Wallpaper", + themType = ThemeType.IMAGE, + background = R.drawable.ic_wallpaper_dummy, + description = "Custom image wallpaper" + ) + ), + checkIsActive = { it.themType == ThemeType.COLOR }, + onApplyTheme = {}, + onBackClick = {} + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeViewModel.kt index 2dcd26e..d80199b 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeViewModel.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeViewModel.kt @@ -5,8 +5,8 @@ import androidx.lifecycle.MutableLiveData import com.frogobox.appkeyboard.R import com.frogobox.appkeyboard.common.base.BaseViewModel import com.frogobox.appkeyboard.model.KeyboardThemeModel +import com.frogobox.appkeyboard.model.ThemeType import com.frogobox.appkeyboard.services.KeyboardUtil -import com.frogobox.coresdk.response.FrogoStateResponse import com.frogobox.sdk.delegate.preference.PreferenceDelegates import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -20,7 +20,6 @@ import javax.inject.Inject * ----------------------------------------- */ - @HiltViewModel class ThemeViewModel @Inject constructor( private val pref: PreferenceDelegates, @@ -38,6 +37,14 @@ class ThemeViewModel @Inject constructor( return pref.getPrefInt(KeyboardUtil.KEYBOARD_COLOR, R.color.color_bg_keyboard_default) } + fun getThemeType(): String { + return pref.getPrefString(KeyboardUtil.KEYBOARD_COLOR_TYPE, ThemeType.COLOR.name) + } + + fun isThemeActive(data: KeyboardThemeModel): Boolean { + return data.background == getThemeColor() && data.themType.name == getThemeType() + } + fun setThemeColor(data: KeyboardThemeModel, onSuccess: () -> Unit) { pref.savePrefString(KeyboardUtil.KEYBOARD_COLOR_TYPE, data.themType.name) pref.savePrefInt(KeyboardUtil.KEYBOARD_COLOR, data.background) diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/FrogoEmptyView.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/FrogoEmptyView.kt new file mode 100644 index 0000000..1d6d5ce --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/FrogoEmptyView.kt @@ -0,0 +1,67 @@ +package com.frogobox.appkeyboard.ui.theme.compose + +import androidx.compose.foundation.layout.Arrangement +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.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 + +/** + * Reusable Empty State Composable for lists across Frogo Keyboard. + */ +@Composable +fun FrogoEmptyView( + title: String = "NO DATA FOUND", + subtitle: String = "Please insert data", + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + modifier = Modifier.size(72.dp), + tint = MaterialTheme.colorScheme.outline + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = title.uppercase(), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ), + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/FrogoTopAppBar.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/FrogoTopAppBar.kt new file mode 100644 index 0000000..520c08c --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/FrogoTopAppBar.kt @@ -0,0 +1,56 @@ +package com.frogobox.appkeyboard.ui.theme.compose + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight + +/** + * Reusable Material 3 TopAppBar for Frogo Keyboard app screens. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FrogoTopAppBar( + title: String, + modifier: Modifier = Modifier, + onBackClick: (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {} +) { + TopAppBar( + title = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onBackground + ) + }, + modifier = modifier, + navigationIcon = { + if (onBackClick != null) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onBackground + ) + } + } + }, + actions = actions, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + scrolledContainerColor = MaterialTheme.colorScheme.surface + ) + ) +} 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 d5d55ff..5e665b2 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 @@ -1,107 +1,45 @@ package com.frogobox.appkeyboard.ui.toggle import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup import androidx.activity.viewModels -import com.frogobox.appkeyboard.databinding.ActivityToggleBinding -import com.frogobox.appkeyboard.databinding.ItemToggleBinding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.frogobox.appkeyboard.common.base.BaseComposeActivity import com.frogobox.appkeyboard.model.KeyboardFeatureModel -import com.frogobox.appkeyboard.ui.main.BaseMainActivity -import com.frogobox.recycler.core.FrogoRecyclerNotifyListener -import com.frogobox.recycler.core.IFrogoBindingAdapter -import com.frogobox.recycler.ext.injectorBinding import dagger.hilt.android.AndroidEntryPoint -/** - * Created by Faisal Amir on 24/10/22 - * ----------------------------------------- - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - * Copyright (C) Frogobox ID / amirisback - * All rights reserved - */ - - @AndroidEntryPoint -class ToggleActivity : BaseMainActivity() { +class ToggleActivity : BaseComposeActivity() { private val viewModel: ToggleViewModel by viewModels() - override fun setupViewBinding(): ActivityToggleBinding { - return ActivityToggleBinding.inflate(layoutInflater) - } + private var featureList by mutableStateOf>(emptyList()) + private val toggleStates = mutableStateMapOf() override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) - if (savedInstanceState == null) { - viewModel.getKeyboardFeatureData() + viewModel.keyboardFeatureState.observe(this) { list -> + featureList = list + list.forEach { feature -> + toggleStates[feature.id] = viewModel.getSwitchToggle(feature.id) + } } + viewModel.getKeyboardFeatureData() } - override fun initView() { - super.initView() - setupDetailActivity("Toggle Feature") - } - - override fun setupViewModel() { - super.setupViewModel() - viewModel.keyboardFeatureState.observe(this) { - binding.root.injectorBinding() - .addData(it) - .addCallback(object : - IFrogoBindingAdapter { - override fun areContentsTheSame( - oldItem: KeyboardFeatureModel, - newItem: KeyboardFeatureModel - ): Boolean { - return oldItem == newItem - } - - override fun areItemsTheSame( - oldItem: KeyboardFeatureModel, - newItem: KeyboardFeatureModel - ): Boolean { - return oldItem.id == newItem.id - } - - override fun setViewBinding(parent: ViewGroup): ItemToggleBinding { - return ItemToggleBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - } - - override fun setupInitComponent( - binding: ItemToggleBinding, - data: KeyboardFeatureModel, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - binding.swToggle.setOnCheckedChangeListener(null) - binding.ivIcon.setImageResource(data.icon) - binding.tvTitle.text = data.text - binding.swToggle.isChecked = viewModel.getSwitchToggle(data.id) - binding.swToggle.setOnCheckedChangeListener { _, isChecked -> - viewModel.switchToggle(data.id, isChecked) - } - } - - override fun onItemClicked( - binding: ItemToggleBinding, - data: KeyboardFeatureModel, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) {} - - - - }) - .createLayoutGrid(2) - .build() - } + @Composable + override fun Content() { + ToggleScreen( + features = featureList, + getToggleState = { id -> toggleStates[id] ?: viewModel.getSwitchToggle(id) }, + onToggleChanged = { id, isChecked -> + toggleStates[id] = isChecked + viewModel.switchToggle(id, isChecked) + }, + onBackClick = { finish() } + ) } - } \ No newline at end of file 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 new file mode 100644 index 0000000..f9de6e9 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt @@ -0,0 +1,550 @@ +package com.frogobox.appkeyboard.ui.toggle + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +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.PaddingValues +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.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.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 com.frogobox.appkeyboard.R +import com.frogobox.appkeyboard.model.KeyboardFeatureModel +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusSuccess +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar + +enum class ToggleFilterTab { + ALL, + ACTIVE, + DISABLED +} + +@Composable +fun ToggleScreen( + features: List, + getToggleState: (String) -> Boolean, + onToggleChanged: (String, Boolean) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + var selectedTab by remember { mutableStateOf(ToggleFilterTab.ALL) } + + val activeCount = features.count { getToggleState(it.id) } + val disabledCount = features.size - activeCount + + val filteredFeatures = when (selectedTab) { + ToggleFilterTab.ALL -> features + ToggleFilterTab.ACTIVE -> features.filter { getToggleState(it.id) } + ToggleFilterTab.DISABLED -> features.filter { !getToggleState(it.id) } + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Toggle Feature", + onBackClick = onBackClick + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 28.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Header summary banner + item(span = { GridItemSpan(maxLineSpan) }) { + ToggleHeaderBanner( + totalCount = features.size, + activeCount = activeCount, + onEnableAll = { + features.forEach { onToggleChanged(it.id, true) } + }, + onDisableAll = { + features.forEach { onToggleChanged(it.id, false) } + } + ) + } + + // Quick Filter Chips + item(span = { GridItemSpan(maxLineSpan) }) { + ToggleFilterRow( + selectedTab = selectedTab, + totalCount = features.size, + activeCount = activeCount, + disabledCount = disabledCount, + onTabSelected = { selectedTab = it } + ) + } + + if (filteredFeatures.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 40.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = if (selectedTab == ToggleFilterTab.ACTIVE) { + "No active features found" + } else { + "No disabled features found" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = { selectedTab = ToggleFilterTab.ALL }) { + Text( + text = "Show All Features", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } + } + } + } + } else { + items(filteredFeatures, key = { it.id }) { feature -> + ToggleFeatureCard( + feature = feature, + isChecked = getToggleState(feature.id), + onCheckedChange = { isChecked -> + onToggleChanged(feature.id, isChecked) + } + ) + } + } + } + } +} + +@Composable +private fun ToggleHeaderBanner( + totalCount: Int, + activeCount: Int, + onEnableAll: () -> Unit, + onDisableAll: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Keyboard Toolbar", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Manage shortcuts displayed on your keyboard", + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + ) { + Text( + text = "$activeCount/$totalCount Active", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 11.sp + ), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) + ) + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = onEnableAll, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = "Enable All", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.primary + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + TextButton( + onClick = onDisableAll, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = "Disable All", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Normal, + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun ToggleFilterRow( + selectedTab: ToggleFilterTab, + totalCount: Int, + activeCount: Int, + disabledCount: Int, + onTabSelected: (ToggleFilterTab) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ToggleFilterChip( + text = "All ($totalCount)", + isSelected = selectedTab == ToggleFilterTab.ALL, + onClick = { onTabSelected(ToggleFilterTab.ALL) } + ) + + ToggleFilterChip( + text = "Active ($activeCount)", + isSelected = selectedTab == ToggleFilterTab.ACTIVE, + onClick = { onTabSelected(ToggleFilterTab.ACTIVE) } + ) + + ToggleFilterChip( + text = "Disabled ($disabledCount)", + isSelected = selectedTab == ToggleFilterTab.DISABLED, + onClick = { onTabSelected(ToggleFilterTab.DISABLED) } + ) + } +} + +@Composable +private fun ToggleFilterChip( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier + .clip(RoundedCornerShape(20.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(20.dp), + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surface + }, + border = BorderStroke( + width = 1.dp, + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + } + ) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + fontSize = 12.sp + ), + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp) + ) + } +} + +@Composable +private fun ToggleFeatureCard( + feature: KeyboardFeatureModel, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val primaryColor = MaterialTheme.colorScheme.primary + val surfaceColor = MaterialTheme.colorScheme.surface + val outlineVariant = MaterialTheme.colorScheme.outlineVariant + + Card( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .clickable { onCheckedChange(!isChecked) }, + shape = RoundedCornerShape(18.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + border = BorderStroke( + width = 1.dp, + color = if (isChecked) { + primaryColor.copy(alpha = 0.45f) + } else { + outlineVariant.copy(alpha = 0.35f) + } + ), + colors = CardDefaults.cardColors( + containerColor = if (isChecked) { + primaryColor.copy(alpha = 0.08f) + } else { + surfaceColor + } + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp) + ) { + // Top row with Icon Container and Switch + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + if (isChecked) { + primaryColor.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + ), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = feature.icon), + contentDescription = feature.text, + modifier = Modifier.size(24.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)) + + Text( + text = feature.text, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ), + color = if (isChecked) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background( + if (isChecked) { + FrogoStatusSuccess + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.4f) + } + ) + ) + + Spacer(modifier = Modifier.width(6.dp)) + + Text( + text = if (isChecked) "Active" else "Disabled", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = if (isChecked) FontWeight.SemiBold else FontWeight.Normal, + fontSize = 11.sp + ), + color = if (isChecked) { + FrogoStatusSuccess + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + } + ) + } + } + } +} + +@Preview(name = "Light Mode", showBackground = true) +@Composable +fun ToggleScreenLightPreview() { + FrogoKeyboardTheme(darkTheme = false) { + ToggleScreen( + features = listOf( + KeyboardFeatureModel( + id = "auto_text", + text = "Auto Text", + icon = R.drawable.ic_menu_auto_text + ), + KeyboardFeatureModel( + id = "app_review", + text = "App Review", + icon = R.drawable.ic_menu_ps_app + ), + KeyboardFeatureModel( + id = "game_review", + text = "Game Review", + icon = R.drawable.ic_menu_ps_game + ), + KeyboardFeatureModel( + id = "love_emoji", + text = "Love Emoji", + icon = R.drawable.ic_menu_ps_love + ) + ), + getToggleState = { it == "auto_text" || it == "game_review" }, + onToggleChanged = { _, _ -> }, + onBackClick = {} + ) + } +} + +@Preview(name = "Dark Mode", showBackground = true) +@Composable +fun ToggleScreenDarkPreview() { + FrogoKeyboardTheme(darkTheme = true) { + ToggleScreen( + features = listOf( + KeyboardFeatureModel( + id = "auto_text", + text = "Auto Text", + icon = R.drawable.ic_menu_auto_text + ), + KeyboardFeatureModel( + id = "app_review", + text = "App Review", + icon = R.drawable.ic_menu_ps_app + ), + KeyboardFeatureModel( + id = "game_review", + text = "Game Review", + icon = R.drawable.ic_menu_ps_game + ), + KeyboardFeatureModel( + id = "love_emoji", + text = "Love Emoji", + icon = R.drawable.ic_menu_ps_love + ) + ), + getToggleState = { it == "auto_text" || it == "game_review" }, + onToggleChanged = { _, _ -> }, + onBackClick = {} + ) + } +} diff --git a/app/src/main/res/layout/item_keyboard_header.xml b/app/src/main/res/layout/item_keyboard_header.xml index 2570902..164282d 100644 --- a/app/src/main/res/layout/item_keyboard_header.xml +++ b/app/src/main/res/layout/item_keyboard_header.xml @@ -12,8 +12,7 @@ android:id="@+id/iv_icon" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:src="@drawable/ic_menu_news" - app:tint="@color/tv_text" /> + android:src="@drawable/ic_menu_news" /> + runCatching { ThemeType.valueOf(typeStr) }.getOrDefault(ThemeType.COLOR) + } + + assertEquals(ThemeType.COLOR, safeParse("COLOR")) + assertEquals(ThemeType.IMAGE, safeParse("IMAGE")) + assertEquals(ThemeType.COLOR, safeParse("CORRUPTED_VALUE")) + assertEquals(ThemeType.COLOR, safeParse("")) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2ce52ef..1369c39 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,8 +15,10 @@ frogoconsumeapi = "3.0.0" composeBom = "2026.09.00" activityCompose = "1.13.0" +junit = "4.13.2" [libraries] +junit = { group = "junit", name = "junit", version.ref = "junit" } 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-emoji2 = { group = "androidx.emoji2", name = "emoji2-bundled", version.ref = "emoji2"} @@ -30,6 +32,7 @@ androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graph androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "liveCycle" } From 296b1bec5f2916320ead28e39dc57d0765343d00 Mon Sep 17 00:00:00 2001 From: amirisback Date: Sat, 19 Sep 2026 21:05:04 +0700 Subject: [PATCH 6/7] update: UI --- .../appkeyboard/model/KeyboardThemeType.kt | 17 +- .../appkeyboard/services/KeyboardUtil.kt | 9 +- .../ui/autotext/AutoTextDetailScreen.kt | 341 +++++- .../ui/autotext/AutoTextEditorScreen.kt | 337 +++++- .../appkeyboard/ui/autotext/AutoTextScreen.kt | 438 +++++++- .../ui/keyboard/autotext/AutoTextKeyboard.kt | 37 +- .../ui/language/KeyboardLanguage.kt | 4 + .../ui/language/KeyboardLanguageActivity.kt | 1 + .../ui/language/KeyboardLanguageScreen.kt | 729 +++++++++++- .../ui/language/KeyboardLanguageViewModel.kt | 115 +- .../appkeyboard/ui/test/TestActivity.kt | 82 +- .../appkeyboard/ui/test/TestScreen.kt | 750 ++++++++++-- .../appkeyboard/ui/test/TestViewModel.kt | 116 +- .../ui/test/TypingMetricsCalculator.kt | 47 + .../appkeyboard/ui/theme/ThemeScreen.kt | 1001 +++++++++++++++-- .../res/layout/item_keyboard_autotext.xml | 30 +- app/src/main/res/layout/keyboard_autotext.xml | 56 +- app/src/main/res/values-night/color.xml | 9 + app/src/main/res/values/color.xml | 13 +- .../com/frogobox/appkeyboard/AutoTextTest.kt | 95 ++ .../appkeyboard/KeyboardLanguageTest.kt | 181 +++ .../com/frogobox/appkeyboard/ThemeTest.kt | 17 +- .../frogobox/appkeyboard/TypingMetricsTest.kt | 80 ++ gradle.properties | 9 +- 24 files changed, 4043 insertions(+), 471 deletions(-) create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/test/TypingMetricsCalculator.kt create mode 100644 app/src/test/java/com/frogobox/appkeyboard/AutoTextTest.kt create mode 100644 app/src/test/java/com/frogobox/appkeyboard/KeyboardLanguageTest.kt create mode 100644 app/src/test/java/com/frogobox/appkeyboard/TypingMetricsTest.kt diff --git a/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardThemeType.kt b/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardThemeType.kt index c65373c..8143679 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardThemeType.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardThemeType.kt @@ -21,12 +21,17 @@ enum class KeyboardThemeType( private val themeType: ThemeType, private val background: Int, ) { - DEFAULT("Default", "Default Color", ThemeType.COLOR, R.color.color_bg_keyboard_default), - RED("Red", "Red Color", ThemeType.COLOR, R.color.color_bg_keyboard_red), - GREEN("Green", "Green Color", ThemeType.COLOR, R.color.color_bg_keyboard_green), - YELLOW("Yellow", "Yellow Color", ThemeType.COLOR, R.color.color_bg_keyboard_yellow), - BLUE("Blue", "Blue Color", ThemeType.COLOR, R.color.color_bg_keyboard_blue), - IMAGE_BG_DARK("Image", "Sample Wallpaper", ThemeType.IMAGE, R.drawable.ic_wallpaper_dummy); + DEFAULT("Default", "Classic Adaptive", ThemeType.COLOR, R.color.color_bg_keyboard_default), + PURPLE("Frogo Purple", "Signature Brand", ThemeType.COLOR, R.color.color_bg_keyboard_purple), + DARK("Midnight AMOLED", "Deep OLED Black", ThemeType.COLOR, R.color.color_bg_keyboard_dark), + BLUE("Ocean Blue", "Calm & Focused", ThemeType.COLOR, R.color.color_bg_keyboard_blue), + GREEN("Forest Emerald", "Natural Harmony", ThemeType.COLOR, R.color.color_bg_keyboard_green), + RED("Crimson Sunset", "Vibrant Warmth", ThemeType.COLOR, R.color.color_bg_keyboard_red), + ORANGE("Sunset Orange", "Energetic Twilight", ThemeType.COLOR, R.color.color_bg_keyboard_orange), + CYAN("Nordic Cyan", "Fresh & Clean", ThemeType.COLOR, R.color.color_bg_keyboard_cyan), + PINK("Sakura Pink", "Aesthetic Pastel", ThemeType.COLOR, R.color.color_bg_keyboard_pink), + YELLOW("Amber Gold", "Golden Accent", ThemeType.COLOR, R.color.color_bg_keyboard_yellow), + IMAGE_BG_DARK("Wallpaper", "Sample Artwork", ThemeType.IMAGE, R.drawable.ic_wallpaper_dummy); fun mapToModel(): KeyboardThemeModel { return KeyboardThemeModel( 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 bc97531..a42e0d2 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardUtil.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardUtil.kt @@ -55,14 +55,7 @@ class KeyboardUtil @Inject constructor( } fun keyboardTheme(): List { - return listOf( - KeyboardThemeType.DEFAULT.mapToModel(), - KeyboardThemeType.RED.mapToModel(), - KeyboardThemeType.GREEN.mapToModel(), - KeyboardThemeType.YELLOW.mapToModel(), - KeyboardThemeType.BLUE.mapToModel(), - KeyboardThemeType.IMAGE_BG_DARK.mapToModel() - ) + return KeyboardThemeType.entries.map { it.mapToModel() } } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailScreen.kt index 8795c05..7b47779 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailScreen.kt @@ -1,5 +1,12 @@ package com.frogobox.appkeyboard.ui.autotext +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.widget.Toast +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 @@ -9,17 +16,29 @@ 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.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ShortText +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Share import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults 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.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -27,8 +46,11 @@ 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.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -49,6 +71,29 @@ fun AutoTextDetailScreen( ) { var showDeleteDialog by remember { mutableStateOf(false) } val scrollState = rememberScrollState() + val context = LocalContext.current + + val wordCount = remember(data.body) { + if (data.body.isBlank()) 0 + else data.body.trim().split(Regex("\\s+")).count { it.isNotEmpty() } + } + + val onCopySnippet = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(data.title, data.body) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Copied \"${data.title}\" to clipboard", Toast.LENGTH_SHORT).show() + } + + val onShareSnippet = { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, data.body) + type = "text/plain" + } + val shareIntent = Intent.createChooser(sendIntent, "Share Auto Text Snippet") + context.startActivity(shareIntent) + } Scaffold( modifier = modifier.fillMaxSize(), @@ -59,96 +104,248 @@ fun AutoTextDetailScreen( ) }, bottomBar = { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp) + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp, + shadowElevation = 8.dp ) { - // Edit Button - Button( - onClick = onEditClick, + Row( modifier = Modifier - .weight(1f) - .height(52.dp), - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.buttonColors( - containerColor = FrogoPrimary - ) + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Text( - text = "Edit", - style = MaterialTheme.typography.labelLarge.copy( - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold + // Delete Button + OutlinedButton( + onClick = { showDeleteDialog = true }, + modifier = Modifier + .weight(1f) + .height(52.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = FrogoStatusFailed ), - color = Color.White - ) - } + border = BorderStroke(1.dp, FrogoStatusFailed.copy(alpha = 0.4f)) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Delete", + style = MaterialTheme.typography.labelLarge.copy( + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ), + color = FrogoStatusFailed + ) + } - // Delete Button - OutlinedButton( - onClick = { showDeleteDialog = true }, - modifier = Modifier - .weight(1f) - .height(52.dp), - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = FrogoStatusFailed - ) - ) { - Text( - text = "Delete", - style = MaterialTheme.typography.labelLarge.copy( - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold - ), - color = FrogoStatusFailed - ) + // Edit Button + Button( + onClick = onEditClick, + modifier = Modifier + .weight(1f) + .height(52.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = FrogoPrimary + ) + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit", + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Edit", + style = MaterialTheme.typography.labelLarge.copy( + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ), + color = Color.White + ) + } } } }, containerColor = MaterialTheme.colorScheme.background ) { paddingValues -> - Box( + Column( modifier = Modifier .fillMaxSize() .padding(paddingValues) - .padding(horizontal = 16.dp, vertical = 8.dp) + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { + // Header Hero Card Card( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState), - shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface - ) + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) ) { Column( modifier = Modifier .fillMaxWidth() .padding(20.dp) ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(12.dp)) + .background(FrogoPrimary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ShortText, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(24.dp) + ) + } + + IconButton(onClick = onShareSnippet) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(14.dp)) + Text( text = data.title, style = MaterialTheme.typography.titleLarge.copy( fontWeight = FontWeight.Bold, - fontSize = 20.sp + fontSize = 22.sp ), color = MaterialTheme.colorScheme.onSurface ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(10.dp)) - Text( - text = data.body, - style = MaterialTheme.typography.bodyLarge.copy( - lineHeight = 24.sp + // Metadata Row + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + ) { + Text( + text = "${data.body.length} characters", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + ) { + Text( + text = "$wordCount words", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Primary Copy Button + Button( + onClick = onCopySnippet, + modifier = Modifier + .fillMaxWidth() + .height(46.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = FrogoPrimary.copy(alpha = 0.12f), + contentColor = FrogoPrimary ), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + elevation = ButtonDefaults.buttonElevation(0.dp, 0.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy text", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Copy to Clipboard", + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + ) + } + } + } + + // Snippet Content Card + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "EXPANDED CONTENT", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) + ) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + Text( + text = data.body, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = 15.sp, + lineHeight = 24.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + } } } } @@ -157,27 +354,47 @@ fun AutoTextDetailScreen( if (showDeleteDialog) { AlertDialog( onDismissRequest = { showDeleteDialog = false }, + icon = { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(FrogoStatusFailed.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = FrogoStatusFailed, + modifier = Modifier.size(24.dp) + ) + } + }, title = { Text( - text = "Delete Auto Text", + text = "Delete Template?", style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.Bold ) ) }, text = { - Text("Are you sure you want to delete this auto text item?") + Text("Are you sure you want to delete \"${data.title}\"? This action cannot be undone.") }, confirmButton = { - TextButton( + Button( onClick = { showDeleteDialog = false onDeleteClick() - } + }, + colors = ButtonDefaults.buttonColors( + containerColor = FrogoStatusFailed + ), + shape = RoundedCornerShape(10.dp) ) { Text( text = "Delete", - color = FrogoStatusFailed, + color = Color.White, fontWeight = FontWeight.Bold ) } @@ -202,7 +419,7 @@ fun AutoTextDetailScreenPreview() { data = AutoTextEntity( id = 1, title = "Greeting Message", - body = "Hello! Thanks for reaching out. How can I assist you today?" + body = "Hello! Thanks for reaching out. How can I assist you with your project today?" ), onEditClick = {}, onDeleteClick = {}, diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorScreen.kt index cf403ab..959214f 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorScreen.kt @@ -1,32 +1,54 @@ package com.frogobox.appkeyboard.ui.autotext +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.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.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.automirrored.filled.ShortText +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Lightbulb import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +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.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold +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.text.input.KeyboardCapitalization +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 @@ -52,39 +74,52 @@ fun AutoTextEditorScreen( modifier = modifier.fillMaxSize(), topBar = { FrogoTopAppBar( - title = "Editor Auto Text", + title = if (isEditMode) "Edit Auto Text" else "New Auto Text", onBackClick = onBackClick ) }, bottomBar = { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp, + shadowElevation = 8.dp ) { - Button( - onClick = { - if (isFormValid) { - onSave(title.trim(), body.trim()) - } - }, + Box( modifier = Modifier .fillMaxWidth() - .height(52.dp), - shape = RoundedCornerShape(16.dp), - enabled = isFormValid, - colors = ButtonDefaults.buttonColors( - containerColor = FrogoPrimary - ) + .padding(16.dp) ) { - Text( - text = if (isEditMode) "Update" else "Save", - style = MaterialTheme.typography.labelLarge.copy( - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold - ), - color = Color.White - ) + Button( + onClick = { + if (isFormValid) { + onSave(title.trim(), body.trim()) + } + }, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(16.dp), + enabled = isFormValid, + colors = ButtonDefaults.buttonColors( + containerColor = FrogoPrimary + ) + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (isEditMode) "Update Template" else "Save Template", + style = MaterialTheme.typography.labelLarge.copy( + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ), + color = Color.White + ) + } } } }, @@ -95,15 +130,49 @@ fun AutoTextEditorScreen( .fillMaxSize() .padding(paddingValues) .verticalScroll(scrollState) - .padding(horizontal = 16.dp, vertical = 8.dp) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { + // Live Keyboard Preview Section + LiveKeyboardPreviewCard( + title = title, + body = body + ) + // Title Input OutlinedTextField( value = title, - onValueChange = { title = it }, + onValueChange = { if (it.length <= 50) title = it }, modifier = Modifier.fillMaxWidth(), - label = { Text("Title") }, - placeholder = { Text("Enter template title") }, + label = { Text("Shortcut Title") }, + placeholder = { Text("e.g. Greeting, BCA Rekening, Email") }, + leadingIcon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.ShortText, + contentDescription = null, + tint = FrogoPrimary + ) + }, + trailingIcon = { + if (title.isNotEmpty()) { + IconButton(onClick = { title = "" }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear Title", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + supportingText = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Appears in keyboard quick bar") + Text("${title.length} / 50") + } + }, singleLine = true, shape = RoundedCornerShape(16.dp), keyboardOptions = KeyboardOptions( @@ -111,19 +180,46 @@ fun AutoTextEditorScreen( ), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = FrogoPrimary, - focusedLabelColor = FrogoPrimary + focusedLabelColor = FrogoPrimary, + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface ) ) - Spacer(modifier = Modifier.height(16.dp)) - - // Body / Content Input + // Content / Body Input OutlinedTextField( value = body, onValueChange = { body = it }, modifier = Modifier.fillMaxWidth(), - label = { Text("Content") }, - placeholder = { Text("Enter auto text body...") }, + label = { Text("Template Content") }, + placeholder = { Text("Enter the full text that will be inserted when tapped...") }, + leadingIcon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.Notes, + contentDescription = null, + tint = FrogoPrimary + ) + }, + trailingIcon = { + if (body.isNotEmpty()) { + IconButton(onClick = { body = "" }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear Content", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + supportingText = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Will be committed to input field") + Text("${body.length} characters") + } + }, minLines = 6, shape = RoundedCornerShape(16.dp), keyboardOptions = KeyboardOptions( @@ -131,11 +227,178 @@ fun AutoTextEditorScreen( ), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = FrogoPrimary, - focusedLabelColor = FrogoPrimary + focusedLabelColor = FrogoPrimary, + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface ) ) - Spacer(modifier = Modifier.height(24.dp)) + // Helper Guidance Card + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = FrogoPrimary.copy(alpha = 0.07f) + ), + border = BorderStroke(1.dp, FrogoPrimary.copy(alpha = 0.20f)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.Top + ) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(FrogoPrimary.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Lightbulb, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(18.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Text( + text = "Keyboard Tip", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold + ), + color = FrogoPrimary + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Short, distinct titles make it effortless to select snippets directly from the Frogo Keyboard while chatting or filling forms.", + style = MaterialTheme.typography.bodySmall.copy( + lineHeight = 16.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun LiveKeyboardPreviewCard( + title: String, + body: String, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Keyboard, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "LIVE KEYBOARD PREVIEW", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + fontSize = 11.sp + ), + color = FrogoPrimary + ) + } + + Surface( + shape = RoundedCornerShape(6.dp), + color = FrogoPrimary.copy(alpha = 0.12f) + ) { + Text( + text = "Interactive", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 10.sp, + color = FrogoPrimary + ), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Simulated Keyboard Item Box + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(12.dp) + ) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp) + ) { + Text( + text = if (title.isBlank()) "Template Title" else title, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ), + color = if (title.isBlank()) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = if (body.isBlank()) "Expanded text preview will appear here..." else body, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp + ), + color = if (body.isBlank()) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } } } } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextScreen.kt index 8e010de..8da8755 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextScreen.kt @@ -1,30 +1,59 @@ package com.frogobox.appkeyboard.ui.autotext +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.BorderStroke +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.PaddingValues +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.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.automirrored.filled.ShortText +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SearchOff import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton 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.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -45,6 +74,27 @@ fun AutoTextScreen( onBackClick: () -> Unit, modifier: Modifier = Modifier ) { + var searchQuery by remember { mutableStateOf("") } + val context = LocalContext.current + + val filteredList = remember(autoTextList, searchQuery) { + if (searchQuery.isBlank()) { + autoTextList + } else { + autoTextList.filter { + it.title.contains(searchQuery, ignoreCase = true) || + it.body.contains(searchQuery, ignoreCase = true) + } + } + } + + val onCopySnippet: (AutoTextEntity) -> Unit = { item -> + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(item.title, item.body) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Copied \"${item.title}\" to clipboard", Toast.LENGTH_SHORT).show() + } + Scaffold( modifier = modifier.fillMaxSize(), topBar = { @@ -53,69 +103,219 @@ fun AutoTextScreen( onBackClick = onBackClick ) }, - bottomBar = { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Button( - onClick = onAddClick, - modifier = Modifier - .fillMaxWidth() - .height(52.dp), - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.buttonColors( - containerColor = FrogoPrimary + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = onAddClick, + containerColor = FrogoPrimary, + contentColor = Color.White, + shape = RoundedCornerShape(16.dp), + icon = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add Template" ) - ) { + }, + text = { Text( - text = "Add Auto Text", + text = "Add Template", style = MaterialTheme.typography.labelLarge.copy( - fontSize = 15.sp, + fontSize = 14.sp, fontWeight = FontWeight.SemiBold - ), - color = Color.White + ) ) } - } + ) }, containerColor = MaterialTheme.colorScheme.background ) { paddingValues -> - Box( + Column( modifier = Modifier .fillMaxSize() .padding(paddingValues) ) { - when { - isLoading && autoTextList.isEmpty() -> { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), - color = FrogoPrimary + // Header Overview Banner & Search Bar + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + AutoTextHeaderBanner( + totalCount = autoTextList.size, + filteredCount = filteredList.size, + isSearching = searchQuery.isNotBlank() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Modern Search Field + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = "Search templates by title or content...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = FrogoPrimary + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = FrogoPrimary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface ) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f) + ) { + when { + isLoading && autoTextList.isEmpty() -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = FrogoPrimary + ) + } + autoTextList.isEmpty() -> { + FrogoEmptyView( + title = "NO AUTO TEXT FOUND", + subtitle = "Tap 'Add Template' below to create a quick template.", + modifier = Modifier.align(Alignment.Center) + ) + } + filteredList.isEmpty() -> { + EmptySearchState( + query = searchQuery, + onClearSearch = { searchQuery = "" }, + modifier = Modifier.align(Alignment.Center) + ) + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 4.dp, + bottom = 88.dp // Space for Extended FAB + ), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(filteredList, key = { it.id }) { item -> + AutoTextCard( + item = item, + onClick = { onItemClick(item) }, + onCopy = { onCopySnippet(item) } + ) + } + } + } } - autoTextList.isEmpty() -> { - FrogoEmptyView( - title = "NO AUTO TEXT FOUND", - subtitle = "Tap 'Add Auto Text' below to create a quick template.", - modifier = Modifier.align(Alignment.Center) + } + } + } +} + +@Composable +private fun AutoTextHeaderBanner( + totalCount: Int, + filteredCount: Int, + isSearching: Boolean, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = FrogoPrimary.copy(alpha = 0.08f) + ), + border = BorderStroke(1.dp, FrogoPrimary.copy(alpha = 0.22f)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(FrogoPrimary.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ShortText, + contentDescription = "Templates Icon", + tint = FrogoPrimary, + modifier = Modifier.size(22.dp) ) } - else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(autoTextList, key = { it.id }) { item -> - AutoTextCard( - item = item, - onClick = { onItemClick(item) } - ) - } - } + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Text( + text = "Quick Templates", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 15.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Instant shortcuts on your keyboard", + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } + + Surface( + shape = RoundedCornerShape(10.dp), + color = FrogoPrimary.copy(alpha = 0.16f) + ) { + Text( + text = if (isSearching) "$filteredCount / $totalCount" else "$totalCount Saved", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + color = FrogoPrimary + ), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp) + ) + } } } } @@ -124,6 +324,7 @@ fun AutoTextScreen( private fun AutoTextCard( item: AutoTextEntity, onClick: () -> Unit, + onCopy: () -> Unit, modifier: Modifier = Modifier ) { Card( @@ -134,30 +335,143 @@ private fun AutoTextCard( elevation = CardDefaults.cardElevation(defaultElevation = 2.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) ) { - Text( - text = item.title, - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - fontSize = 16.sp - ), - color = MaterialTheme.colorScheme.onSurface + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(FrogoPrimary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Notes, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + IconButton( + onClick = onCopy, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy text", + tint = FrogoPrimary, + modifier = Modifier.size(18.dp) + ) + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + // Styled Snippet Container + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = item.body, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 13.sp, + lineHeight = 18.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +private fun EmptySearchState( + query: String, + onClearSearch: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.SearchOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(32.dp) ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "No templates found", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "No template matches \"$query\"", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) - Spacer(modifier = Modifier.height(6.dp)) + Spacer(modifier = Modifier.height(12.dp)) + TextButton(onClick = onClearSearch) { Text( - text = item.body, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis + text = "Clear Search", + color = FrogoPrimary, + fontWeight = FontWeight.SemiBold ) } } @@ -169,8 +483,8 @@ fun AutoTextScreenPreview() { FrogoKeyboardTheme(darkTheme = false) { AutoTextScreen( autoTextList = listOf( - AutoTextEntity(id = 1, title = "Greeting", body = "Hello, good morning!"), - AutoTextEntity(id = 2, title = "Account Number", body = "BCA: 1234567890 a/n Faisal") + AutoTextEntity(id = 1, title = "Greeting", body = "Hello, good morning! How can I help you today?"), + AutoTextEntity(id = 2, title = "Account Number", body = "BCA: 1234567890 a/n Faisal Amir") ), isLoading = false, onItemClick = {}, diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboard.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboard.kt index c91974d..1cd69ef 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboard.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboard.kt @@ -1,13 +1,16 @@ package com.frogobox.appkeyboard.ui.keyboard.autotext import android.content.Context +import android.content.Intent import android.util.AttributeSet import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import android.widget.LinearLayout -import com.frogobox.appkeyboard.databinding.ItemKeyboardNewsBinding +import com.frogobox.appkeyboard.databinding.ItemKeyboardAutotextBinding import com.frogobox.appkeyboard.databinding.KeyboardAutotextBinding import com.frogobox.appkeyboard.model.AutoTextEntity +import com.frogobox.appkeyboard.ui.autotext.AutoTextActivity import com.frogobox.libkeyboard.common.core.BaseKeyboard import com.frogobox.recycler.core.FrogoRecyclerNotifyListener import com.frogobox.recycler.core.IFrogoBindingAdapter @@ -31,6 +34,12 @@ class AutoTextKeyboard( super.initUI() binding.apply { tvToolbarTitle.text = "Auto Text" + toolbarManage.setOnClickListener { + val intent = Intent(context, AutoTextActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + context.startActivity(intent) + } } } @@ -44,21 +53,27 @@ class AutoTextKeyboard( private fun setupRv(data: List) { binding.apply { + if (data.isEmpty()) { + llEmptyState.visibility = View.VISIBLE + rvKeyboardMain.visibility = View.GONE + } else { + llEmptyState.visibility = View.GONE + rvKeyboardMain.visibility = View.VISIBLE + } - val adapterCallback = object : IFrogoBindingAdapter { + val adapterCallback = object : IFrogoBindingAdapter { override fun onItemClicked( - binding: ItemKeyboardNewsBinding, + binding: ItemKeyboardAutotextBinding, data: AutoTextEntity, position: Int, notifyListener: FrogoRecyclerNotifyListener, ) { - // Your Clicked val output = data.body currentInputConnection?.commitText(output, 1) } override fun onItemLongClicked( - binding: ItemKeyboardNewsBinding, + binding: ItemKeyboardAutotextBinding, data: AutoTextEntity, position: Int, notifyListener: FrogoRecyclerNotifyListener, @@ -79,26 +94,28 @@ class AutoTextKeyboard( return oldItem.id == newItem.id } - override fun setViewBinding(parent: ViewGroup): ItemKeyboardNewsBinding { - return ItemKeyboardNewsBinding.inflate( + override fun setViewBinding(parent: ViewGroup): ItemKeyboardAutotextBinding { + return ItemKeyboardAutotextBinding.inflate( LayoutInflater.from(context), parent, - false) + false + ) } override fun setupInitComponent( - binding: ItemKeyboardNewsBinding, + binding: ItemKeyboardAutotextBinding, data: AutoTextEntity, position: Int, notifyListener: FrogoRecyclerNotifyListener, ) { binding.apply { tvItemKeyboardMain.text = data.title + tvItemKeyboardBody.text = data.body } } } - rvKeyboardMain.injectorBinding() + rvKeyboardMain.injectorBinding() .addData(data) .createLayoutLinearVertical(false) .addCallback(adapterCallback) diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguage.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguage.kt index 0afb5ba..9804d69 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguage.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguage.kt @@ -14,4 +14,8 @@ package com.frogobox.appkeyboard.ui.language data class KeyboardLanguage( val name: String, val xml: Int, + val layoutType: String = "Standard", + val code: String = "EN", + val script: String = "Latin", + val isRtl: Boolean = false ) \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageActivity.kt index 9912d6a..f5fc81a 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageActivity.kt @@ -32,6 +32,7 @@ class KeyboardLanguageActivity : BaseComposeActivity() { override fun Content() { KeyboardLanguageScreen( languageList = languageList, + activeLanguageXml = activeLanguageXml, checkIsSelected = { xml -> xml == activeLanguageXml }, onApplyLanguage = { language -> viewModel.setKeyboard(language.xml) { diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageScreen.kt index a3e785c..2daf85d 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageScreen.kt @@ -1,26 +1,42 @@ package com.frogobox.appkeyboard.ui.language +import androidx.compose.foundation.BorderStroke +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.PaddingValues 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults 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.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -30,13 +46,16 @@ 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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign 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 com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusSuccess import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar @Composable @@ -45,10 +64,36 @@ fun KeyboardLanguageScreen( checkIsSelected: (Int) -> Boolean, onApplyLanguage: (KeyboardLanguage) -> Unit, onBackClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + activeLanguageXml: Int = 0 ) { + var searchQuery by remember { mutableStateOf("") } var selectedLanguageForDialog by remember { mutableStateOf(null) } + // Resolve active language object + val activeLanguage = remember(activeLanguageXml, languageList) { + if (activeLanguageXml != 0) { + languageList.firstOrNull { it.xml == activeLanguageXml } + } else { + languageList.firstOrNull { checkIsSelected(it.xml) } + } + } + + // Filter languages reactively based on query + val filteredLanguages = remember(searchQuery, languageList) { + if (searchQuery.isBlank()) { + languageList + } else { + val query = searchQuery.trim().lowercase() + languageList.filter { + it.name.lowercase().contains(query) || + it.code.lowercase().contains(query) || + it.layoutType.lowercase().contains(query) || + it.script.lowercase().contains(query) + } + } + } + Scaffold( modifier = modifier.fillMaxSize(), topBar = { @@ -63,46 +108,163 @@ fun KeyboardLanguageScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues), - contentPadding = PaddingValues(16.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - items(languageList, key = { it.name + it.xml }) { language -> - LanguageCard( - language = language, - isSelected = checkIsSelected(language.xml), - onClick = { - selectedLanguageForDialog = language - } + // 1. Active Language Hero Card (Only shown when not searching, or when active matches query) + if (activeLanguage != null && searchQuery.isBlank()) { + item(key = "active_hero_card") { + ActiveLanguageHeroCard( + language = activeLanguage + ) + } + } + + // 2. Search Field & Results Header + item(key = "search_header") { + SearchAndFilterHeader( + query = searchQuery, + onQueryChange = { searchQuery = it }, + totalLanguages = languageList.size, + filteredCount = filteredLanguages.size ) } + + // 3. Empty Search State + if (filteredLanguages.isEmpty()) { + item(key = "empty_state") { + EmptySearchState( + query = searchQuery, + onClearSearch = { searchQuery = "" } + ) + } + } else { + // 4. Filtered / Full Language Cards List + items( + items = filteredLanguages, + key = { it.name + it.xml } + ) { language -> + val isSelected = if (activeLanguageXml != 0) { + language.xml == activeLanguageXml + } else { + checkIsSelected(language.xml) + } + + LanguageCard( + language = language, + isSelected = isSelected, + onClick = { + selectedLanguageForDialog = language + } + ) + } + } } } + // Confirmation Dialog selectedLanguageForDialog?.let { language -> AlertDialog( onDismissRequest = { selectedLanguageForDialog = null }, title = { - Text( - text = "Change Keyboard Layout", - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(FrogoPrimary.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Language, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(20.dp) + ) + } + Text( + text = "Change Keyboard Layout", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold + ) ) - ) + } }, text = { - Text("Are you sure you want to change keyboard layout to ${language.name}?") + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Are you sure you want to switch your active typing layout?", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Selected Language Preview Box + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(FrogoPrimary.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center + ) { + Text( + text = language.code, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + color = FrogoPrimary + ) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = language.name, + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "${language.layoutType} Layout • ${language.script}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } }, confirmButton = { - TextButton( + Button( onClick = { val current = language selectedLanguageForDialog = null onApplyLanguage(current) - } + }, + colors = ButtonDefaults.buttonColors( + containerColor = FrogoPrimary + ), + shape = RoundedCornerShape(10.dp) ) { Text( - text = "Apply", - color = FrogoPrimary, + text = "Apply Layout", fontWeight = FontWeight.Bold ) } @@ -114,11 +276,251 @@ fun KeyboardLanguageScreen( Text("Cancel") } }, - shape = RoundedCornerShape(16.dp) + shape = RoundedCornerShape(18.dp) + ) + } +} + +/** + * Overload function for backward compatibility and clean call sites. + */ +@Composable +fun KeyboardLanguageScreen( + languageList: List, + activeLanguageXml: Int, + onApplyLanguage: (KeyboardLanguage) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + KeyboardLanguageScreen( + languageList = languageList, + checkIsSelected = { it == activeLanguageXml }, + onApplyLanguage = onApplyLanguage, + onBackClick = onBackClick, + modifier = modifier, + activeLanguageXml = activeLanguageXml + ) +} + +/** + * Prominent Hero Card displaying the currently active keyboard language. + */ +@Composable +private fun ActiveLanguageHeroCard( + language: KeyboardLanguage, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors( + containerColor = FrogoPrimary.copy(alpha = 0.08f) + ), + border = BorderStroke(1.5.dp, FrogoPrimary.copy(alpha = 0.35f)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Header: Badge Pill + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = FrogoStatusSuccess.copy(alpha = 0.15f) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background(FrogoStatusSuccess) + ) + Text( + text = "CURRENT ACTIVE", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + color = FrogoStatusSuccess + ) + ) + } + } + + Text( + text = "Default Input Method", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Main Content Row + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // Language Code Avatar + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(FrogoPrimary.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center + ) { + Text( + text = language.code, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + color = FrogoPrimary, + fontSize = 17.sp + ) + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + // Titles & Subtitles + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center + ) { + Text( + text = language.name, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 17.sp + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = "${language.layoutType} Layout • ${language.script}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Active Indicator", + tint = FrogoStatusSuccess, + modifier = Modifier.size(26.dp) + ) + } + } + } +} + +/** + * Modern Search Field with Result Statistics + */ +@Composable +private fun SearchAndFilterHeader( + query: String, + onQueryChange: (String) -> Unit, + totalLanguages: Int, + filteredCount: Int, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + placeholder = { + Text( + text = "Search language, code, or layout...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = FrogoPrimary, + modifier = Modifier.size(20.dp) + ) + }, + trailingIcon = { + if (query.isNotBlank()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Clear Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + } + }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = FrogoPrimary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface + ) ) + + // Count / Section Label + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = if (query.isBlank()) "Available Languages" else "Search Results", + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) { + Text( + text = if (query.isBlank()) "$totalLanguages available" else "$filteredCount found", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) + ) + } + } } } +/** + * Individual Language Item Card with Avatar Badge, Subtitles, and Selection State. + */ @Composable private fun LanguageCard( language: KeyboardLanguage, @@ -131,54 +533,299 @@ private fun LanguageCard( .fillMaxWidth() .clickable { onClick() }, shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + elevation = CardDefaults.cardElevation( + defaultElevation = if (isSelected) 3.dp else 1.dp + ), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = if (isSelected) { + FrogoPrimary.copy(alpha = 0.04f) + } else { + MaterialTheme.colorScheme.surface + } + ), + border = BorderStroke( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) { + FrogoPrimary + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f) + } ) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .padding(14.dp), verticalAlignment = Alignment.CenterVertically ) { - Text( - text = language.name, - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold, - fontSize = 15.sp - ), - color = MaterialTheme.colorScheme.onSurface, + // Circular Avatar Container with Language Code + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background( + if (isSelected) FrogoPrimary.copy(alpha = 0.18f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = language.code, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + color = if (isSelected) FrogoPrimary else MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + // Text Info Column + Column( modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = language.name, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Layout Badge Chip + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + ) { + Text( + text = language.layoutType, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + + // Script details + Text( + text = "• ${language.script}", + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // RTL Tag if applicable + if (language.isRtl) { + Surface( + shape = RoundedCornerShape(6.dp), + color = FrogoPrimary.copy(alpha = 0.1f) + ) { + Text( + text = "RTL", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 9.sp, + color = FrogoPrimary + ), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) + ) + } + } + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Trailing Status Indicator if (isSelected) { - Spacer(modifier = Modifier.width(12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = FrogoStatusSuccess.copy(alpha = 0.15f) + ) { + Text( + text = "ACTIVE", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + color = FrogoStatusSuccess + ), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp) + ) + } + + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Active Language", + tint = FrogoStatusSuccess, + modifier = Modifier.size(22.dp) + ) + } + } else { Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = "Active Language", - tint = FrogoPrimary, - modifier = Modifier.size(24.dp) + imageVector = Icons.Default.RadioButtonUnchecked, + contentDescription = "Select Language", + tint = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.size(20.dp) ) } } } } +/** + * Empty State when search query yields no matches. + */ +@Composable +private fun EmptySearchState( + query: String, + onClearSearch: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 40.dp, horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.outline + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "No Language Found", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = "We couldn't find any language or layout matching \"$query\".\nTry searching by code or layout type.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(18.dp)) + + TextButton( + onClick = onClearSearch, + colors = ButtonDefaults.textButtonColors( + contentColor = FrogoPrimary + ) + ) { + Text( + text = "Reset Search", + fontWeight = FontWeight.Bold + ) + } + } +} + @Preview(showBackground = true) @Composable fun KeyboardLanguageScreenPreview() { FrogoKeyboardTheme(darkTheme = false) { KeyboardLanguageScreen( languageList = listOf( - KeyboardLanguage("English (QWERTY)", 1), - KeyboardLanguage("German", 2) + KeyboardLanguage( + name = "English (QWERTY)", + xml = 1, + layoutType = "QWERTY", + code = "EN", + script = "Latin" + ), + KeyboardLanguage( + name = "German", + xml = 2, + layoutType = "QWERTZ", + code = "DE", + script = "Deutsch (Latin)" + ), + KeyboardLanguage( + name = "French", + xml = 3, + layoutType = "AZERTY", + code = "FR", + script = "Français (Latin)" + ), + KeyboardLanguage( + name = "Persian", + xml = 4, + layoutType = "Standard", + code = "FA", + script = "فارسی (Perso-Arabic)", + isRtl = true + ) + ), + checkIsSelected = { it == 1 }, + onApplyLanguage = {}, + onBackClick = {}, + activeLanguageXml = 1 + ) + } +} + +@Preview(showBackground = true) +@Composable +fun KeyboardLanguageScreenDarkPreview() { + FrogoKeyboardTheme(darkTheme = true) { + KeyboardLanguageScreen( + languageList = listOf( + KeyboardLanguage( + name = "English (QWERTY)", + xml = 1, + layoutType = "QWERTY", + code = "EN", + script = "Latin" + ), + KeyboardLanguage( + name = "Russian", + xml = 2, + layoutType = "ЙЦУКЕН", + code = "RU", + script = "Русский (Cyrillic)" + ) ), checkIsSelected = { it == 1 }, onApplyLanguage = {}, - onBackClick = {} + onBackClick = {}, + activeLanguageXml = 1 ) } } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageViewModel.kt index 3158b85..83e3506 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageViewModel.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageViewModel.kt @@ -36,88 +36,140 @@ class KeyboardLanguageViewModel @Inject constructor( val list = mutableListOf() list.add( KeyboardLanguage( - "${context.getString(com.frogobox.libkeyboard.R.string.translation_english)} (QWERTY)", - com.frogobox.libkeyboard.R.xml.keys_letters_qwerty + name = "${context.getString(com.frogobox.libkeyboard.R.string.translation_english)} (QWERTY)", + xml = com.frogobox.libkeyboard.R.xml.keys_letters_qwerty, + layoutType = "QWERTY", + code = "EN", + script = "Latin" ) ) list.add( KeyboardLanguage( - "${context.getString(com.frogobox.libkeyboard.R.string.translation_english)} (QWERTZ)", - com.frogobox.libkeyboard.R.xml.keys_letters_english_qwertz + name = "${context.getString(com.frogobox.libkeyboard.R.string.translation_english)} (QWERTZ)", + xml = com.frogobox.libkeyboard.R.xml.keys_letters_english_qwertz, + layoutType = "QWERTZ", + code = "EN", + script = "Latin" ) ) list.add( KeyboardLanguage( - "${context.getString(com.frogobox.libkeyboard.R.string.translation_english)} (DVORAK)", - com.frogobox.libkeyboard.R.xml.keys_letters_english_dvorak + name = "${context.getString(com.frogobox.libkeyboard.R.string.translation_english)} (DVORAK)", + xml = com.frogobox.libkeyboard.R.xml.keys_letters_english_dvorak, + layoutType = "DVORAK", + code = "EN", + script = "Latin" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_bengali), - com.frogobox.libkeyboard.R.xml.keys_letters_bengali + name = context.getString(com.frogobox.libkeyboard.R.string.translation_bengali), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_bengali, + layoutType = "Standard", + code = "BN", + script = "বাংলা (Bengali)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_bulgarian), - com.frogobox.libkeyboard.R.xml.keys_letters_bulgarian + name = context.getString(com.frogobox.libkeyboard.R.string.translation_bulgarian), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_bulgarian, + layoutType = "Standard", + code = "BG", + script = "Български (Cyrillic)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_french), - com.frogobox.libkeyboard.R.xml.keys_letters_french + name = context.getString(com.frogobox.libkeyboard.R.string.translation_french), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_french, + layoutType = "AZERTY", + code = "FR", + script = "Français (Latin)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_german), - com.frogobox.libkeyboard.R.xml.keys_letters_german + name = context.getString(com.frogobox.libkeyboard.R.string.translation_german), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_german, + layoutType = "QWERTZ", + code = "DE", + script = "Deutsch (Latin)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_greek), - com.frogobox.libkeyboard.R.xml.keys_letters_greek + name = context.getString(com.frogobox.libkeyboard.R.string.translation_greek), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_greek, + layoutType = "Standard", + code = "EL", + script = "Ελληνικά (Greek)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_lithuanian), - com.frogobox.libkeyboard.R.xml.keys_letters_lithuanian + name = context.getString(com.frogobox.libkeyboard.R.string.translation_lithuanian), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_lithuanian, + layoutType = "ĄŽERTY", + code = "LT", + script = "Lietuvių (Baltic)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_romanian), - com.frogobox.libkeyboard.R.xml.keys_letters_romanian + name = context.getString(com.frogobox.libkeyboard.R.string.translation_romanian), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_romanian, + layoutType = "QWERTY", + code = "RO", + script = "Română (Latin)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_slovenian), - com.frogobox.libkeyboard.R.xml.keys_letters_slovenian + name = context.getString(com.frogobox.libkeyboard.R.string.translation_russian), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_russian, + layoutType = "ЙЦУКЕН", + code = "RU", + script = "Русский (Cyrillic)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_spanish), - com.frogobox.libkeyboard.R.xml.keys_letters_spanish_qwerty + name = context.getString(com.frogobox.libkeyboard.R.string.translation_slovenian), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_slovenian, + layoutType = "QWERTZ", + code = "SL", + script = "Slovenščina (Latin)" ) ) list.add( KeyboardLanguage( - "${context.getString(com.frogobox.libkeyboard.R.string.translation_turkish)} (Q)", - com.frogobox.libkeyboard.R.xml.keys_letters_turkish_q + name = context.getString(com.frogobox.libkeyboard.R.string.translation_spanish), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_spanish_qwerty, + layoutType = "QWERTY", + code = "ES", + script = "Español (Latin)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_persian), - com.frogobox.libkeyboard.R.xml.keys_letter_persian + name = "${context.getString(com.frogobox.libkeyboard.R.string.translation_turkish)} (Q)", + xml = com.frogobox.libkeyboard.R.xml.keys_letters_turkish_q, + layoutType = "QWERTY", + code = "TR", + script = "Türkçe (Latin)" + ) + ) + list.add( + KeyboardLanguage( + name = context.getString(com.frogobox.libkeyboard.R.string.translation_persian), + xml = com.frogobox.libkeyboard.R.xml.keys_letter_persian, + layoutType = "Standard", + code = "FA", + script = "فارسی (Perso-Arabic)", + isRtl = true ) ) _keyboardLanguage.postValue(list) @@ -137,4 +189,11 @@ class KeyboardLanguageViewModel @Inject constructor( ) == xml } + fun getActiveKeyboardXml(): Int { + return pref.getPrefInt( + KeyboardUtil.KEYBOARD_TYPE, + com.frogobox.libkeyboard.R.xml.keys_letters_qwerty + ) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestActivity.kt index 4cfb17d..03f0348 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestActivity.kt @@ -1,9 +1,16 @@ package com.frogobox.appkeyboard.ui.test +import android.content.Intent import android.os.Bundle +import android.provider.Settings import android.util.Log +import android.view.inputmethod.InputMethodManager import androidx.activity.viewModels import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import com.frogobox.appkeyboard.common.base.BaseComposeActivity import dagger.hilt.android.AndroidEntryPoint @@ -16,31 +23,88 @@ class TestActivity : BaseComposeActivity() { private val viewModel: TestViewModel by viewModels() + private var isUsingFrogoKeyboard by mutableStateOf(false) + private var isFrogoKeyboardEnabled by mutableStateOf(false) + private val dummyOptions = listOf( "Kuningan", "Menteng", - "Menten213g", - "Men123teng", - "Mw23423", - "Me123nteng", - "Mente234234234ng", - "Pegangsaan" + "Pegangsaan", + "Kebon Jeruk", + "Kemang", + "Senayan", + "Sudirman", + "Thamrin" ) override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) if (savedInstanceState == null) { - // Call View Model Here - Log.d(TAG, "View Model : ${viewModel::class.java.simpleName}") + Log.d(TAG, "Initialized TestActivity with ViewModel: ${viewModel::class.java.simpleName}") + } + updateKeyboardStatus() + } + + override fun onResume() { + super.onResume() + updateKeyboardStatus() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + updateKeyboardStatus() } } + private fun isUsingKeyboard(): Boolean { + val currentKeyboard = Settings.Secure.getString(contentResolver, Settings.Secure.DEFAULT_INPUT_METHOD) + val targetKeyboard = "$packageName/com.frogobox.appkeyboard.services.KeyboardIME" + return currentKeyboard == targetKeyboard + } + + private fun isKeyboardEnabled(): Boolean { + val inputMethodManager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + val enabledKeyboards = inputMethodManager.enabledInputMethodList + return enabledKeyboards.any { + it.serviceInfo.packageName == packageName + } + } + + private fun updateKeyboardStatus() { + isUsingFrogoKeyboard = isUsingKeyboard() + isFrogoKeyboardEnabled = isKeyboardEnabled() + } + @Composable override fun Content() { + val sandboxText by viewModel.sandboxText.collectAsState() + val metrics by viewModel.metrics.collectAsState() + val activeTab by viewModel.activeTab.collectAsState() + val autoTextList by viewModel.autoTextList.collectAsState() + TestScreen( + sandboxText = sandboxText, + metrics = metrics, + activeTab = activeTab, + autoTextList = autoTextList, + isKeyboardActive = isUsingFrogoKeyboard, dummyOptions = dummyOptions, + onTextChanged = viewModel::onTextChanged, + onInsertText = viewModel::onInsertText, + onClearText = viewModel::onClearText, + onResetTimer = viewModel::onResetTimer, + onTabSelected = viewModel::onTabSelected, + onChangeKeyboard = { + (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker() + }, + onGoToSettings = { + val intent = Intent(Settings.ACTION_INPUT_METHOD_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + }, onBackClick = { finish() } ) } - } \ No newline at end of file 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 b265dec..7f82c6b 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 @@ -1,166 +1,667 @@ package com.frogobox.appkeyboard.ui.test +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +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.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab 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.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight -import androidx.compose.material3.Scaffold +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.model.AutoTextEntity import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusSuccess +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusWarning import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar /** - * TestScreen Composable - * Migrated from activity_test.xml - * Provides testing input fields (multiline text, numeric input, and auto-complete dropdown). + * Modern Material 3 Keyboard Testing Playground & Sandbox + * Redesigned for TASK-010 by Tim Mobile */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun TestScreen( + sandboxText: String, + metrics: TypingMetrics, + activeTab: Int, + autoTextList: List, + isKeyboardActive: Boolean, dummyOptions: List = emptyList(), + onTextChanged: (String) -> Unit, + onInsertText: (String) -> Unit, + onClearText: () -> Unit, + onResetTimer: () -> Unit, + onTabSelected: (Int) -> Unit, + onChangeKeyboard: () -> Unit, + onGoToSettings: () -> Unit, onBackClick: (() -> Unit)? = null, modifier: Modifier = Modifier ) { - var fullText by remember { mutableStateOf("") } - var numberText by remember { mutableStateOf("") } - var autoText by remember { mutableStateOf("") } + val context = LocalContext.current + val scrollState = rememberScrollState() + + // Local states for specialized input tab + var searchInput by remember { mutableStateOf("") } + var numberInput by remember { mutableStateOf("") } + var phoneInput by remember { mutableStateOf("") } + var passwordInput by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + var autoDropdownText by remember { mutableStateOf("") } var dropdownExpanded by remember { mutableStateOf(false) } - val filteredOptions = remember(autoText, dummyOptions) { - if (autoText.isBlank()) { + val filteredDropdownOptions = remember(autoDropdownText, dummyOptions) { + if (autoDropdownText.isBlank()) { dummyOptions } else { - dummyOptions.filter { it.contains(autoText, ignoreCase = true) } + dummyOptions.filter { it.contains(autoDropdownText, ignoreCase = true) } } } - val scrollState = rememberScrollState() + FrogoKeyboardTheme { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Test Area", + onBackClick = onBackClick + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(scrollState) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + // 1. Active Keyboard Status Card + KeyboardStatusCard( + isFrogoActive = isKeyboardActive, + onChangeKeyboard = onChangeKeyboard, + onGoToSettings = onGoToSettings + ) - Scaffold( - modifier = modifier.fillMaxSize(), - topBar = { - FrogoTopAppBar( - title = "Test Area", - onBackClick = onBackClick - ) - }, - containerColor = MaterialTheme.colorScheme.background - ) { paddingValues -> + // 2. Real-time Live Typing Metrics Card + LiveMetricsCard(metrics = metrics) + + // 3. Tab Navigation Row + PrimaryTabRow( + selectedTabIndex = activeTab, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = FrogoPrimary + ) { + Tab( + selected = activeTab == 0, + onClick = { onTabSelected(0) }, + text = { Text("Sandbox", fontWeight = FontWeight.SemiBold) }, + icon = { Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(20.dp)) } + ) + Tab( + selected = activeTab == 1, + onClick = { onTabSelected(1) }, + text = { Text("Inputs", fontWeight = FontWeight.SemiBold) }, + icon = { Icon(Icons.Default.Keyboard, contentDescription = null, modifier = Modifier.size(20.dp)) } + ) + Tab( + selected = activeTab == 2, + onClick = { onTabSelected(2) }, + text = { Text("AutoText", fontWeight = FontWeight.SemiBold) }, + icon = { Icon(Icons.AutoMirrored.Filled.Notes, contentDescription = null, modifier = Modifier.size(20.dp)) } + ) + } + + // 4. Tab Body Content + when (activeTab) { + 0 -> SandboxTabContent( + text = sandboxText, + onTextChanged = onTextChanged, + onInsertText = onInsertText, + onClearText = onClearText, + onResetTimer = onResetTimer, + onCopyText = { + if (sandboxText.isNotEmpty()) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Sandbox Text", sandboxText)) + Toast.makeText(context, "Text copied to clipboard", Toast.LENGTH_SHORT).show() + } + } + ) + 1 -> SpecializedInputsTabContent( + searchInput = searchInput, + onSearchChange = { searchInput = it }, + numberInput = numberInput, + onNumberChange = { numberInput = it }, + phoneInput = phoneInput, + onPhoneChange = { phoneInput = it }, + passwordInput = passwordInput, + onPasswordChange = { passwordInput = it }, + passwordVisible = passwordVisible, + onTogglePasswordVisibility = { passwordVisible = !passwordVisible }, + autoDropdownText = autoDropdownText, + onDropdownTextChange = { autoDropdownText = it }, + dropdownExpanded = dropdownExpanded, + onDropdownExpandedChange = { dropdownExpanded = it }, + dropdownOptions = filteredDropdownOptions + ) + 2 -> AutoTextTabContent( + snippets = autoTextList, + onSnippetClicked = { snippet -> + onInsertText(snippet.body) + Toast.makeText(context, "Inserted '${snippet.title}' to sandbox", Toast.LENGTH_SHORT).show() + onTabSelected(0) + } + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} + +/** + * 1. Active Keyboard Status Card Component + */ +@Composable +private fun KeyboardStatusCard( + isFrogoActive: Boolean, + onChangeKeyboard: () -> Unit, + onGoToSettings: () -> Unit, + modifier: Modifier = Modifier +) { + val statusColor = if (isFrogoActive) FrogoStatusSuccess else FrogoStatusWarning + val statusTitle = if (isFrogoActive) "Frogo Keyboard Active" else "Other Keyboard Active" + val statusDesc = if (isFrogoActive) { + "Frogo Keyboard is default. Testing real keyboard layouts." + } else { + "External keyboard is active. Switch to test Frogo features." + } + val icon = if (isFrogoActive) Icons.Default.CheckCircle else Icons.Default.Warning + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = statusColor.copy(alpha = 0.08f) + ), + border = BorderStroke(1.dp, statusColor.copy(alpha = 0.25f)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(statusColor.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = statusColor, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = statusTitle, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = statusDesc, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = onChangeKeyboard, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors(containerColor = FrogoPrimary) + ) { + Icon(Icons.Default.Keyboard, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Switch Keyboard", fontSize = 13.sp) + } + + OutlinedButton( + onClick = onGoToSettings, + shape = RoundedCornerShape(10.dp) + ) { + Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Settings", fontSize = 13.sp) + } + } + } + } +} + +/** + * 2. Real-time Live Typing Metrics Card Component + */ +@Composable +private fun LiveMetricsCard( + metrics: TypingMetrics, + modifier: Modifier = Modifier +) { + ElevatedCard( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp) + ) { Column( modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .verticalScroll(scrollState) - .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 16.dp) + .fillMaxWidth() + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Live Typing Analytics", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "${metrics.elapsedSeconds}s elapsed", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + MetricItem(label = "Characters", value = "${metrics.characterCount}", modifier = Modifier.weight(1f)) + MetricItem(label = "Words", value = "${metrics.wordCount}", modifier = Modifier.weight(1f)) + MetricItem(label = "Lines", value = "${metrics.lineCount}", modifier = Modifier.weight(1f)) + MetricItem( + label = "Typing Speed", + value = "${metrics.wordsPerMinute} WPM", + highlight = true, + modifier = Modifier.weight(1.2f) + ) + } + } + } +} + +@Composable +private fun MetricItem( + label: String, + value: String, + highlight: Boolean = false, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { Text( - text = "Let's Do Some Test", - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold, - fontSize = 20.sp - ), - color = MaterialTheme.colorScheme.onBackground + text = value, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.ExtraBold, + color = if (highlight) FrogoPrimary else MaterialTheme.colorScheme.onSurface + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) + } +} - Spacer(modifier = Modifier.height(16.dp)) +/** + * 3. Tab 0: Freeform Sandbox & Preset Pangrams + */ +@Composable +private fun SandboxTabContent( + text: String, + onTextChanged: (String) -> Unit, + onInsertText: (String) -> Unit, + onClearText: () -> Unit, + onResetTimer: () -> Unit, + onCopyText: () -> Unit, + modifier: Modifier = Modifier +) { + val pangramsScroll = rememberScrollState() - HorizontalDivider( - thickness = 2.dp, - color = MaterialTheme.colorScheme.outlineVariant + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Preset Pangrams horizontal chips + Text( + text = "Quick Presets & Pangrams", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold ) - Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(pangramsScroll), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + AssistChip( + onClick = { onInsertText("The quick brown fox jumps over the lazy dog") }, + label = { Text("English Pangram") } + ) + AssistChip( + onClick = { onInsertText("Keluarga Jefri bernomor fax tujuh belas") }, + label = { Text("Indonesian Pangram") } + ) + AssistChip( + onClick = { onInsertText("1234567890 !@#$%^&*()_+") }, + label = { Text("Numbers & Symbols") } + ) + AssistChip( + onClick = { onInsertText("😀🚀✨🎉🔥👍💯❤️") }, + label = { Text("Emoji Palette") } + ) + } - // Full Text (Multiline) + // Multiline Editor Area OutlinedTextField( - value = fullText, - onValueChange = { fullText = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Full Text") }, - placeholder = { Text("Full Text") }, - minLines = 3, + value = text, + onValueChange = onTextChanged, + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + placeholder = { Text("Type anywhere here with your keyboard to test metrics, latency, and keys...") }, shape = RoundedCornerShape(16.dp), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text) + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = FrogoPrimary, + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface + ) ) - Spacer(modifier = Modifier.height(16.dp)) + // Action Toolbar: Copy, Clear, Reset Timer + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onCopyText, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp) + ) { + Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Copy", fontSize = 13.sp) + } + + OutlinedButton( + onClick = onResetTimer, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp) + ) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Reset WPM", fontSize = 13.sp) + } + + Button( + onClick = onClearText, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Clear", fontSize = 13.sp) + } + } + } +} + +/** + * 4. Tab 1: Specialized Input Types (Search, Number, Phone, Password, AutoComplete) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SpecializedInputsTabContent( + searchInput: String, + onSearchChange: (String) -> Unit, + numberInput: String, + onNumberChange: (String) -> Unit, + phoneInput: String, + onPhoneChange: (String) -> Unit, + passwordInput: String, + onPasswordChange: (String) -> Unit, + passwordVisible: Boolean, + onTogglePasswordVisibility: () -> Unit, + autoDropdownText: String, + onDropdownTextChange: (String) -> Unit, + dropdownExpanded: Boolean, + onDropdownExpandedChange: (Boolean) -> Unit, + dropdownOptions: List, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + // Search Input + OutlinedTextField( + value = searchInput, + onValueChange = onSearchChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Search Input (ImeAction.Search)") }, + placeholder = { Text("Search keywords...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (searchInput.isNotEmpty()) { + IconButton(onClick = { onSearchChange("") }) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + }, + singleLine = true, + shape = RoundedCornerShape(14.dp), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Search + ) + ) // Number Input OutlinedTextField( - value = numberText, - onValueChange = { numberText = it }, + value = numberInput, + onValueChange = onNumberChange, modifier = Modifier.fillMaxWidth(), - label = { Text("Number") }, - placeholder = { Text("Number") }, + label = { Text("Numeric Keypad (KeyboardType.Number)") }, + placeholder = { Text("12345678") }, singleLine = true, - shape = RoundedCornerShape(16.dp), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + shape = RoundedCornerShape(14.dp), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next + ) + ) + + // Phone Input + OutlinedTextField( + value = phoneInput, + onValueChange = onPhoneChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Phone Keypad (KeyboardType.Phone)") }, + placeholder = { Text("+62 812-3456-7890") }, + singleLine = true, + shape = RoundedCornerShape(14.dp), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone, + imeAction = ImeAction.Next + ) ) - Spacer(modifier = Modifier.height(16.dp)) + // Password Input + OutlinedTextField( + value = passwordInput, + onValueChange = onPasswordChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Password (Visibility Toggle)") }, + placeholder = { Text("Enter secret password...") }, + singleLine = true, + shape = RoundedCornerShape(14.dp), + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + trailingIcon = { + val icon = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff + IconButton(onClick = onTogglePasswordVisibility) { + Icon(icon, contentDescription = if (passwordVisible) "Hide password" else "Show password") + } + } + ) // AutoComplete Dropdown ExposedDropdownMenuBox( expanded = dropdownExpanded, - onExpandedChange = { dropdownExpanded = it }, + onExpandedChange = onDropdownExpandedChange, modifier = Modifier.fillMaxWidth() ) { OutlinedTextField( - value = autoText, + value = autoDropdownText, onValueChange = { - autoText = it - dropdownExpanded = true + onDropdownTextChange(it) + onDropdownExpandedChange(true) }, modifier = Modifier - .fillMaxWidth() - .menuAnchor(type = ExposedDropdownMenuAnchorType.PrimaryEditable, enabled = true), - label = { Text("Full Text Auto") }, - placeholder = { Text("Start typing city...") }, - shape = RoundedCornerShape(16.dp), - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded) - }, + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, true) + .fillMaxWidth(), + label = { Text("Searchable Dropdown (Filter Chips)") }, + placeholder = { Text("Type city name...") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded) }, + shape = RoundedCornerShape(14.dp), colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors() ) - if (filteredOptions.isNotEmpty()) { + if (dropdownOptions.isNotEmpty()) { ExposedDropdownMenu( expanded = dropdownExpanded, - onDismissRequest = { dropdownExpanded = false } + onDismissRequest = { onDropdownExpandedChange(false) } ) { - filteredOptions.forEach { option -> + dropdownOptions.forEach { option -> DropdownMenuItem( text = { Text(option) }, onClick = { - autoText = option - dropdownExpanded = false - } + onDropdownTextChange(option) + onDropdownExpandedChange(false) + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding ) } } @@ -168,24 +669,97 @@ fun TestScreen( } } } -} -@Preview(showBackground = true, name = "TestScreen Light") +/** + * 5. Tab 2: AutoText Snippets Showcase (Tap to Insert) + */ @Composable -fun TestScreenPreview() { - FrogoKeyboardTheme(darkTheme = false) { - TestScreen( - dummyOptions = listOf("Kuningan", "Menteng", "Pegangsaan") +private fun AutoTextTabContent( + snippets: List, + onSnippetClicked: (AutoTextEntity) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = "Tap any AutoText snippet to insert it into the test sandbox:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - } -} -@Preview(showBackground = true, name = "TestScreen Dark") -@Composable -fun TestScreenDarkPreview() { - FrogoKeyboardTheme(darkTheme = true) { - TestScreen( - dummyOptions = listOf("Kuningan", "Menteng", "Pegangsaan") - ) + snippets.forEach { snippet -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onSnippetClicked(snippet) }, + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(FrogoPrimary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Notes, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(18.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = snippet.title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = snippet.label.name, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = snippet.body, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } } } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestViewModel.kt index cd31092..67dfd24 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestViewModel.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestViewModel.kt @@ -1,24 +1,124 @@ package com.frogobox.appkeyboard.ui.test +import androidx.lifecycle.viewModelScope import com.frogobox.appkeyboard.common.base.BaseViewModel +import com.frogobox.appkeyboard.model.AutoTextEntity +import com.frogobox.appkeyboard.model.AutoTextLabelType import com.frogobox.appkeyboard.repository.autotext.AutoTextRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.launch import javax.inject.Inject /** * Created by Faisal Amir on 24/10/22 - * ----------------------------------------- - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - * Copyright (C) Frogobox ID / amirisback - * All rights reserved + * Redesigned for TASK-010 by Tim Mobile */ - @HiltViewModel class TestViewModel @Inject constructor( private val repository: AutoTextRepository -): BaseViewModel() { +) : BaseViewModel() { + + private val _sandboxText = MutableStateFlow("") + val sandboxText: StateFlow = _sandboxText.asStateFlow() + + private val _activeTab = MutableStateFlow(0) + val activeTab: StateFlow = _activeTab.asStateFlow() + + private val _metrics = MutableStateFlow(TypingMetrics()) + val metrics: StateFlow = _metrics.asStateFlow() + + private val _autoTextList = MutableStateFlow>(emptyList()) + val autoTextList: StateFlow> = _autoTextList.asStateFlow() + + private var typingStartTimestamp: Long = 0L + + init { + loadAutoTextSnippets() + } + + private fun loadAutoTextSnippets() { + viewModelScope.launch { + repository.getAutoText() + .catch { emit(getDefaultTestSnippets()) } + .collect { list -> + if (list.isEmpty()) { + _autoTextList.value = getDefaultTestSnippets() + } else { + _autoTextList.value = list + } + } + } + } + + private fun getDefaultTestSnippets(): List { + return listOf( + AutoTextEntity( + id = -1, + title = "Greeting", + body = "Hello! Hope you are having a wonderful and productive day.", + label = AutoTextLabelType.DEFAULT, + isActive = true + ), + AutoTextEntity( + id = -2, + title = "Meeting Confirmation", + body = "Looking forward to our sync meeting tomorrow at 10:00 AM.", + label = AutoTextLabelType.DEFAULT, + isActive = true + ), + AutoTextEntity( + id = -3, + title = "Quick Sign-off", + body = "Kind regards,\nFrogo Keyboard Development Team", + label = AutoTextLabelType.DEFAULT, + isActive = true + ), + AutoTextEntity( + id = -4, + title = "Fast Response", + body = "Thanks for reaching out! I will review and reply as soon as possible.", + label = AutoTextLabelType.DEFAULT, + isActive = true + ) + ) + } + + fun onTextChanged(newText: String) { + _sandboxText.value = newText + val now = System.currentTimeMillis() + if (typingStartTimestamp == 0L && newText.isNotEmpty()) { + typingStartTimestamp = now + } + val elapsedMillis = if (typingStartTimestamp > 0L) now - typingStartTimestamp else 0L + _metrics.value = TypingMetricsCalculator.calculate(newText, elapsedMillis) + } + + fun onInsertText(textToInsert: String) { + val current = _sandboxText.value + val updated = if (current.isEmpty()) { + textToInsert + } else { + "$current\n$textToInsert" + } + onTextChanged(updated) + } + + fun onClearText() { + _sandboxText.value = "" + typingStartTimestamp = 0L + _metrics.value = TypingMetrics() + } + fun onResetTimer() { + typingStartTimestamp = System.currentTimeMillis() + _metrics.value = TypingMetricsCalculator.calculate(_sandboxText.value, 0L) + } + fun onTabSelected(tabIndex: Int) { + _activeTab.value = tabIndex + } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TypingMetricsCalculator.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TypingMetricsCalculator.kt new file mode 100644 index 0000000..cdebeca --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TypingMetricsCalculator.kt @@ -0,0 +1,47 @@ +package com.frogobox.appkeyboard.ui.test + +import kotlin.math.roundToInt + +data class TypingMetrics( + val characterCount: Int = 0, + val wordCount: Int = 0, + val lineCount: Int = 0, + val wordsPerMinute: Int = 0, + val elapsedSeconds: Long = 0L +) + +object TypingMetricsCalculator { + + fun calculate(text: String, elapsedMillis: Long): TypingMetrics { + val charCount = text.length + val trimmed = text.trim() + val wordCount = if (trimmed.isEmpty()) { + 0 + } else { + trimmed.split("\\s+".toRegex()).count { it.isNotEmpty() } + } + + val lineCount = if (text.isEmpty()) { + 0 + } else { + text.count { it == '\n' } + 1 + } + + val elapsedSeconds = elapsedMillis / 1000L + val wpm = if (elapsedSeconds < 2L || wordCount == 0) { + 0 + } else { + val minutes = elapsedSeconds / 60.0 + val rawWpm = (wordCount / minutes).roundToInt() + rawWpm.coerceIn(0, 300) + } + + return TypingMetrics( + characterCount = charCount, + wordCount = wordCount, + lineCount = lineCount, + wordsPerMinute = wpm, + elapsedSeconds = elapsedSeconds + ) + } +} 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 ab0d9ed..b63daf8 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 @@ -1,47 +1,78 @@ package com.frogobox.appkeyboard.ui.theme +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio 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.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape 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.Backspace +import androidx.compose.material.icons.automirrored.filled.KeyboardReturn +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.SentimentSatisfied +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.TextFields import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton 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 import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf 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.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -53,6 +84,12 @@ import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar +enum class ThemeCategory(val title: String) { + ALL("All"), + SOLID("Solid Colors"), + WALLPAPER("Wallpapers") +} + @Composable fun ThemeScreen( themeList: List, @@ -61,78 +98,198 @@ fun ThemeScreen( onBackClick: () -> Unit, modifier: Modifier = Modifier ) { - var selectedThemeForDialog by remember { mutableStateOf(null) } + var selectedCategory by remember { mutableStateOf(ThemeCategory.ALL) } + var previewedTheme by remember { mutableStateOf(null) } + var confirmApplyDialogTheme by remember { mutableStateOf(null) } + + // Synchronize preview with active theme on initial load or change + LaunchedEffect(themeList) { + if (previewedTheme == null && themeList.isNotEmpty()) { + previewedTheme = themeList.firstOrNull { checkIsActive(it) } ?: themeList.first() + } + } + + // Filter themes based on selected category chip + val filteredThemes = remember(themeList, selectedCategory) { + when (selectedCategory) { + ThemeCategory.ALL -> themeList + ThemeCategory.SOLID -> themeList.filter { it.themType == ThemeType.COLOR } + ThemeCategory.WALLPAPER -> themeList.filter { it.themType == ThemeType.IMAGE } + } + } + + val activeTheme = themeList.firstOrNull { checkIsActive(it) } Scaffold( modifier = modifier.fillMaxSize(), topBar = { FrogoTopAppBar( - title = "Setup Theme", + title = "Keyboard Themes", onBackClick = onBackClick ) }, containerColor = MaterialTheme.colorScheme.background ) { paddingValues -> - LazyColumn( + LazyVerticalGrid( + columns = GridCells.Fixed(2), modifier = Modifier .fillMaxSize() .padding(paddingValues), contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - items(themeList, key = { it.name }) { theme -> - ThemeCard( + // 1. Live Interactive Keyboard Preview Hero Section (Spans all columns) + item(span = { GridItemSpan(2) }) { + val currentPreview = previewedTheme ?: activeTheme ?: themeList.firstOrNull() + currentPreview?.let { theme -> + val isPreviewActive = checkIsActive(theme) + ThemePreviewHero( + theme = theme, + isActive = isPreviewActive, + onApplyClick = { + confirmApplyDialogTheme = theme + } + ) + } + } + + // 2. Category Filter Chips Section (Spans all columns) + item(span = { GridItemSpan(2) }) { + ThemeCategoryFilterRow( + selectedCategory = selectedCategory, + onCategorySelected = { selectedCategory = it }, + totalAllCount = themeList.size, + solidCount = themeList.count { it.themType == ThemeType.COLOR }, + wallpaperCount = themeList.count { it.themType == ThemeType.IMAGE } + ) + } + + // 3. Section Title (Spans all columns) + item(span = { GridItemSpan(2) }) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Theme Gallery (${filteredThemes.size})", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ), + color = MaterialTheme.colorScheme.onBackground + ) + + activeTheme?.let { + Text( + text = "Active: ${it.name}", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + color = FrogoPrimary + ) + ) + } + } + } + + // 4. 2-Column Responsive Theme Cards + items(filteredThemes, key = { it.name }) { theme -> + val isActive = checkIsActive(theme) + val isPreviewed = previewedTheme?.name == theme.name + + ThemeGridCard( theme = theme, - isSelected = checkIsActive(theme), - onClick = { - selectedThemeForDialog = theme + isActive = isActive, + isPreviewed = isPreviewed, + onCardClick = { + previewedTheme = theme + }, + onApplyClick = { + confirmApplyDialogTheme = theme } ) } + + // Bottom Spacer for edge-to-edge breathing room + item(span = { GridItemSpan(2) }) { + Spacer(modifier = Modifier.height(24.dp)) + } } } - selectedThemeForDialog?.let { theme -> + // Confirmation Dialog before applying theme + confirmApplyDialogTheme?.let { theme -> AlertDialog( - onDismissRequest = { selectedThemeForDialog = null }, + onDismissRequest = { confirmApplyDialogTheme = null }, + icon = { + Surface( + shape = CircleShape, + color = FrogoPrimary.copy(alpha = 0.12f), + modifier = Modifier.size(48.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Palette, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(24.dp) + ) + } + } + }, title = { Text( - text = "Change Theme", + text = "Apply Theme", style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.Bold - ) + ), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() ) }, text = { - Text("Are you sure you want to change keyboard theme to ${theme.name}?") + Text( + text = "Set \"${theme.name}\" as your active keyboard theme? All keyboard keys and panels will adopt this style.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) }, confirmButton = { - TextButton( + Button( onClick = { val current = theme - selectedThemeForDialog = null + confirmApplyDialogTheme = null onApplyTheme(current) - } + previewedTheme = current + }, + colors = ButtonDefaults.buttonColors(containerColor = FrogoPrimary), + shape = RoundedCornerShape(10.dp) ) { Text( - text = "Apply", - color = FrogoPrimary, + text = "Apply Now", fontWeight = FontWeight.Bold ) } }, dismissButton = { TextButton( - onClick = { selectedThemeForDialog = null } + onClick = { confirmApplyDialogTheme = null } ) { Text("Cancel") } }, - shape = RoundedCornerShape(16.dp) + shape = RoundedCornerShape(20.dp) ) } } +/** + * Backwards-compatible overload matching previous signature + */ @Composable fun ThemeScreen( themeList: List, @@ -150,121 +307,795 @@ fun ThemeScreen( ) } +/** + * 1. Live Interactive Keyboard Preview Hero Component + */ @Composable -private fun ThemeCard( +private fun ThemePreviewHero( theme: KeyboardThemeModel, - isSelected: Boolean, - onClick: () -> Unit, + isActive: Boolean, + onApplyClick: () -> Unit, modifier: Modifier = Modifier ) { Card( - modifier = modifier - .fillMaxWidth() - .clickable { onClick() }, - shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 3.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke( + width = if (isActive) 2.dp else 1.dp, + color = if (isActive) FrogoPrimary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) ) ) { - Row( + Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically + .padding(16.dp) ) { - // Circular Thumbnail Preview based on ThemeType - when (theme.themType) { - ThemeType.COLOR -> { - Box( - modifier = Modifier - .size(56.dp) - .clip(CircleShape) - .background(colorResource(id = theme.background)) - .border( - width = 1.5.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - shape = CircleShape + // Header Row: Theme Label + Status / Apply Action + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = CircleShape, + color = FrogoPrimary.copy(alpha = 0.12f), + modifier = Modifier.size(32.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Palette, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(18.dp) + ) + } + } + + Spacer(modifier = Modifier.width(10.dp)) + + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = theme.name, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ), + color = MaterialTheme.colorScheme.onSurface ) + Spacer(modifier = Modifier.width(6.dp)) + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Text( + text = if (theme.themType == ThemeType.COLOR) "Color" else "Wallpaper", + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold + ), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Text( + text = theme.description, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (isActive) { + Surface( + shape = RoundedCornerShape(12.dp), + color = Color(0xFF41AF60).copy(alpha = 0.12f), + border = BorderStroke(1.dp, Color(0xFF41AF60).copy(alpha = 0.4f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Active", + tint = Color(0xFF41AF60), + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Active", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + color = Color(0xFF41AF60), + fontSize = 12.sp + ) + ) + } + } + } else { + Button( + onClick = onApplyClick, + colors = ButtonDefaults.buttonColors(containerColor = FrogoPrimary), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 6.dp), + modifier = Modifier.height(34.dp) + ) { + Text( + text = "Apply", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ) + ) + } + } + } + + Spacer(modifier = Modifier.height(14.dp)) + + // Realistic Keyboard Mockup Canvas + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + shape = RoundedCornerShape(14.dp) ) + ) { + // Background Layer (Color or Wallpaper) + when (theme.themType) { + ThemeType.COLOR -> { + Box( + modifier = Modifier + .matchParentSize() + .background(colorResource(id = theme.background)) + ) + } + ThemeType.IMAGE -> { + Image( + painter = painterResource(id = theme.background), + contentDescription = theme.name, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop + ) + } } - ThemeType.IMAGE -> { - Image( - painter = painterResource(id = theme.background), - contentDescription = theme.name, + + // Keyboard Layout Overlay (Utility Strip + Keycaps) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Top Suggestion & Toolbar Strip + Row( modifier = Modifier - .size(56.dp) - .clip(CircleShape) - .border( - width = 1.5.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - shape = CircleShape - ), - contentScale = ContentScale.Crop + .fillMaxWidth() + .padding(bottom = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MockToolIcon(Icons.Default.SentimentSatisfied) + MockToolIcon(Icons.Default.TextFields) + MockToolIcon(Icons.Default.Language) + MockToolIcon(Icons.Default.Settings) + } + + Text( + text = "Frogo Keyboard", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + color = Color.White.copy(alpha = 0.85f) + ) + ) + } + + // Keycap Row 1: Q W E R T Y U I O P (10 keys) + MockKeyRow(keys = listOf("Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P")) + + // Keycap Row 2: A S D F G H J K L (9 keys with inset margins) + MockKeyRow( + keys = listOf("A", "S", "D", "F", "G", "H", "J", "K", "L"), + horizontalPadding = 12.dp ) + + // Keycap Row 3: Shift, Z X C V B N M, Backspace + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MockTextKey( + text = "⇧", + weight = 1.3f + ) + listOf("Z", "X", "C", "V", "B", "N", "M").forEach { letter -> + MockKey( + text = letter, + modifier = Modifier.weight(1f) + ) + } + MockSpecialKey( + icon = Icons.AutoMirrored.Filled.Backspace, + weight = 1.3f + ) + } + + // Keycap Row 4: ?123, Language, Spacebar, Enter + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MockTextKey(text = "?123", weight = 1.4f) + MockIconKey(icon = Icons.Default.Language, weight = 1f) + MockSpacebarKey(text = "English", weight = 4.2f) + MockActionKey(icon = Icons.AutoMirrored.Filled.KeyboardReturn, weight = 1.6f) + } } } + } + } +} + +/** + * 2. Category Filter Chips Row Component + */ +@Composable +private fun ThemeCategoryFilterRow( + selectedCategory: ThemeCategory, + onCategorySelected: (ThemeCategory) -> Unit, + totalAllCount: Int, + solidCount: Int, + wallpaperCount: Int, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ThemeCategory.entries.forEach { category -> + val count = when (category) { + ThemeCategory.ALL -> totalAllCount + ThemeCategory.SOLID -> solidCount + ThemeCategory.WALLPAPER -> wallpaperCount + } + val isSelected = selectedCategory == category + + FilterChip( + selected = isSelected, + onClick = { onCategorySelected(category) }, + label = { + Text( + text = "${category.title} ($count)", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium + ) + ) + }, + leadingIcon = { + when (category) { + ThemeCategory.ALL -> Icon( + imageVector = Icons.Default.Palette, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + ThemeCategory.SOLID -> Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + ThemeCategory.WALLPAPER -> Icon( + imageVector = Icons.Default.SentimentSatisfied, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = FrogoPrimary.copy(alpha = 0.15f), + selectedLabelColor = FrogoPrimary, + selectedLeadingIconColor = FrogoPrimary + ), + shape = RoundedCornerShape(12.dp) + ) + } + } +} + +/** + * 3. 2-Column Visual Theme Card Component + */ +@Composable +private fun ThemeGridCard( + theme: KeyboardThemeModel, + isActive: Boolean, + isPreviewed: Boolean, + onCardClick: () -> Unit, + onApplyClick: () -> Unit, + modifier: Modifier = Modifier +) { + val borderColor by animateColorAsState( + targetValue = when { + isActive -> FrogoPrimary + isPreviewed -> FrogoPrimary.copy(alpha = 0.6f) + else -> MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + }, + animationSpec = tween(durationMillis = 200), + label = "cardBorderColor" + ) - Spacer(modifier = Modifier.width(16.dp)) + Card( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable { onCardClick() }, + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = if (isPreviewed || isActive) 3.dp else 1.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke( + width = if (isActive || isPreviewed) 2.dp else 1.dp, + color = borderColor + ) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Top Preview Swatch / Wallpaper Area + Box( + modifier = Modifier + .fillMaxWidth() + .height(92.dp) + ) { + when (theme.themType) { + ThemeType.COLOR -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(colorResource(id = theme.background)) + ) + } + ThemeType.IMAGE -> { + Image( + painter = painterResource(id = theme.background), + contentDescription = theme.name, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + } + // Mini Keycap Silhouette Overlay for Authentic Keyboard Feel + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + repeat(10) { + Box( + modifier = Modifier + .weight(1f) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.28f)) + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + repeat(9) { + Box( + modifier = Modifier + .weight(1f) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.28f)) + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Box( + modifier = Modifier + .weight(1.3f) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.35f)) + ) + repeat(7) { + Box( + modifier = Modifier + .weight(1f) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.28f)) + ) + } + Box( + modifier = Modifier + .weight(1.3f) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.35f)) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Box( + modifier = Modifier + .weight(1.5f) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.28f)) + ) + Box( + modifier = Modifier + .weight(4.5f) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.38f)) + ) + Box( + modifier = Modifier + .weight(2f) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + .background(FrogoPrimary.copy(alpha = 0.75f)) + ) + } + } + + // Floating Active Badge (Top Right) + if (isActive) { + Surface( + shape = RoundedCornerShape(bottomStart = 10.dp), + color = Color(0xFF41AF60), + modifier = Modifier.align(Alignment.TopEnd) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Active", + tint = Color.White, + modifier = Modifier.size(12.dp) + ) + Spacer(modifier = Modifier.width(3.dp)) + Text( + text = "Active", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + color = Color.White + ) + ) + } + } + } else if (isPreviewed) { + Surface( + shape = RoundedCornerShape(bottomStart = 10.dp), + color = FrogoPrimary, + modifier = Modifier.align(Alignment.TopEnd) + ) { + Text( + text = "Previewing", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 9.sp, + color = Color.White + ), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp) + ) + } + } + } + + // Bottom Info Container Column( - modifier = Modifier.weight(1f) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp) ) { Text( text = theme.name, - style = MaterialTheme.typography.titleMedium.copy( + style = MaterialTheme.typography.titleSmall.copy( fontWeight = FontWeight.Bold, - fontSize = 16.sp + fontSize = 14.sp ), color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis ) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(2.dp)) Text( text = theme.description, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp + ), color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) } + } + } +} + +// -------------------------------------------------------------------------------- +// KEYBOARD MOCKUP HELPER COMPOSABLES +// -------------------------------------------------------------------------------- + +@Composable +private fun MockToolIcon( + icon: ImageVector, + modifier: Modifier = Modifier +) { + Surface( + shape = CircleShape, + color = Color.Black.copy(alpha = 0.25f), + modifier = modifier.size(20.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White.copy(alpha = 0.9f), + modifier = Modifier.size(12.dp) + ) + } + } +} + +@Composable +private fun MockKeyRow( + keys: List, + horizontalPadding: androidx.compose.ui.unit.Dp = 0.dp, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + keys.forEach { letter -> + MockKey( + text = letter, + modifier = Modifier.weight(1f) + ) + } + } +} - if (isSelected) { - Spacer(modifier = Modifier.width(12.dp)) - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = "Active Theme", - tint = FrogoPrimary, - modifier = Modifier.size(28.dp) +@Composable +private fun MockKey( + text: String, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape(5.dp), + color = Color.White.copy(alpha = 0.22f), + border = BorderStroke(0.5.dp, Color.White.copy(alpha = 0.35f)), + modifier = modifier.height(26.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + color = Color.White ) - } + ) } } } -@Preview(showBackground = true) +@Composable +private fun MockSpecialKey( + icon: ImageVector, + weight: Float, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape(5.dp), + color = Color.Black.copy(alpha = 0.25f), + border = BorderStroke(0.5.dp, Color.White.copy(alpha = 0.3f)), + modifier = modifier + .height(26.dp) + .then(Modifier.run { this }) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(horizontal = 6.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(14.dp) + ) + } + } +} + +@Composable +private fun MockTextKey( + text: String, + weight: Float, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape(5.dp), + color = Color.Black.copy(alpha = 0.25f), + border = BorderStroke(0.5.dp, Color.White.copy(alpha = 0.3f)), + modifier = modifier.height(26.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(horizontal = 6.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + color = Color.White + ) + ) + } + } +} + +@Composable +private fun MockIconKey( + icon: ImageVector, + weight: Float, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape(5.dp), + color = Color.Black.copy(alpha = 0.25f), + border = BorderStroke(0.5.dp, Color.White.copy(alpha = 0.3f)), + modifier = modifier.height(26.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(horizontal = 6.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(13.dp) + ) + } + } +} + +@Composable +private fun MockSpacebarKey( + text: String, + weight: Float, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape(5.dp), + color = Color.White.copy(alpha = 0.28f), + border = BorderStroke(0.5.dp, Color.White.copy(alpha = 0.4f)), + modifier = modifier.height(26.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 10.sp, + color = Color.White.copy(alpha = 0.9f) + ) + ) + } + } +} + +@Composable +private fun MockActionKey( + icon: ImageVector, + weight: Float, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape(5.dp), + color = FrogoPrimary, + modifier = modifier.height(26.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(14.dp) + ) + } + } +} + +@Preview(showBackground = true, name = "ThemeScreen Light Preview") @Composable fun ThemeScreenPreview() { FrogoKeyboardTheme(darkTheme = false) { ThemeScreen( themeList = listOf( - KeyboardThemeModel( - name = "Default", - themType = ThemeType.COLOR, - background = R.color.color_bg_keyboard_default, - description = "Default standard keyboard theme" - ), - KeyboardThemeModel( - name = "Wallpaper", - themType = ThemeType.IMAGE, - background = R.drawable.ic_wallpaper_dummy, - description = "Custom image wallpaper" - ) + KeyboardThemeModel("Default", "Classic Adaptive", ThemeType.COLOR, R.color.color_bg_keyboard_default), + KeyboardThemeModel("Frogo Purple", "Signature Brand", ThemeType.COLOR, R.color.color_bg_keyboard_purple), + KeyboardThemeModel("Midnight AMOLED", "Deep OLED Black", ThemeType.COLOR, R.color.color_bg_keyboard_dark), + KeyboardThemeModel("Ocean Blue", "Calm & Focused", ThemeType.COLOR, R.color.color_bg_keyboard_blue), + KeyboardThemeModel("Forest Emerald", "Natural Harmony", ThemeType.COLOR, R.color.color_bg_keyboard_green), + KeyboardThemeModel("Crimson Sunset", "Vibrant Warmth", ThemeType.COLOR, R.color.color_bg_keyboard_red), + KeyboardThemeModel("Sunset Orange", "Energetic Twilight", ThemeType.COLOR, R.color.color_bg_keyboard_orange), + KeyboardThemeModel("Nordic Cyan", "Fresh & Clean", ThemeType.COLOR, R.color.color_bg_keyboard_cyan), + KeyboardThemeModel("Sakura Pink", "Aesthetic Pastel", ThemeType.COLOR, R.color.color_bg_keyboard_pink), + KeyboardThemeModel("Amber Gold", "Golden Accent", ThemeType.COLOR, R.color.color_bg_keyboard_yellow), + KeyboardThemeModel("Wallpaper", "Sample Artwork", ThemeType.IMAGE, R.drawable.ic_wallpaper_dummy) + ), + checkIsActive = { it.name == "Frogo Purple" }, + onApplyTheme = {}, + onBackClick = {} + ) + } +} + +@Preview(showBackground = true, name = "ThemeScreen Dark Preview") +@Composable +fun ThemeScreenDarkPreview() { + FrogoKeyboardTheme(darkTheme = true) { + ThemeScreen( + themeList = listOf( + KeyboardThemeModel("Default", "Classic Adaptive", ThemeType.COLOR, R.color.color_bg_keyboard_default), + KeyboardThemeModel("Frogo Purple", "Signature Brand", ThemeType.COLOR, R.color.color_bg_keyboard_purple), + KeyboardThemeModel("Midnight AMOLED", "Deep OLED Black", ThemeType.COLOR, R.color.color_bg_keyboard_dark), + KeyboardThemeModel("Wallpaper", "Sample Artwork", ThemeType.IMAGE, R.drawable.ic_wallpaper_dummy) ), - checkIsActive = { it.themType == ThemeType.COLOR }, + checkIsActive = { it.name == "Midnight AMOLED" }, onApplyTheme = {}, onBackClick = {} ) diff --git a/app/src/main/res/layout/item_keyboard_autotext.xml b/app/src/main/res/layout/item_keyboard_autotext.xml index 3da3016..025b3f5 100644 --- a/app/src/main/res/layout/item_keyboard_autotext.xml +++ b/app/src/main/res/layout/item_keyboard_autotext.xml @@ -1,24 +1,38 @@ + android:elevation="@dimen/frogo_dimen_2dp" + android:orientation="vertical" + android:paddingStart="14dp" + android:paddingTop="10dp" + android:paddingEnd="14dp" + android:paddingBottom="10dp"> + android:textStyle="bold" + android:textSize="14sp" + tools:text="Greetings" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/keyboard_autotext.xml b/app/src/main/res/layout/keyboard_autotext.xml index a27d6bc..c3b768a 100644 --- a/app/src/main/res/layout/keyboard_autotext.xml +++ b/app/src/main/res/layout/keyboard_autotext.xml @@ -32,29 +32,71 @@ + + + + + + + + + diff --git a/app/src/main/res/values-night/color.xml b/app/src/main/res/values-night/color.xml index 5a6dec7..c3b87e0 100644 --- a/app/src/main/res/values-night/color.xml +++ b/app/src/main/res/values-night/color.xml @@ -2,5 +2,14 @@ @color/keyboard_background + #7C4DFF + #000000 + #1E88E5 + #388E3C + #E53935 + #F57C00 + #00ACC1 + #D81B60 + #FDD835 \ No newline at end of file diff --git a/app/src/main/res/values/color.xml b/app/src/main/res/values/color.xml index 000324b..1746e8e 100644 --- a/app/src/main/res/values/color.xml +++ b/app/src/main/res/values/color.xml @@ -2,9 +2,14 @@ @color/keyboard_background - #F44336 - #4CAF50 - #3F51B5 - #FFEB3B + #6200EE + #121212 + #1976D2 + #2E7D32 + #C62828 + #EF6C00 + #00838F + #C2185B + #FBC02D \ No newline at end of file diff --git a/app/src/test/java/com/frogobox/appkeyboard/AutoTextTest.kt b/app/src/test/java/com/frogobox/appkeyboard/AutoTextTest.kt new file mode 100644 index 0000000..2574d4c --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/AutoTextTest.kt @@ -0,0 +1,95 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.model.AutoTextEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AutoTextTest { + + private val sampleList = listOf( + AutoTextEntity(id = 1, title = "Greeting Morning", body = "Good morning! How can I help you today?"), + AutoTextEntity(id = 2, title = "Bank Account", body = "BCA: 1234567890 a/n Faisal Amir"), + AutoTextEntity(id = 3, title = "Email Address", body = "faisal.amir@example.com"), + AutoTextEntity(id = 4, title = "Closing Note", body = "Thank you for reaching out. Have a wonderful day!") + ) + + @Test + fun testAutoTextFilteringByTitle() { + val query = "bank" + val filtered = sampleList.filter { + it.title.contains(query, ignoreCase = true) || it.body.contains(query, ignoreCase = true) + } + assertEquals(1, filtered.size) + assertEquals("Bank Account", filtered[0].title) + } + + @Test + fun testAutoTextFilteringByBody() { + val query = "1234567890" + val filtered = sampleList.filter { + it.title.contains(query, ignoreCase = true) || it.body.contains(query, ignoreCase = true) + } + assertEquals(1, filtered.size) + assertEquals("Bank Account", filtered[0].title) + } + + @Test + fun testAutoTextFilteringCaseInsensitive() { + val query = "GREETING" + val filtered = sampleList.filter { + it.title.contains(query, ignoreCase = true) || it.body.contains(query, ignoreCase = true) + } + assertEquals(1, filtered.size) + assertEquals(1, filtered[0].id) + } + + @Test + fun testAutoTextFilteringEmptyQueryReturnsAll() { + val query = "" + val filtered = sampleList.filter { + it.title.contains(query, ignoreCase = true) || it.body.contains(query, ignoreCase = true) + } + assertEquals(sampleList.size, filtered.size) + } + + @Test + fun testAutoTextFilteringNoMatch() { + val query = "xyznonexistent" + val filtered = sampleList.filter { + it.title.contains(query, ignoreCase = true) || it.body.contains(query, ignoreCase = true) + } + assertTrue(filtered.isEmpty()) + } + + @Test + fun testAutoTextWordCountCalculation() { + val text = "Good morning! How can I help you today?" + val wordCount = text.trim().split(Regex("\\s+")).count { it.isNotEmpty() } + assertEquals(8, wordCount) + + val emptyText = " " + val emptyWordCount = if (emptyText.isBlank()) 0 else emptyText.trim().split(Regex("\\s+")).count { it.isNotEmpty() } + assertEquals(0, emptyWordCount) + } + + @Test + fun testAutoTextCharacterCount() { + val body = "Hello World" + assertEquals(11, body.length) + } + + @Test + fun testAutoTextFormValidation() { + val validTitle = "My Title" + val validBody = "My Body" + assertTrue(validTitle.isNotBlank() && validBody.isNotBlank()) + + val emptyTitle = "" + assertFalse(emptyTitle.isNotBlank() && validBody.isNotBlank()) + + val blankBody = " " + assertFalse(validTitle.isNotBlank() && blankBody.isNotBlank()) + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/KeyboardLanguageTest.kt b/app/src/test/java/com/frogobox/appkeyboard/KeyboardLanguageTest.kt new file mode 100644 index 0000000..db6bc21 --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/KeyboardLanguageTest.kt @@ -0,0 +1,181 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.ui.language.KeyboardLanguage +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 + +/** + * Unit tests for KeyboardLanguage data model, metadata integrity, active resolution, and search filtering. + */ +class KeyboardLanguageTest { + + private val sampleLanguages = listOf( + KeyboardLanguage( + name = "English (QWERTY)", + xml = 101, + layoutType = "QWERTY", + code = "EN", + script = "Latin" + ), + KeyboardLanguage( + name = "English (QWERTZ)", + xml = 102, + layoutType = "QWERTZ", + code = "EN", + script = "Latin" + ), + KeyboardLanguage( + name = "English (DVORAK)", + xml = 103, + layoutType = "DVORAK", + code = "EN", + script = "Latin" + ), + KeyboardLanguage( + name = "French", + xml = 104, + layoutType = "AZERTY", + code = "FR", + script = "Français (Latin)" + ), + KeyboardLanguage( + name = "German", + xml = 105, + layoutType = "QWERTZ", + code = "DE", + script = "Deutsch (Latin)" + ), + KeyboardLanguage( + name = "Russian", + xml = 106, + layoutType = "ЙЦУКЕН", + code = "RU", + script = "Русский (Cyrillic)" + ), + KeyboardLanguage( + name = "Persian", + xml = 107, + layoutType = "Standard", + code = "FA", + script = "فارسی (Perso-Arabic)", + isRtl = true + ) + ) + + @Test + fun testKeyboardLanguageBackwardCompatibilityDefaults() { + // Constructor call without new optional parameters + val legacyLanguage = KeyboardLanguage( + name = "Legacy Language", + xml = 999 + ) + + assertEquals("Legacy Language", legacyLanguage.name) + assertEquals(999, legacyLanguage.xml) + assertEquals("Standard", legacyLanguage.layoutType) + assertEquals("EN", legacyLanguage.code) + assertEquals("Latin", legacyLanguage.script) + assertFalse(legacyLanguage.isRtl) + } + + @Test + fun testActiveLanguageResolution() { + val activeXml = 105 // German + val active = sampleLanguages.firstOrNull { it.xml == activeXml } + + assertNotNull(active) + assertEquals("German", active?.name) + assertEquals("DE", active?.code) + assertEquals("QWERTZ", active?.layoutType) + + val nonExistentActive = sampleLanguages.firstOrNull { it.xml == 9999 } + assertNull(nonExistentActive) + } + + @Test + fun testSearchFilteringByName() { + val query = "french" + val results = sampleLanguages.filter { + it.name.lowercase().contains(query.lowercase()) || + it.code.lowercase().contains(query.lowercase()) || + it.layoutType.lowercase().contains(query.lowercase()) || + it.script.lowercase().contains(query.lowercase()) + } + + assertEquals(1, results.size) + assertEquals("French", results[0].name) + assertEquals("FR", results[0].code) + } + + @Test + fun testSearchFilteringByLayoutType() { + val query = "QWERTZ" + val results = sampleLanguages.filter { + it.name.lowercase().contains(query.lowercase()) || + it.code.lowercase().contains(query.lowercase()) || + it.layoutType.lowercase().contains(query.lowercase()) || + it.script.lowercase().contains(query.lowercase()) + } + + // Both English (QWERTZ) and German (QWERTZ) + assertEquals(2, results.size) + assertTrue(results.any { it.name == "English (QWERTZ)" }) + assertTrue(results.any { it.name == "German" }) + } + + @Test + fun testSearchFilteringByCode() { + val query = "RU" + val results = sampleLanguages.filter { + it.name.lowercase().contains(query.lowercase()) || + it.code.lowercase().contains(query.lowercase()) || + it.layoutType.lowercase().contains(query.lowercase()) || + it.script.lowercase().contains(query.lowercase()) + } + + assertEquals(1, results.size) + assertEquals("Russian", results[0].name) + assertEquals("ЙЦУКЕН", results[0].layoutType) + } + + @Test + fun testSearchFilteringByScript() { + val query = "cyrillic" + val results = sampleLanguages.filter { + it.name.lowercase().contains(query.lowercase()) || + it.code.lowercase().contains(query.lowercase()) || + it.layoutType.lowercase().contains(query.lowercase()) || + it.script.lowercase().contains(query.lowercase()) + } + + assertEquals(1, results.size) + assertEquals("Russian", results[0].name) + } + + @Test + fun testSearchFilteringNoResults() { + val query = "NonExistentLanguage123" + val results = sampleLanguages.filter { + it.name.lowercase().contains(query.lowercase()) || + it.code.lowercase().contains(query.lowercase()) || + it.layoutType.lowercase().contains(query.lowercase()) || + it.script.lowercase().contains(query.lowercase()) + } + + assertTrue(results.isEmpty()) + } + + @Test + fun testRtlMetadataIntegrity() { + val persian = sampleLanguages.first { it.code == "FA" } + assertTrue(persian.isRtl) + assertEquals("Standard", persian.layoutType) + + val english = sampleLanguages.first { it.code == "EN" && it.layoutType == "QWERTY" } + assertFalse(english.isRtl) + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt b/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt index 0fca606..dcfbc7f 100644 --- a/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt +++ b/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt @@ -17,13 +17,17 @@ class ThemeTest { @Test fun testKeyboardThemeTypeMapping() { val themes = KeyboardThemeType.entries.map { it.mapToModel() } - assertEquals(6, themes.size) + assertEquals(11, themes.size) val defaultTheme = themes.first { it.name == "Default" } assertEquals(ThemeType.COLOR, defaultTheme.themType) assertTrue(defaultTheme.background != 0) - val imageTheme = themes.first { it.name == "Image" } + val purpleTheme = themes.first { it.name == "Frogo Purple" } + assertEquals(ThemeType.COLOR, purpleTheme.themType) + assertTrue(purpleTheme.background != 0) + + val imageTheme = themes.first { it.name == "Wallpaper" } assertEquals(ThemeType.IMAGE, imageTheme.themType) assertTrue(imageTheme.background != 0) } @@ -31,10 +35,15 @@ class ThemeTest { @Test fun testKeyboardThemeTypeFrom() { assertEquals(KeyboardThemeType.DEFAULT, KeyboardThemeType from "DEFAULT") - assertEquals(KeyboardThemeType.RED, KeyboardThemeType from "RED") + assertEquals(KeyboardThemeType.PURPLE, KeyboardThemeType from "PURPLE") + assertEquals(KeyboardThemeType.DARK, KeyboardThemeType from "DARK") + assertEquals(KeyboardThemeType.BLUE, KeyboardThemeType from "BLUE") assertEquals(KeyboardThemeType.GREEN, KeyboardThemeType from "GREEN") + assertEquals(KeyboardThemeType.RED, KeyboardThemeType from "RED") + assertEquals(KeyboardThemeType.ORANGE, KeyboardThemeType from "ORANGE") + assertEquals(KeyboardThemeType.CYAN, KeyboardThemeType from "CYAN") + assertEquals(KeyboardThemeType.PINK, KeyboardThemeType from "PINK") assertEquals(KeyboardThemeType.YELLOW, KeyboardThemeType from "YELLOW") - assertEquals(KeyboardThemeType.BLUE, KeyboardThemeType from "BLUE") assertEquals(KeyboardThemeType.IMAGE_BG_DARK, KeyboardThemeType from "IMAGE_BG_DARK") // Unknown fallback to DEFAULT assertEquals(KeyboardThemeType.DEFAULT, KeyboardThemeType from "UNKNOWN_THEME") diff --git a/app/src/test/java/com/frogobox/appkeyboard/TypingMetricsTest.kt b/app/src/test/java/com/frogobox/appkeyboard/TypingMetricsTest.kt new file mode 100644 index 0000000..6ffff4a --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/TypingMetricsTest.kt @@ -0,0 +1,80 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.ui.test.TypingMetricsCalculator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for TypingMetricsCalculator domain logic + * Verifies character counting, word counting, line counting, and WPM calculation safety guards. + */ +class TypingMetricsTest { + + @Test + fun testEmptyTextMetrics() { + val metrics = TypingMetricsCalculator.calculate("", 5000L) + assertEquals(0, metrics.characterCount) + assertEquals(0, metrics.wordCount) + assertEquals(0, metrics.lineCount) + assertEquals(0, metrics.wordsPerMinute) + assertEquals(5L, metrics.elapsedSeconds) + } + + @Test + fun testWhitespaceOnlyMetrics() { + val metrics = TypingMetricsCalculator.calculate(" \n\t ", 10000L) + assertEquals(7, metrics.characterCount) + assertEquals(0, metrics.wordCount) + assertEquals(2, metrics.lineCount) + assertEquals(0, metrics.wordsPerMinute) + } + + @Test + fun testEnglishPangramWordCount() { + val pangram = "The quick brown fox jumps over the lazy dog" + val metrics = TypingMetricsCalculator.calculate(pangram, 12000L) + assertEquals(43, metrics.characterCount) + assertEquals(9, metrics.wordCount) + assertEquals(1, metrics.lineCount) + // 9 words in 12s = 9 / (12/60) = 45 WPM + assertEquals(45, metrics.wordsPerMinute) + } + + @Test + fun testIndonesianPangramMetrics() { + val pangram = "Keluarga Jefri bernomor fax tujuh belas" + val metrics = TypingMetricsCalculator.calculate(pangram, 6000L) + assertEquals(39, metrics.characterCount) + assertEquals(6, metrics.wordCount) + assertEquals(1, metrics.lineCount) + // 6 words in 6s = 6 / 0.1 = 60 WPM + assertEquals(60, metrics.wordsPerMinute) + } + + @Test + fun testMultilineCount() { + val text = "Line 1\nLine 2\nLine 3\nLine 4" + val metrics = TypingMetricsCalculator.calculate(text, 20000L) + assertEquals(4, metrics.lineCount) + assertEquals(8, metrics.wordCount) + } + + @Test + fun testWpmShortDurationReturnsZero() { + val text = "Hello world quick test" + val metrics = TypingMetricsCalculator.calculate(text, 1500L) // 1.5s < 2s threshold + assertEquals(4, metrics.wordCount) + assertEquals(0, metrics.wordsPerMinute) + } + + @Test + fun testWpmClampedToMax300() { + // 500 words pasted in 3 seconds -> extreme raw WPM + val manyWords = (1..500).joinToString(" ") { "word$it" } + val metrics = TypingMetricsCalculator.calculate(manyWords, 3000L) + assertEquals(500, metrics.wordCount) + assertTrue(metrics.wordsPerMinute <= 300) + assertEquals(300, metrics.wordsPerMinute) + } +} diff --git a/gradle.properties b/gradle.properties index c2cb610..5655886 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,10 +6,11 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx4608m -XX:+UseParallelGC -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx1280m -XX:+UseParallelGC -XX:MaxMetaspaceSize=320m -Dfile.encoding=UTF-8 # Gradle Performance Flags org.gradle.daemon=true -org.gradle.parallel=true +org.gradle.parallel=false +org.gradle.workers.max=1 org.gradle.caching=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app"s APK @@ -20,8 +21,8 @@ android.enableJetifier=false # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official android.nonTransitiveRClass=true -ksp.incremental=true -kotlin.incremental=true +ksp.incremental=false +kotlin.incremental=false # Enabled parallel sync for Gradle 9.4+ org.gradle.tooling.parallel=true From 1f45156663de4942e0269933c7fdfa278e40915d Mon Sep 17 00:00:00 2001 From: amirisback Date: Sat, 19 Sep 2026 21:45:40 +0700 Subject: [PATCH 7/7] feat: implement mechanical keyboard sound engine and programmer coding keyboard layout (#68) - Add MechanicalSoundManager with low-latency SoundPool engine and acoustic profiles (Cherry MX Blue, Brown, Red, Typewriter) - Add Sound & Haptics settings dashboard (SoundActivity, SoundViewModel, SoundScreen) with interactive audio test keycaps - Add Codeboard-style Programmer keyboard layout (keys_letters_programmer.xml) with dedicated brackets header, Tab key, and arrow navigation - Support smart multiline Tab vs single-line focus navigation, cursor positioning in BaseKeyboardIME - Register Programmer layout in KeyboardLanguageViewModel - Add unit test coverage and SDLC workflow evidence --- app/src/main/AndroidManifest.xml | 4 + app/src/main/assets/text/dictionary_en.txt | 895 ++++++++++++++++++ .../appkeyboard/model/KeyboardFeatureType.kt | 1 + .../appkeyboard/services/KeyboardIME.kt | 134 ++- .../appkeyboard/services/KeyboardUtil.kt | 6 + .../suggestion/KeyboardSuggestionView.kt | 115 +++ .../suggestion/SuggestionResult.kt | 21 + .../suggestion/WordSuggestionEngine.kt | 294 ++++++ .../ui/language/KeyboardLanguageViewModel.kt | 9 + .../appkeyboard/ui/main/MainActivity.kt | 2 + .../appkeyboard/ui/main/MainScreen.kt | 12 +- .../appkeyboard/ui/sound/SoundActivity.kt | 47 + .../appkeyboard/ui/sound/SoundScreen.kt | 615 ++++++++++++ .../appkeyboard/ui/sound/SoundViewModel.kt | 88 ++ .../main/res/drawable/bg_suggestion_bar.xml | 13 + .../main/res/drawable/bg_suggestion_pill.xml | 6 + .../main/res/drawable/ic_close_suggestion.xml | 9 + app/src/main/res/drawable/ic_menu_sound.xml | 15 + .../main/res/drawable/ic_menu_suggestion.xml | 9 + .../res/drawable/ic_suggestion_switch.xml | 9 + app/src/main/res/layout/keyboard_ime.xml | 6 + .../res/layout/layout_keyboard_suggestion.xml | 135 +++ app/src/main/res/values-night/color.xml | 8 + app/src/main/res/values/color.xml | 8 + ...echanicalSoundAndProgrammerKeyboardTest.kt | 126 +++ .../appkeyboard/WordSuggestionEngineTest.kt | 114 +++ .../common/core/BaseKeyboardIME.kt | 36 + .../common/sound/MechanicalSoundManager.kt | 129 +++ .../libkeyboard/ui/main/ItemMainKeyboard.kt | 15 +- .../libkeyboard/ui/main/MainKeyboard.kt | 35 +- .../res/drawable/ic_keyboard_arrow_down.xml | 10 + .../res/drawable/ic_keyboard_arrow_up.xml | 10 + .../src/main/res/drawable/ic_keyboard_tab.xml | 10 + .../main/res/raw/mechanical_cherry_blue.wav | Bin 0 -> 4012 bytes .../main/res/raw/mechanical_cherry_brown.wav | Bin 0 -> 3572 bytes .../main/res/raw/mechanical_cherry_red.wav | Bin 0 -> 3130 bytes .../main/res/raw/mechanical_typewriter.wav | Bin 0 -> 5336 bytes .../main/res/xml/keys_letters_programmer.xml | 253 +++++ 38 files changed, 3172 insertions(+), 27 deletions(-) create mode 100644 app/src/main/assets/text/dictionary_en.txt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/suggestion/KeyboardSuggestionView.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/suggestion/SuggestionResult.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/suggestion/WordSuggestionEngine.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundActivity.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundScreen.kt create mode 100644 app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundViewModel.kt create mode 100644 app/src/main/res/drawable/bg_suggestion_bar.xml create mode 100644 app/src/main/res/drawable/bg_suggestion_pill.xml create mode 100644 app/src/main/res/drawable/ic_close_suggestion.xml create mode 100644 app/src/main/res/drawable/ic_menu_sound.xml create mode 100644 app/src/main/res/drawable/ic_menu_suggestion.xml create mode 100644 app/src/main/res/drawable/ic_suggestion_switch.xml create mode 100644 app/src/main/res/layout/layout_keyboard_suggestion.xml create mode 100644 app/src/test/java/com/frogobox/appkeyboard/MechanicalSoundAndProgrammerKeyboardTest.kt create mode 100644 app/src/test/java/com/frogobox/appkeyboard/WordSuggestionEngineTest.kt create mode 100644 frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/sound/MechanicalSoundManager.kt create mode 100644 frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_down.xml create mode 100644 frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_up.xml create mode 100644 frogo-keyboard/src/main/res/drawable/ic_keyboard_tab.xml create mode 100644 frogo-keyboard/src/main/res/raw/mechanical_cherry_blue.wav create mode 100644 frogo-keyboard/src/main/res/raw/mechanical_cherry_brown.wav create mode 100644 frogo-keyboard/src/main/res/raw/mechanical_cherry_red.wav create mode 100644 frogo-keyboard/src/main/res/raw/mechanical_typewriter.wav create mode 100644 frogo-keyboard/src/main/res/xml/keys_letters_programmer.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 397c09e..30cf068 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -41,6 +41,10 @@ android:name=".ui.theme.ThemeActivity" android:exported="false" /> + + diff --git a/app/src/main/assets/text/dictionary_en.txt b/app/src/main/assets/text/dictionary_en.txt new file mode 100644 index 0000000..c97d0d0 --- /dev/null +++ b/app/src/main/assets/text/dictionary_en.txt @@ -0,0 +1,895 @@ +the +be +to +of +and +a +in +that +have +i +it +for +not +on +with +he +as +you +do +at +this +but +his +by +from +they +we +say +her +she +or +an +will +my +one +all +would +there +their +what +so +up +out +if +about +who +get +which +go +me +when +make +can +like +time +no +just +him +know +take +people +into +year +your +good +some +could +them +see +other +than +then +now +look +only +come +its +over +think +also +back +after +use +two +how +our +work +first +well +way +even +new +want +because +any +these +give +day +most +us +is +are +was +were +been +has +had +did +doing +done +having +does +said +says +saying +went +gone +going +goes +got +getting +made +making +makes +took +taking +takes +knew +knowing +knows +came +coming +comes +looked +looking +looks +thought +thinking +thinks +saw +seen +seeing +sees +wanted +wanting +wants +gave +given +giving +gives +found +finding +finds +find +tell +told +telling +tells +asked +asking +asks +ask +worked +working +works +called +calling +calls +call +felt +feeling +feels +feel +tried +trying +tries +try +left +leaving +leaves +leave +helped +helping +helps +help +talked +talking +talks +talk +turned +turning +turns +turn +started +starting +starts +start +showed +showing +shows +show +heard +hearing +hears +hear +played +playing +plays +play +ran +running +runs +run +moved +moving +moves +move +liked +liking +likes +lived +living +lives +live +believed +believing +believes +believe +held +holding +holds +hold +brought +bringing +brings +bring +happened +happening +happens +happen +wrote +written +writing +writes +write +provided +providing +provides +provide +sat +sitting +sits +sit +stood +standing +stands +stand +lost +losing +loses +lose +paid +paying +pays +pay +met +meeting +meets +meet +included +including +includes +include +continued +continuing +continues +continue +set +setting +sets +learned +learning +learns +learn +changed +changing +changes +change +led +leading +leads +lead +understood +understanding +understands +understand +watched +watching +watches +watch +followed +following +follows +follow +stopped +stopping +stops +stop +created +creating +creates +create +spoke +spoken +speaking +speaks +speak +read +reading +reads +allowed +allowing +allows +allow +added +adding +adds +add +spent +spending +spends +spend +grew +growing +grows +grow +opened +opening +opens +open +walked +walking +walks +walk +won +winning +wins +win +offered +offering +offers +offer +remembered +remembering +remembers +remember +loved +loving +loves +love +considered +considering +considers +consider +appeared +appearing +appears +appear +bought +buying +buys +buy +waited +waiting +waits +wait +served +serving +serves +serve +died +dying +dies +die +sent +sending +sends +send +expected +expecting +expects +expect +built +building +builds +build +stayed +staying +stays +stay +fell +fallen +falling +falls +fall +cut +cutting +cuts +reached +reaching +reaches +reach +killed +killing +kills +kill +remained +remaining +remains +remain +suggest +suggested +suggesting +suggests +suggestion +suggestions +predict +predicted +predicting +predicts +prediction +predictions +correct +corrected +correcting +corrects +correction +autocorrect +keyboard +keyboards +keypad +typing +type +typed +types +android +google +kotlin +compose +developer +development +device +screen +layout +interface +system +mobile +application +software +hardware +computer +phone +message +messages +text +texts +notification +settings +toggle +feature +features +theme +color +background +button +input +connection +word +words +string +char +character +dictionary +search +query +result +results +candidate +candidates +hello +hi +hey +welcome +thanks +thank +please +sorry +excuse +morning +afternoon +evening +night +today +tomorrow +yesterday +weekend +monday +tuesday +wednesday +thursday +friday +saturday +sunday +january +february +march +april +may +june +july +august +september +october +november +december +home +office +school +university +college +student +teacher +friend +friends +family +brother +sister +father +mother +parent +child +children +person +people +group +team +company +business +market +product +service +money +price +order +cart +customer +client +support +account +user +password +login +signup +register +profile +setting +privacy +security +policy +terms +about +contact +info +information +help +faq +feedback +review +rating +star +stars +share +download +install +update +version +release +build +test +testing +tested +tester +bug +issue +fix +fixed +fixing +error +warning +exception +crash +failed +passed +success +successful +status +check +active +enable +disabled +enabled +disable +open +close +closed +cancel +confirm +accept +delete +remove +edit +editor +save +saved +saving +copy +paste +cut +select +all +none +clear +done +next +previous +back +forward +up +down +left +right +center +middle +top +bottom +start +end +first +last +min +max +small +medium +large +huge +fast +slow +easy +hard +simple +complex +clean +clear +true +false +yes +no +ok +okay +fine +great +awesome +cool +nice +super +wonderful +amazing +excellent +perfect +best +better +worse +worst +high +higher +highest +low +lower +lowest +long +longer +longest +short +shorter +shortest +big +bigger +biggest +little +few +many +much +more +less +most +least +both +either +neither +each +every +another +such +same +different +important +real +possible +able +available +public +private +personal +general +special +main +major +common +current +recent +popular +hot +new +old +young +free +busy +ready +sure +certain +safe +secure +strong +weak +rich +poor +happy +glad +sad +angry +excited +bored +tired +hungry +fresh +warm +hot +cold +cool +bright +dark +light +heavy +blue +green +red +yellow +orange +purple +pink +black +white +gray +brown +gold +silver +apple +orange +banana +fruit +coffee +tea +water +food +dinner +lunch +breakfast +restaurant +store +shop +game +movie +music +song +video +photo +picture +image +camera +audio +sound +news +web +website +internet +network +online +offline +link +page +post +comment +like +follow +view +play +pause +stop +record +player +podcast +book +read +write +note +notes +document +file +folder +data +database +table +list +grid +item +items +header +footer +dialog +sheet +tab +tabs +box +card +panel +window +view +viewmodel +activity +fragment +adapter +service +receiver +provider +broadcast +intent +permission +location +map +navigation +route +distance +speed +time +hour +minute +second +clock +alarm +calendar +event +date +weather +cloud +rain +sun +wind +snow +travel +trip +flight +hotel +car +bus +train +plane +ticket +city +country +place +world +earth +space +star +moon diff --git a/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardFeatureType.kt b/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardFeatureType.kt index a727add..0cba63f 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardFeatureType.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardFeatureType.kt @@ -20,6 +20,7 @@ enum class KeyboardFeatureType(val id: String, val text: String, val icon: Int) TEMPLATE_TEXT_SALE("menu_template_text_sale", "Sale Admin", R.drawable.ic_menu_ps_sale), TEMPLATE_TEXT_LOVE("menu_template_text_love", "Love Emoji", R.drawable.ic_menu_ps_love), TEMPLATE_TEXT_GREETING("menu_template_text_greeting", "Greeting", R.drawable.ic_menu_ps_greeting), + SUGGESTION("menu_suggestion", "Word Suggestion", R.drawable.ic_menu_suggestion), CHANGE_KEYBOARD("menu_change_keyboard", "Change Keyboard", R.drawable.ic_menu_keyboard), SETTING("menu_setting", "Setting", R.drawable.ic_menu_setting); diff --git a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt index 09fe9c6..5276f7a 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt @@ -5,6 +5,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager import android.widget.EditText import com.frogobox.appkeyboard.R @@ -15,6 +16,9 @@ import com.frogobox.appkeyboard.model.KeyboardFeatureType import com.frogobox.appkeyboard.model.ThemeType import com.frogobox.appkeyboard.ui.main.MainActivity import com.frogobox.libkeyboard.common.core.BaseKeyboardIME +import com.frogobox.libkeyboard.common.sound.MechanicalSoundManager +import com.frogobox.libkeyboard.common.sound.MechanicalSoundType +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard import com.frogobox.recycler.core.FrogoRecyclerNotifyListener import com.frogobox.recycler.core.IFrogoBindingAdapter import com.frogobox.recycler.ext.injectorBinding @@ -22,6 +26,7 @@ import com.frogobox.sdk.delegate.preference.PreferenceDelegates import com.frogobox.sdk.ext.getColorExt import com.frogobox.sdk.ext.gone import com.frogobox.sdk.ext.invisible +import com.frogobox.appkeyboard.suggestion.WordSuggestionEngine import com.frogobox.sdk.ext.visible import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -36,6 +41,9 @@ class KeyboardIME : BaseKeyboardIME() { @Inject lateinit var keyboardUtil: KeyboardUtil + @Inject + lateinit var suggestionEngine: WordSuggestionEngine + override fun setupViewBinding(): KeyboardImeBinding { return KeyboardImeBinding.inflate(LayoutInflater.from(this), null, false) } @@ -150,6 +158,10 @@ class KeyboardIME : BaseKeyboardIME() { this@KeyboardIME.binding?.keyboardTemplateText?.visible() } + KeyboardFeatureType.SUGGESTION -> { + this@KeyboardIME.showSuggestionBar() + } + KeyboardFeatureType.CHANGE_KEYBOARD -> { (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker() } @@ -198,7 +210,13 @@ class KeyboardIME : BaseKeyboardIME() { } } + override fun onWindowShown() { + super.onWindowShown() + applySoundAndHapticSettings() + } + override fun initialSetupKeyboard() { + applySoundAndHapticSettings() binding?.keyboardMain?.setKeyboard(keyboard!!) } @@ -232,12 +250,14 @@ class KeyboardIME : BaseKeyboardIME() { binding?.apply { keyboardMain.invisible() keyboardHeader.invisible() + keyboardSuggestion.gone() } } override fun showMainKeyboard() { binding?.apply { keyboardMain.visible() + keyboardSuggestion.gone() if (keyboardUtil.menuKeyboard().isEmpty()) { keyboardHeader.gone() } else { @@ -337,39 +357,99 @@ class KeyboardIME : BaseKeyboardIME() { } - override fun onKey(code: Int) { + private fun getActiveInputConnection(): InputConnection? { val formView = binding?.keyboardForm - var inputConnection = currentInputConnection - if (formView?.visibility == View.VISIBLE) { val et1 = formView.binding.etText - val et1Connection = et1.onCreateInputConnection(EditorInfo()) - val et2 = formView.binding.etText2 - val et2Connection = et2.onCreateInputConnection(EditorInfo()) - val et3 = formView.binding.etText3 - val et3Connection = et3.onCreateInputConnection(EditorInfo()) if (et1.isFocused) { - inputConnection = et1Connection + return et1.onCreateInputConnection(EditorInfo()) } else if (et2.isFocused) { - inputConnection = et2Connection + return et2.onCreateInputConnection(EditorInfo()) } else if (et3.isFocused) { - inputConnection = et3Connection + return et3.onCreateInputConnection(EditorInfo()) } - } else if (binding?.keyboardWebview?.visibility == View.VISIBLE) { - inputConnection = - binding?.keyboardWebview?.binding?.webview?.onCreateInputConnection(EditorInfo()) - } else { - inputConnection = currentInputConnection + return binding?.keyboardWebview?.binding?.webview?.onCreateInputConnection(EditorInfo()) + } + return currentInputConnection + } + + private fun getWordBeforeCursor(ic: InputConnection): String { + val text = ic.getTextBeforeCursor(40, 0)?.toString() ?: "" + return text.takeLastWhile { it.isLetterOrDigit() || it == '\'' } + } + + private fun setupSuggestionBar() { + binding?.keyboardSuggestion?.apply { + onCandidateSelected = { selectedWord, _ -> + val ic = getActiveInputConnection() + if (ic != null) { + val currentWord = getWordBeforeCursor(ic) + if (currentWord.isNotEmpty()) { + ic.deleteSurroundingText(currentWord.length, 0) + } + ic.commitText("$selectedWord ", 1) + clearSuggestions() + showFeatureHeader() + } + } + + onSwitchMenuClicked = { + showFeatureHeader() + } + + onCloseClicked = { + clearSuggestions() + showFeatureHeader() + } } - val ic = inputConnection ?: return + } + + private fun showSuggestionBar() { + binding?.apply { + keyboardHeader.gone() + keyboardSuggestion.visible() + } + } + + private fun showFeatureHeader() { + binding?.apply { + keyboardSuggestion.gone() + if (keyboardUtil.menuKeyboard().isEmpty()) { + keyboardHeader.gone() + } else { + keyboardHeader.visible() + } + } + } + + override fun onKey(code: Int) { + val ic = getActiveInputConnection() ?: return onKeyExt(code, ic) + + if (keyboardUtil.isSuggestionEnabled()) { + val word = getWordBeforeCursor(ic) + if (word.isNotEmpty()) { + val suggestions = suggestionEngine.getSuggestions(word) + binding?.keyboardSuggestion?.setSuggestions(suggestions) + showSuggestionBar() + } else { + binding?.keyboardSuggestion?.clearSuggestions() + if (code == ItemMainKeyboard.KEYCODE_SPACE || + code == ItemMainKeyboard.KEYCODE_ENTER || + code == ItemMainKeyboard.KEYCODE_DELETE) { + showFeatureHeader() + } + } + } } override fun initView() { + suggestionEngine.loadDictionaryFromAsset(this) + setupSuggestionBar() setupFeatureKeyboard() initBackToMainKeyboard() } @@ -396,4 +476,24 @@ class KeyboardIME : BaseKeyboardIME() { return pref.getPrefBoolean(key, true) } + private fun applySoundAndHapticSettings() { + val soundEnabled = pref.getPrefBoolean(MechanicalSoundManager.PREF_KEYBOARD_SOUND_ENABLED, true) + val soundType = pref.getPrefString( + MechanicalSoundManager.PREF_KEYBOARD_SOUND_TYPE, + MechanicalSoundType.CHERRY_MX_BLUE.id + ) + val soundVolumeInt = pref.getPrefInt(MechanicalSoundManager.PREF_KEYBOARD_SOUND_VOLUME, 80) + val vibrateEnabled = pref.getPrefBoolean(MechanicalSoundManager.PREF_KEYBOARD_VIBRATE_ENABLED, true) + + 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 + } + + override fun onDestroy() { + super.onDestroy() + MechanicalSoundManager.getInstance(this).release() + } + } \ 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 a42e0d2..62f2a3e 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardUtil.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardUtil.kt @@ -22,14 +22,20 @@ class KeyboardUtil @Inject constructor( const val KEYBOARD_TYPE = "KEYBOARD_TYPE" const val KEYBOARD_COLOR = "KEYBOARD_COLOR" const val KEYBOARD_COLOR_TYPE = "KEYBOARD_COLOR_TYPE" + const val KEYBOARD_SUGGESTION_ENABLED = "KEYBOARD_SUGGESTION_ENABLED" } private fun getStateToggle(key: String) : Boolean { return pref.getPrefBoolean(key, true) } + fun isSuggestionEnabled(): Boolean { + return getStateToggle(KeyboardFeatureType.SUGGESTION.id) + } + fun menuToggle(): List { return listOf( + KeyboardFeatureType.SUGGESTION.mapToModel(), KeyboardFeatureType.AUTO_TEXT.mapToModel(), KeyboardFeatureType.TEMPLATE_TEXT_APP.mapToModel(), KeyboardFeatureType.TEMPLATE_TEXT_GAME.mapToModel(), diff --git a/app/src/main/java/com/frogobox/appkeyboard/suggestion/KeyboardSuggestionView.kt b/app/src/main/java/com/frogobox/appkeyboard/suggestion/KeyboardSuggestionView.kt new file mode 100644 index 0000000..c0a90b3 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/suggestion/KeyboardSuggestionView.kt @@ -0,0 +1,115 @@ +package com.frogobox.appkeyboard.suggestion + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.FrameLayout +import com.frogobox.appkeyboard.databinding.LayoutKeyboardSuggestionBinding +import com.frogobox.sdk.ext.gone +import com.frogobox.sdk.ext.visible + +/** + * Custom Suggestion Strip View displaying the 3 candidate sections: + * Section 1: User's typed word + * Section 2: Predicted word (central hero) + * Section 3: Auto-correct word + */ +class KeyboardSuggestionView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + val binding: LayoutKeyboardSuggestionBinding = + LayoutKeyboardSuggestionBinding.inflate(LayoutInflater.from(context), this, true) + + private var currentResult: SuggestionResult = SuggestionResult.EMPTY + + var onCandidateSelected: ((selectedWord: String, candidateType: CandidateType) -> Unit)? = null + var onSwitchMenuClicked: (() -> Unit)? = null + var onCloseClicked: (() -> Unit)? = null + + enum class CandidateType { + USER_WORD, + PREDICTED_WORD, + AUTOCORRECT_WORD + } + + init { + setupListeners() + } + + private fun setupListeners() { + binding.sectionUserWord.setOnClickListener { + val word = currentResult.userWord + if (word.isNotEmpty()) { + onCandidateSelected?.invoke(word, CandidateType.USER_WORD) + } + } + + binding.sectionPredictedWord.setOnClickListener { + val word = currentResult.predictedWord + if (word.isNotEmpty()) { + onCandidateSelected?.invoke(word, CandidateType.PREDICTED_WORD) + } + } + + binding.sectionAutocorrectWord.setOnClickListener { + val word = currentResult.autoCorrectWord + if (word.isNotEmpty()) { + onCandidateSelected?.invoke(word, CandidateType.AUTOCORRECT_WORD) + } + } + + binding.btnSwitchMenu.setOnClickListener { + onSwitchMenuClicked?.invoke() + } + + binding.btnClearSuggestion.setOnClickListener { + onCloseClicked?.invoke() + } + } + + /** + * Updates the 3 suggestion candidates in real-time + */ + fun setSuggestions(result: SuggestionResult) { + currentResult = result + + // Section 1: User's literal word + if (result.userWord.isNotEmpty()) { + binding.tvUserWord.text = "“${result.userWord}”" + binding.sectionUserWord.visible() + binding.divider1.visible() + } else { + binding.tvUserWord.text = "" + binding.sectionUserWord.gone() + binding.divider1.gone() + } + + // Section 2: Predicted word + if (result.predictedWord.isNotEmpty()) { + binding.tvPredictedWord.text = result.predictedWord + binding.sectionPredictedWord.visible() + } else { + binding.tvPredictedWord.text = "" + binding.sectionPredictedWord.gone() + } + + // Section 3: Auto-correct word + if (result.autoCorrectWord.isNotEmpty() && result.autoCorrectWord != result.predictedWord) { + binding.tvAutocorrectWord.text = result.autoCorrectWord + binding.sectionAutocorrectWord.visible() + binding.divider2.visible() + } else { + binding.tvAutocorrectWord.text = "" + binding.sectionAutocorrectWord.gone() + binding.divider2.gone() + } + } + + fun clearSuggestions() { + setSuggestions(SuggestionResult.EMPTY) + } + +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/suggestion/SuggestionResult.kt b/app/src/main/java/com/frogobox/appkeyboard/suggestion/SuggestionResult.kt new file mode 100644 index 0000000..ff20c3a --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/suggestion/SuggestionResult.kt @@ -0,0 +1,21 @@ +package com.frogobox.appkeyboard.suggestion + +/** + * Data class representing the 3-section candidate suggestion model + * satisfying GitHub Issue #50: + * - Section 1: userWord (literal verbatim word typed by user) + * - Section 2: predictedWord (highest frequency completion or contextual prediction) + * - Section 3: autoCorrectWord (best typographical correction or alternative) + */ +data class SuggestionResult( + val userWord: String = "", + val predictedWord: String = "", + val autoCorrectWord: String = "" +) { + fun hasSuggestions(): Boolean = + userWord.isNotEmpty() || predictedWord.isNotEmpty() || autoCorrectWord.isNotEmpty() + + companion object { + val EMPTY = SuggestionResult("", "", "") + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/suggestion/WordSuggestionEngine.kt b/app/src/main/java/com/frogobox/appkeyboard/suggestion/WordSuggestionEngine.kt new file mode 100644 index 0000000..85d7dde --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/suggestion/WordSuggestionEngine.kt @@ -0,0 +1,294 @@ +package com.frogobox.appkeyboard.suggestion + +import android.content.Context +import java.io.BufferedReader +import java.io.InputStreamReader +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.abs +import kotlin.math.min + +/** + * Intelligent on-device Word Suggestion & Auto-Correction Engine + * Provides sub-millisecond candidate computation satisfying GitHub Issue #50: + * - Section 1: User's typed word (verbatim) + * - Section 2: Predicted word (prefix match / completion) + * - Section 3: Auto-correct word (edit distance / typo correction) + */ +@Singleton +class WordSuggestionEngine @Inject constructor() { + + // Common instant typo and shorthand substitution mapping + private val commonTypoMap = mapOf( + "teh" to "the", + "wht" to "what", + "adn" to "and", + "dont" to "don't", + "cant" to "can't", + "wont" to "won't", + "im" to "I'm", + "ive" to "I've", + "youre" to "you're", + "theyre" to "they're", + "alot" to "a lot", + "helo" to "hello", + "hw" to "how", + "pls" to "please", + "thx" to "thanks", + "ty" to "thank you", + "u" to "you", + "r" to "are", + "ur" to "your", + "recive" to "receive", + "recieve" to "receive", + "becuase" to "because", + "definately" to "definitely", + "seperate" to "separate", + "goverment" to "government", + "occured" to "occurred", + "untill" to "until", + "beleive" to "believe", + "frogo" to "Frogo" + ) + + // In-memory frequency-ranked dictionary + private val dictionaryList = CopyOnWriteArrayList() + private val dictionarySet = ConcurrentHashMap.newKeySet() + private val userCustomWords = ConcurrentHashMap.newKeySet() + + @Volatile + private var isDictionaryLoaded = false + + init { + loadBuiltinFallbackVocabulary() + } + + /** + * Fallback high-frequency words loaded synchronously so engine works immediately + */ + private fun loadBuiltinFallbackVocabulary() { + val fallbackWords = listOf( + "the", "be", "to", "of", "and", "a", "in", "that", "have", "i", + "it", "for", "not", "on", "with", "he", "as", "you", "do", "at", + "this", "but", "his", "by", "from", "they", "we", "say", "her", "she", + "or", "an", "will", "my", "one", "all", "would", "there", "their", "what", + "so", "up", "out", "if", "about", "who", "get", "which", "go", "me", + "when", "make", "can", "like", "time", "no", "just", "him", "know", "take", + "people", "into", "year", "your", "good", "some", "could", "them", "see", "other", + "than", "then", "now", "look", "only", "come", "its", "over", "think", "also", + "back", "after", "use", "two", "how", "our", "work", "first", "well", "way", + "even", "new", "want", "because", "any", "these", "give", "day", "most", "us", + "hello", "thanks", "please", "sorry", "today", "tomorrow", "keyboard", "android", + "apple", "application", "screen", "mobile", "message", "email", "happy", "great", + "help", "home", "call", "start", "stop", "test", "ready", "super", "cool", "fine" + ) + for (w in fallbackWords) { + addWordInternal(w) + } + } + + /** + * Loads extended dictionary from assets/text/dictionary_en.txt + */ + fun loadDictionaryFromAsset(context: Context) { + if (isDictionaryLoaded) return + try { + val assetManager = context.assets + val inputStream = assetManager.open("text/dictionary_en.txt") + BufferedReader(InputStreamReader(inputStream)).use { reader -> + var line: String? = reader.readLine() + while (line != null) { + val word = line.trim().lowercase() + if (word.isNotEmpty() && !dictionarySet.contains(word)) { + addWordInternal(word) + } + line = reader.readLine() + } + } + isDictionaryLoaded = true + } catch (_: Exception) { + // Gracefully keep fallback vocabulary if asset read fails + } + } + + private fun addWordInternal(word: String) { + if (!dictionarySet.contains(word)) { + dictionarySet.add(word) + dictionaryList.add(word) + } + } + + /** + * Allows dynamically learning or adding user custom words + */ + fun addUserWord(word: String) { + val trimmed = word.trim() + if (trimmed.length > 1) { + userCustomWords.add(trimmed.lowercase()) + addWordInternal(trimmed.lowercase()) + } + } + + /** + * Core suggestion generation returning the 3 required sections: + * - Section 1: User's verbatim word + * - Section 2: Predicted completion / highest rank prediction + * - Section 3: Auto-corrected candidate or alternative suggestion + */ + fun getSuggestions(input: String): SuggestionResult { + val trimmedInput = input.trim() + if (trimmedInput.isEmpty()) { + return SuggestionResult( + userWord = "", + predictedWord = matchCasing(input, "I"), + autoCorrectWord = matchCasing(input, "The") + ) + } + + val lowerQuery = trimmedInput.lowercase() + val userWord = trimmedInput + + // 1. Direct typo replacement check + val typoMatch = commonTypoMap[lowerQuery] + + // 2. Find prefix predictions + val prefixMatches = mutableListOf() + for (word in dictionaryList) { + if (word.startsWith(lowerQuery)) { + prefixMatches.add(word) + if (prefixMatches.size >= 5) break + } + } + + // 3. Compute predicted word (Section 2) + val predictedWordRaw = when { + typoMatch != null -> typoMatch + prefixMatches.isNotEmpty() -> { + // If the exact word is typed and there is a longer continuation, use continuation + if (prefixMatches[0] == lowerQuery && prefixMatches.size > 1) { + prefixMatches[1] + } else { + prefixMatches[0] + } + } + else -> findClosestCorrection(lowerQuery) ?: lowerQuery + } + + // 4. Compute auto-correct word (Section 3) + val autoCorrectWordRaw = when { + typoMatch != null -> { + // Typo was matched, provide alternative prefix or closest candidate + prefixMatches.firstOrNull { it != lowerQuery && it != typoMatch.lowercase() } + ?: findClosestCorrection(lowerQuery, exclude = listOf(typoMatch.lowercase())) + ?: lowerQuery + } + !dictionarySet.contains(lowerQuery) -> { + // Misspelled: find best correction different from predicted + findClosestCorrection(lowerQuery, exclude = listOf(predictedWordRaw.lowercase())) + ?: prefixMatches.firstOrNull { it != predictedWordRaw.lowercase() } + ?: lowerQuery + } + prefixMatches.size > 1 -> { + // Word is valid, provide alternative candidate + val secondOption = prefixMatches.firstOrNull { it != predictedWordRaw.lowercase() && it != lowerQuery } + secondOption ?: (prefixMatches.getOrNull(1) ?: lowerQuery) + } + else -> { + findClosestCorrection(lowerQuery, exclude = listOf(predictedWordRaw.lowercase())) + ?: lowerQuery + } + } + + return SuggestionResult( + userWord = userWord, + predictedWord = matchCasing(trimmedInput, predictedWordRaw), + autoCorrectWord = matchCasing(trimmedInput, autoCorrectWordRaw) + ) + } + + /** + * Finds closest word in dictionary within Levenshtein distance <= 2 + */ + private fun findClosestCorrection(query: String, exclude: List = emptyList()): String? { + if (query.isEmpty()) return null + var bestMatch: String? = null + var minDistance = Int.MAX_VALUE + + val maxAllowedDistance = if (query.length <= 3) 1 else 2 + + for (word in dictionaryList) { + if (exclude.contains(word) || word == query) continue + + // Quick length filter: skip words with length difference > maxAllowedDistance + if (abs(word.length - query.length) > maxAllowedDistance) continue + + // Bonus heuristic: prefer words sharing the first letter + val firstCharSame = word[0] == query[0] + val distance = calculateLevenshteinDistance(query, word) + + val adjustedDistance = if (firstCharSame) distance else distance + 1 + + if (distance <= maxAllowedDistance && adjustedDistance < minDistance) { + minDistance = adjustedDistance + bestMatch = word + if (distance == 1 && firstCharSame) { + // Fast break on high-confidence match + break + } + } + } + + return bestMatch + } + + /** + * Efficient iterative Levenshtein Distance calculation + */ + fun calculateLevenshteinDistance(s1: String, s2: String): Int { + val len1 = s1.length + val len2 = s2.length + + var prev = IntArray(len2 + 1) { it } + var curr = IntArray(len2 + 1) + + for (i in 1..len1) { + curr[0] = i + for (j in 1..len2) { + val cost = if (s1[i - 1] == s2[j - 1]) 0 else 1 + curr[j] = min( + min(curr[j - 1] + 1, prev[j] + 1), + prev[j - 1] + cost + ) + } + val temp = prev + prev = curr + curr = temp + } + + return prev[len2] + } + + /** + * Adapts candidate string casing to match the user's input style: + * - UPPERCASE if input is all caps (e.g., "HEL" -> "HELLO") + * - TitleCase if input is capitalized (e.g., "Hel" -> "Hello") + * - lowercase otherwise (e.g., "hel" -> "hello") + */ + fun matchCasing(source: String, candidate: String): String { + if (source.isEmpty() || candidate.isEmpty()) return candidate + return when { + source.length > 1 && source.all { it.isLetter() && it.isUpperCase() } -> { + candidate.uppercase() + } + source.first().isUpperCase() -> { + candidate.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + } + else -> { + candidate.lowercase() + } + } + } + +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageViewModel.kt index 83e3506..a096b1a 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageViewModel.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageViewModel.kt @@ -43,6 +43,15 @@ class KeyboardLanguageViewModel @Inject constructor( script = "Latin" ) ) + list.add( + KeyboardLanguage( + name = "Programmer / Coding (QWERTY)", + xml = com.frogobox.libkeyboard.R.xml.keys_letters_programmer, + layoutType = "Codeboard", + code = "DEV", + script = "Code & Symbols" + ) + ) list.add( KeyboardLanguage( name = "${context.getString(com.frogobox.libkeyboard.R.string.translation_english)} (QWERTZ)", 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 a51455f..41ad94b 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 @@ -12,6 +12,7 @@ import androidx.compose.runtime.setValue import com.frogobox.appkeyboard.common.base.BaseComposeMainActivity import com.frogobox.appkeyboard.ui.autotext.AutoTextActivity import com.frogobox.appkeyboard.ui.language.KeyboardLanguageActivity +import com.frogobox.appkeyboard.ui.sound.SoundActivity import com.frogobox.appkeyboard.ui.test.TestActivity import com.frogobox.appkeyboard.ui.theme.ThemeActivity import com.frogobox.appkeyboard.ui.toggle.ToggleActivity @@ -89,6 +90,7 @@ class MainActivity : BaseComposeMainActivity() { onNavigateToggle = { startActivityExt() }, onNavigateLanguage = { startActivityExt() }, onNavigateTheme = { startActivityExt() }, + onNavigateSound = { startActivityExt() }, onNavigateTest = { startActivityExt() } ) } 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 95063d5..3e18982 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 @@ -90,6 +90,7 @@ fun MainScreen( onNavigateToggle: () -> Unit, onNavigateLanguage: () -> Unit, onNavigateTheme: () -> Unit, + onNavigateSound: () -> Unit, onNavigateTest: () -> Unit, modifier: Modifier = Modifier ) { @@ -252,11 +253,18 @@ fun MainScreen( MainMenuItemCard( title = "Toggle Function", - subtitle = "Sound, vibration & auto-capitalization switches", + subtitle = "Keyboard shortcuts & toolbar switches", iconRes = R.drawable.ic_menu_form, onClick = onNavigateToggle ) + MainMenuItemCard( + title = "Sound & Haptics", + subtitle = "Mechanical switch sounds & key vibration", + iconRes = R.drawable.ic_menu_sound, + onClick = onNavigateSound + ) + MainMenuItemCard( title = "Keyboard Language", subtitle = "Multilingual input languages & layouts", @@ -440,6 +448,7 @@ fun MainScreenActivePreview() { onNavigateToggle = {}, onNavigateLanguage = {}, onNavigateTheme = {}, + onNavigateSound = {}, onNavigateTest = {} ) } @@ -457,6 +466,7 @@ fun MainScreenInactiveDarkPreview() { onNavigateToggle = {}, onNavigateLanguage = {}, onNavigateTheme = {}, + onNavigateSound = {}, onNavigateTest = {} ) } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundActivity.kt new file mode 100644 index 0000000..62e609b --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundActivity.kt @@ -0,0 +1,47 @@ +package com.frogobox.appkeyboard.ui.sound + +import android.os.Bundle +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.frogobox.appkeyboard.common.base.BaseComposeActivity +import com.frogobox.libkeyboard.common.sound.MechanicalSoundType +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class SoundActivity : BaseComposeActivity() { + + private val viewModel: SoundViewModel by viewModels() + + private var soundEnabled by mutableStateOf(true) + private var selectedSoundType by mutableStateOf(MechanicalSoundType.CHERRY_MX_BLUE) + private var soundVolume by mutableStateOf(80) + private var vibrateEnabled by mutableStateOf(true) + + override fun onCreateExt(savedInstanceState: Bundle?) { + super.onCreateExt(savedInstanceState) + viewModel.soundEnabled.observe(this) { soundEnabled = it } + viewModel.selectedSoundType.observe(this) { selectedSoundType = it } + viewModel.soundVolume.observe(this) { soundVolume = it } + viewModel.vibrateEnabled.observe(this) { vibrateEnabled = it } + viewModel.loadSettings() + } + + @Composable + override fun Content() { + SoundScreen( + soundEnabled = soundEnabled, + selectedSoundType = selectedSoundType, + soundVolume = soundVolume, + vibrateEnabled = vibrateEnabled, + onSoundEnabledChange = { viewModel.setSoundEnabled(it) }, + onSoundTypeSelected = { viewModel.setSoundType(it) }, + onVolumeChange = { viewModel.setSoundVolume(it) }, + onVibrateChange = { viewModel.setVibrateEnabled(it) }, + onTestKeyTap = { viewModel.playTestKeySound() }, + onBackClick = { finish() } + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundScreen.kt new file mode 100644 index 0000000..75b2b8e --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundScreen.kt @@ -0,0 +1,615 @@ +package com.frogobox.appkeyboard.ui.sound + +import android.view.HapticFeedbackConstants +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +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.PaddingValues +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.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +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.platform.LocalView +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +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 +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar +import com.frogobox.libkeyboard.common.sound.MechanicalSoundType + +@Composable +fun SoundScreen( + soundEnabled: Boolean, + selectedSoundType: MechanicalSoundType, + soundVolume: Int, + vibrateEnabled: Boolean, + onSoundEnabledChange: (Boolean) -> Unit, + onSoundTypeSelected: (MechanicalSoundType) -> Unit, + onVolumeChange: (Int) -> Unit, + onVibrateChange: (Boolean) -> Unit, + onTestKeyTap: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + val view = LocalView.current + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Keyboard Sound & Haptics", + onBackClick = onBackClick + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 28.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Master Sound Feedback Switch Card + item(span = { GridItemSpan(maxLineSpan) }) { + MasterSoundCard( + soundEnabled = soundEnabled, + selectedSoundType = selectedSoundType, + onSoundEnabledChange = onSoundEnabledChange + ) + } + + // Section Header: Switch Profiles + item(span = { GridItemSpan(maxLineSpan) }) { + Column(modifier = Modifier.padding(top = 8.dp, bottom = 2.dp)) { + Text( + text = "MECHANICAL SWITCH PROFILES (MECHVIBES)", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + letterSpacing = 0.8.sp + ), + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Authentic tactile & acoustic mechanical switch models", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Grid of Switch Profiles + items(MechanicalSoundType.entries.toList(), key = { it.id }) { type -> + SoundProfileCard( + profile = type, + isSelected = selectedSoundType == type, + onClick = { + onSoundTypeSelected(type) + if (vibrateEnabled) { + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + } + } + ) + } + + // Section Header: Volume & Haptics + item(span = { GridItemSpan(maxLineSpan) }) { + Column(modifier = Modifier.padding(top = 10.dp, bottom = 2.dp)) { + Text( + text = "VOLUME & HAPTIC FEEDBACK", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + letterSpacing = 0.8.sp + ), + color = MaterialTheme.colorScheme.primary + ) + } + } + + // Volume Card + item(span = { GridItemSpan(maxLineSpan) }) { + VolumeControlCard( + volume = soundVolume, + soundEnabled = soundEnabled, + onVolumeChange = onVolumeChange + ) + } + + // Haptic Vibration Card + item(span = { GridItemSpan(maxLineSpan) }) { + HapticVibrationCard( + vibrateEnabled = vibrateEnabled, + onVibrateChange = { enabled -> + onVibrateChange(enabled) + if (enabled) { + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + } + } + ) + } + + // Interactive Mechanical Sound Playground + item(span = { GridItemSpan(maxLineSpan) }) { + LiveSoundPlaygroundCard( + selectedSoundType = selectedSoundType, + soundEnabled = soundEnabled, + onKeyTap = { + onTestKeyTap() + if (vibrateEnabled) { + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + } + } + ) + } + } + } +} + +@Composable +private fun MasterSoundCard( + soundEnabled: Boolean, + selectedSoundType: MechanicalSoundType, + onSoundEnabledChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val primaryColor = MaterialTheme.colorScheme.primary + val cardBackground by animateColorAsState( + targetValue = if (soundEnabled) primaryColor.copy(alpha = 0.08f) else MaterialTheme.colorScheme.surface, + label = "cardBg" + ) + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = cardBackground), + border = BorderStroke( + width = 1.dp, + color = if (soundEnabled) primaryColor.copy(alpha = 0.4f) else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(46.dp) + .clip(CircleShape) + .background( + if (soundEnabled) FrogoPrimary.copy(alpha = 0.15f) + else MaterialTheme.colorScheme.surfaceVariant + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = null, + tint = if (soundEnabled) FrogoPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Mechanical Sound Feedback", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 15.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = if (soundEnabled) "Active: ${selectedSoundType.title}" else "Acoustic feedback muted", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Switch( + checked = soundEnabled, + onCheckedChange = onSoundEnabledChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = FrogoPrimary + ) + ) + } + } +} + +@Composable +private fun SoundProfileCard( + profile: MechanicalSoundType, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val primaryColor = MaterialTheme.colorScheme.primary + val borderColor by animateColorAsState( + targetValue = if (isSelected) primaryColor else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), + label = "border" + ) + val containerColor by animateColorAsState( + targetValue = if (isSelected) primaryColor.copy(alpha = 0.08f) else MaterialTheme.colorScheme.surface, + label = "container" + ) + + Card( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = containerColor), + border = BorderStroke(if (isSelected) 1.5.dp else 1.dp, borderColor), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = if (isSelected) FrogoPrimary.copy(alpha = 0.15f) else MaterialTheme.colorScheme.surfaceVariant + ) { + Text( + text = when (profile) { + MechanicalSoundType.CHERRY_MX_BLUE -> "CLICKY" + MechanicalSoundType.CHERRY_MX_BROWN -> "TACTILE" + MechanicalSoundType.CHERRY_MX_RED -> "LINEAR" + MechanicalSoundType.TYPEWRITER -> "VINTAGE" + MechanicalSoundType.SYSTEM_CLICK -> "SYSTEM" + MechanicalSoundType.OFF -> "MUTE" + }, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 9.sp + ), + color = if (isSelected) FrogoPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + + if (isSelected) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Selected", + tint = FrogoStatusSuccess, + modifier = Modifier.size(18.dp) + ) + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = profile.title, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = profile.description, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun VolumeControlCard( + volume: Int, + soundEnabled: Boolean, + onVolumeChange: (Int) -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Acoustic Volume", + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + + Text( + text = if (soundEnabled) "$volume%" else "Muted", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + color = FrogoPrimary + ) + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + + Slider( + value = volume.toFloat(), + onValueChange = { onVolumeChange(it.toInt()) }, + valueRange = 5f..100f, + enabled = soundEnabled, + colors = SliderDefaults.colors( + thumbColor = FrogoPrimary, + activeTrackColor = FrogoPrimary, + inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) + } + } +} + +@Composable +private fun HapticVibrationCard( + vibrateEnabled: Boolean, + onVibrateChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(42.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.PhoneAndroid, + contentDescription = null, + tint = if (vibrateEnabled) FrogoPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Keypress Vibration (Haptic)", + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Subtle tactile pulse on key touch", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Switch( + checked = vibrateEnabled, + onCheckedChange = onVibrateChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = FrogoPrimary + ) + ) + } + } +} + +@Composable +private fun LiveSoundPlaygroundCard( + selectedSoundType: MechanicalSoundType, + soundEnabled: Boolean, + onKeyTap: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) + ), + border = BorderStroke(1.dp, FrogoPrimary.copy(alpha = 0.3f)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "LIVE MECHANICAL KEYBOARD PLAYGROUND", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + letterSpacing = 0.8.sp + ), + color = FrogoPrimary + ) + + Surface( + shape = RoundedCornerShape(6.dp), + color = FrogoPrimary.copy(alpha = 0.15f) + ) { + Text( + text = "TEST ACOUSTICS", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 9.sp + ), + color = FrogoPrimary, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = "Tap keycaps below to experience ${selectedSoundType.title} acoustics and tactile vibration:", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(14.dp)) + + // Simulated Keycaps Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + listOf("Q", "W", "E", "R").forEach { keyChar -> + PlaygroundKeycap( + label = keyChar, + onClick = onKeyTap, + modifier = Modifier.weight(1f) + ) + } + + PlaygroundKeycap( + label = "SPACE", + onClick = onKeyTap, + modifier = Modifier.weight(2f) + ) + + PlaygroundKeycap( + label = "ENTER", + onClick = onKeyTap, + modifier = Modifier.weight(1.5f), + isAction = true + ) + } + } + } +} + +@Composable +private fun PlaygroundKeycap( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isAction: Boolean = false +) { + Surface( + modifier = modifier + .height(44.dp) + .clip(RoundedCornerShape(10.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(10.dp), + color = if (isAction) FrogoPrimary else MaterialTheme.colorScheme.surface, + border = BorderStroke( + 1.dp, + if (isAction) FrogoPrimary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ), + shadowElevation = 2.dp + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = if (label.length > 2) 11.sp else 14.sp + ), + color = if (isAction) Color.White else MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundViewModel.kt new file mode 100644 index 0000000..2092b52 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundViewModel.kt @@ -0,0 +1,88 @@ +package com.frogobox.appkeyboard.ui.sound + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.frogobox.libkeyboard.common.sound.MechanicalSoundManager +import com.frogobox.libkeyboard.common.sound.MechanicalSoundType +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard +import com.frogobox.sdk.delegate.preference.PreferenceDelegates +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class SoundViewModel @Inject constructor( + application: Application, + private val pref: PreferenceDelegates +) : AndroidViewModel(application) { + + private val _soundEnabled = MutableLiveData() + val soundEnabled: LiveData = _soundEnabled + + private val _selectedSoundType = MutableLiveData() + val selectedSoundType: LiveData = _selectedSoundType + + private val _soundVolume = MutableLiveData() + val soundVolume: LiveData = _soundVolume + + private val _vibrateEnabled = MutableLiveData() + val vibrateEnabled: LiveData = _vibrateEnabled + + init { + loadSettings() + } + + fun loadSettings() { + val soundOn = pref.getPrefBoolean(MechanicalSoundManager.PREF_KEYBOARD_SOUND_ENABLED, true) + val soundTypeId = pref.getPrefString( + MechanicalSoundManager.PREF_KEYBOARD_SOUND_TYPE, + MechanicalSoundType.CHERRY_MX_BLUE.id + ) + val volume = pref.getPrefInt(MechanicalSoundManager.PREF_KEYBOARD_SOUND_VOLUME, 80) + val vibrateOn = pref.getPrefBoolean(MechanicalSoundManager.PREF_KEYBOARD_VIBRATE_ENABLED, true) + + _soundEnabled.value = soundOn + _selectedSoundType.value = MechanicalSoundType.fromId(soundTypeId) + _soundVolume.value = volume + _vibrateEnabled.value = vibrateOn + + ItemMainKeyboard.SOUND_ON_KEYPRESS = soundOn + ItemMainKeyboard.MECHANICAL_SOUND_TYPE = soundTypeId + ItemMainKeyboard.SOUND_VOLUME = volume / 100f + ItemMainKeyboard.VIBRATE_ON_KEYPRESS = vibrateOn + } + + fun setSoundEnabled(enabled: Boolean) { + pref.savePrefBoolean(MechanicalSoundManager.PREF_KEYBOARD_SOUND_ENABLED, enabled) + ItemMainKeyboard.SOUND_ON_KEYPRESS = enabled + _soundEnabled.value = enabled + } + + fun setSoundType(type: MechanicalSoundType) { + pref.savePrefString(MechanicalSoundManager.PREF_KEYBOARD_SOUND_TYPE, type.id) + ItemMainKeyboard.MECHANICAL_SOUND_TYPE = type.id + _selectedSoundType.value = type + playTestKeySound() + } + + fun setSoundVolume(volume: Int) { + pref.savePrefInt(MechanicalSoundManager.PREF_KEYBOARD_SOUND_VOLUME, volume) + ItemMainKeyboard.SOUND_VOLUME = volume / 100f + _soundVolume.value = volume + } + + fun setVibrateEnabled(enabled: Boolean) { + pref.savePrefBoolean(MechanicalSoundManager.PREF_KEYBOARD_VIBRATE_ENABLED, enabled) + ItemMainKeyboard.VIBRATE_ON_KEYPRESS = enabled + _vibrateEnabled.value = enabled + } + + fun playTestKeySound() { + val isSoundOn = _soundEnabled.value ?: true + if (!isSoundOn) return + val soundType = _selectedSoundType.value ?: MechanicalSoundType.CHERRY_MX_BLUE + val volume = (_soundVolume.value ?: 80) / 100f + MechanicalSoundManager.getInstance(getApplication()).playKeySound(soundType, volume) + } +} diff --git a/app/src/main/res/drawable/bg_suggestion_bar.xml b/app/src/main/res/drawable/bg_suggestion_bar.xml new file mode 100644 index 0000000..5736f68 --- /dev/null +++ b/app/src/main/res/drawable/bg_suggestion_bar.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_suggestion_pill.xml b/app/src/main/res/drawable/bg_suggestion_pill.xml new file mode 100644 index 0000000..6af3416 --- /dev/null +++ b/app/src/main/res/drawable/bg_suggestion_pill.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_close_suggestion.xml b/app/src/main/res/drawable/ic_close_suggestion.xml new file mode 100644 index 0000000..fc0a6e1 --- /dev/null +++ b/app/src/main/res/drawable/ic_close_suggestion.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu_sound.xml b/app/src/main/res/drawable/ic_menu_sound.xml new file mode 100644 index 0000000..c4fde8e --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_sound.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_menu_suggestion.xml b/app/src/main/res/drawable/ic_menu_suggestion.xml new file mode 100644 index 0000000..e6c052a --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_suggestion.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_suggestion_switch.xml b/app/src/main/res/drawable/ic_suggestion_switch.xml new file mode 100644 index 0000000..0629f82 --- /dev/null +++ b/app/src/main/res/drawable/ic_suggestion_switch.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/keyboard_ime.xml b/app/src/main/res/layout/keyboard_ime.xml index c897c68..553303b 100644 --- a/app/src/main/res/layout/keyboard_ime.xml +++ b/app/src/main/res/layout/keyboard_ime.xml @@ -23,6 +23,12 @@ android:orientation="vertical" app:layout_constraintBottom_toBottomOf="parent"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/color.xml b/app/src/main/res/values-night/color.xml index c3b87e0..d24e8ee 100644 --- a/app/src/main/res/values-night/color.xml +++ b/app/src/main/res/values-night/color.xml @@ -12,4 +12,12 @@ #D81B60 #FDD835 + + #1E232A + #B0BEC5 + #4FC3F7 + #0D47A1 + #ECEFF1 + #37474F + \ No newline at end of file diff --git a/app/src/main/res/values/color.xml b/app/src/main/res/values/color.xml index 1746e8e..4c9034d 100644 --- a/app/src/main/res/values/color.xml +++ b/app/src/main/res/values/color.xml @@ -12,4 +12,12 @@ #C2185B #FBC02D + + #F4F6F8 + #546E7A + #0288D1 + #E1F5FE + #37474F + #E0E0E0 + \ No newline at end of file diff --git a/app/src/test/java/com/frogobox/appkeyboard/MechanicalSoundAndProgrammerKeyboardTest.kt b/app/src/test/java/com/frogobox/appkeyboard/MechanicalSoundAndProgrammerKeyboardTest.kt new file mode 100644 index 0000000..03a606c --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/MechanicalSoundAndProgrammerKeyboardTest.kt @@ -0,0 +1,126 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.ui.language.KeyboardLanguage +import com.frogobox.libkeyboard.common.sound.MechanicalSoundType +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for Mechanical Sound Switch Profiles (Mechvibes) + * and Programmer / Coding Keyboard (Codeboard style). + */ +class MechanicalSoundAndProgrammerKeyboardTest { + + @Test + fun testMechanicalSoundTypeResolution() { + assertEquals(MechanicalSoundType.CHERRY_MX_BLUE, MechanicalSoundType.fromId("cherry_mx_blue")) + assertEquals(MechanicalSoundType.CHERRY_MX_BROWN, MechanicalSoundType.fromId("cherry_mx_brown")) + assertEquals(MechanicalSoundType.CHERRY_MX_RED, MechanicalSoundType.fromId("cherry_mx_red")) + assertEquals(MechanicalSoundType.TYPEWRITER, MechanicalSoundType.fromId("typewriter")) + assertEquals(MechanicalSoundType.SYSTEM_CLICK, MechanicalSoundType.fromId("system")) + assertEquals(MechanicalSoundType.OFF, MechanicalSoundType.fromId("off")) + + // Case insensitivity + assertEquals(MechanicalSoundType.CHERRY_MX_BLUE, MechanicalSoundType.fromId("CHERRY_MX_BLUE")) + assertEquals(MechanicalSoundType.TYPEWRITER, MechanicalSoundType.fromId("Typewriter")) + + // Fallback for null or unknown id + assertEquals(MechanicalSoundType.CHERRY_MX_BLUE, MechanicalSoundType.fromId(null)) + assertEquals(MechanicalSoundType.CHERRY_MX_BLUE, MechanicalSoundType.fromId("unknown_switch")) + } + + @Test + fun testMechanicalSoundTypeResources() { + val mechanicalProfiles = listOf( + MechanicalSoundType.CHERRY_MX_BLUE, + MechanicalSoundType.CHERRY_MX_BROWN, + MechanicalSoundType.CHERRY_MX_RED, + MechanicalSoundType.TYPEWRITER + ) + + for (profile in mechanicalProfiles) { + assertTrue("Raw resource ID should be valid for $profile", profile.rawResId > 0) + assertTrue("Title should not be empty for $profile", profile.title.isNotBlank()) + assertTrue("Description should not be empty for $profile", profile.description.isNotBlank()) + } + + assertEquals(0, MechanicalSoundType.OFF.rawResId) + assertEquals(-1, MechanicalSoundType.SYSTEM_CLICK.rawResId) + } + + @Test + fun testProgrammerKeycodes() { + assertEquals(-7, ItemMainKeyboard.KEYCODE_TAB) + assertEquals(-8, ItemMainKeyboard.KEYCODE_ARROW_LEFT) + assertEquals(-9, ItemMainKeyboard.KEYCODE_ARROW_RIGHT) + assertEquals(-10, ItemMainKeyboard.KEYCODE_ARROW_UP) + assertEquals(-11, ItemMainKeyboard.KEYCODE_ARROW_DOWN) + + // Ensure keycodes are unique and negative + val keycodes = listOf( + ItemMainKeyboard.KEYCODE_SHIFT, + ItemMainKeyboard.KEYCODE_MODE_CHANGE, + ItemMainKeyboard.KEYCODE_ENTER, + ItemMainKeyboard.KEYCODE_DELETE, + ItemMainKeyboard.KEYCODE_EMOJI, + ItemMainKeyboard.KEYCODE_TAB, + ItemMainKeyboard.KEYCODE_ARROW_LEFT, + ItemMainKeyboard.KEYCODE_ARROW_RIGHT, + ItemMainKeyboard.KEYCODE_ARROW_UP, + ItemMainKeyboard.KEYCODE_ARROW_DOWN + ) + + assertEquals(keycodes.size, keycodes.toSet().size) + assertTrue(keycodes.all { it < 0 }) + } + + @Test + fun testProgrammerKeyboardMetadataAndSearch() { + val programmerKeyboard = KeyboardLanguage( + name = "Programmer / Coding (QWERTY)", + xml = com.frogobox.libkeyboard.R.xml.keys_letters_programmer, + layoutType = "Codeboard", + code = "DEV", + script = "Code & Symbols" + ) + + assertEquals("Programmer / Coding (QWERTY)", programmerKeyboard.name) + assertEquals("Codeboard", programmerKeyboard.layoutType) + assertEquals("DEV", programmerKeyboard.code) + assertEquals("Code & Symbols", programmerKeyboard.script) + + val languages = listOf( + KeyboardLanguage("English (QWERTY)", 1, "QWERTY", "EN", "Latin"), + programmerKeyboard + ) + + // Search by 'programmer' + val searchProgrammer = languages.filter { it.name.lowercase().contains("programmer") } + assertEquals(1, searchProgrammer.size) + assertEquals("DEV", searchProgrammer[0].code) + + // Search by 'codeboard' + val searchCodeboard = languages.filter { it.layoutType.lowercase().contains("codeboard") } + assertEquals(1, searchCodeboard.size) + + // Search by 'dev' + val searchDev = languages.filter { it.code.lowercase().contains("dev") } + assertEquals(1, searchDev.size) + } + + @Test + fun testVolumeClamping() { + fun clampVolume(rawVolumePercent: Int): Float { + return (rawVolumePercent / 100f).coerceIn(0.05f, 1.0f) + } + + assertEquals(0.8f, clampVolume(80), 0.001f) + assertEquals(0.05f, clampVolume(0), 0.001f) + assertEquals(0.05f, clampVolume(-20), 0.001f) + assertEquals(1.0f, clampVolume(100), 0.001f) + assertEquals(1.0f, clampVolume(150), 0.001f) + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/WordSuggestionEngineTest.kt b/app/src/test/java/com/frogobox/appkeyboard/WordSuggestionEngineTest.kt new file mode 100644 index 0000000..eb9440f --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/WordSuggestionEngineTest.kt @@ -0,0 +1,114 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.suggestion.WordSuggestionEngine +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit test suite for WordSuggestionEngine + * Validates three-section candidates (Issue #50): + * - Section 1: user's literal word + * - Section 2: predicted word + * - Section 3: auto-correct word + */ +class WordSuggestionEngineTest { + + private lateinit var engine: WordSuggestionEngine + + @Before + fun setup() { + engine = WordSuggestionEngine() + } + + @Test + fun testThreeSectionIntegrity() { + val result = engine.getSuggestions("th") + assertNotNull(result) + assertEquals("th", result.userWord) + assertTrue(result.predictedWord.isNotEmpty()) + assertTrue(result.autoCorrectWord.isNotEmpty()) + assertTrue(result.hasSuggestions()) + } + + @Test + fun testPrefixPrediction() { + val result = engine.getSuggestions("appl") + assertEquals("appl", result.userWord) + assertEquals("apple", result.predictedWord.lowercase()) + } + + @Test + fun testCommonTypoAutoCorrection() { + // Test "teh" -> "the" + val resultThe = engine.getSuggestions("teh") + assertEquals("teh", resultThe.userWord) + assertEquals("the", resultThe.predictedWord.lowercase()) + + // Test "wht" -> "what" + val resultWhat = engine.getSuggestions("wht") + assertEquals("wht", resultWhat.userWord) + assertEquals("what", resultWhat.predictedWord.lowercase()) + + // Test "helo" -> "hello" + val resultHello = engine.getSuggestions("helo") + assertEquals("helo", resultHello.userWord) + assertEquals("hello", resultHello.predictedWord.lowercase()) + } + + @Test + fun testCasingPreservationLowercase() { + val result = engine.getSuggestions("appl") + assertEquals("appl", result.userWord) + assertEquals("apple", result.predictedWord) + } + + @Test + fun testCasingPreservationTitleCase() { + val result = engine.getSuggestions("Appl") + assertEquals("Appl", result.userWord) + assertEquals("Apple", result.predictedWord) + } + + @Test + fun testCasingPreservationUppercase() { + val result = engine.getSuggestions("APPL") + assertEquals("APPL", result.userWord) + assertEquals("APPLE", result.predictedWord) + } + + @Test + fun testLevenshteinDistanceCalculation() { + assertEquals(0, engine.calculateLevenshteinDistance("test", "test")) + assertEquals(1, engine.calculateLevenshteinDistance("hello", "helo")) + assertEquals(1, engine.calculateLevenshteinDistance("apply", "apple")) + assertEquals(3, engine.calculateLevenshteinDistance("kitten", "sitting")) + } + + @Test + fun testUserCustomWordLearning() { + engine.addUserWord("FrogoDeveloper") + val result = engine.getSuggestions("frogodev") + assertEquals("frogodev", result.userWord) + assertEquals("frogodeveloper", result.predictedWord.lowercase()) + } + + @Test + fun testEmptyInputHandling() { + val result = engine.getSuggestions("") + assertNotNull(result) + assertEquals("", result.userWord) + assertTrue(result.predictedWord.isNotEmpty()) + } + + @Test + fun testBlankWhitespaceInputHandling() { + val result = engine.getSuggestions(" ") + assertNotNull(result) + assertEquals("", result.userWord) + } + +} diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt index 276e133..16aea89 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt @@ -287,7 +287,43 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo ItemMainKeyboard.KEYCODE_EMOJI -> { runEmojiBoard() } + ItemMainKeyboard.KEYCODE_TAB -> { + val isMultiline = (currentInputEditorInfo?.inputType ?: 0) and InputType.TYPE_TEXT_FLAG_MULTI_LINE != 0 + if (isMultiline) { + inputConnection.commitText("\t", 1) + } else { + inputConnection.sendKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_TAB) + ) + inputConnection.sendKeyEvent( + KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_TAB) + ) + } + } + ItemMainKeyboard.KEYCODE_ARROW_LEFT -> { + moveCursor(false) + } + ItemMainKeyboard.KEYCODE_ARROW_RIGHT -> { + moveCursor(true) + } + ItemMainKeyboard.KEYCODE_ARROW_UP -> { + inputConnection.sendKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP) + ) + inputConnection.sendKeyEvent( + KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_UP) + ) + } + ItemMainKeyboard.KEYCODE_ARROW_DOWN -> { + inputConnection.sendKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN) + ) + inputConnection.sendKeyEvent( + KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_DOWN) + ) + } else -> { + if (code <= 0) return var codeChar = code.toChar() if (Character.isLetter(codeChar) && kb.mShiftState > SHIFT_OFF) { codeChar = Character.toUpperCase(codeChar) diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/sound/MechanicalSoundManager.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/sound/MechanicalSoundManager.kt new file mode 100644 index 0000000..a90a937 --- /dev/null +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/sound/MechanicalSoundManager.kt @@ -0,0 +1,129 @@ +package com.frogobox.libkeyboard.common.sound + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.SoundPool +import android.util.Log +import androidx.annotation.RawRes +import com.frogobox.libkeyboard.R +import java.util.concurrent.ConcurrentHashMap +import kotlin.random.Random + +/** + * Mechanical Keyboard Switch Profiles (Mechvibes compatible) + */ +enum class MechanicalSoundType( + val id: String, + val title: String, + val description: String, + @RawRes val rawResId: Int +) { + OFF("off", "Off (Mute)", "Silent typing with no acoustic feedback", 0), + SYSTEM_CLICK("system", "System Default Click", "Native Android system keyclick sound effect", -1), + CHERRY_MX_BLUE("cherry_mx_blue", "Cherry MX Blue", "Crisp tactile click with high-frequency snap", R.raw.mechanical_cherry_blue), + CHERRY_MX_BROWN("cherry_mx_brown", "Cherry MX Brown", "Balanced tactile bump with rounded acoustic thock", R.raw.mechanical_cherry_brown), + CHERRY_MX_RED("cherry_mx_red", "Cherry MX Red", "Smooth linear travel with clean bottom-out thock", R.raw.mechanical_cherry_red), + TYPEWRITER("typewriter", "Classic Typewriter", "Vintage mechanical typewriter strike & carriage clink", R.raw.mechanical_typewriter); + + companion object { + fun fromId(id: String?): MechanicalSoundType { + return entries.firstOrNull { it.id.equals(id, ignoreCase = true) } ?: CHERRY_MX_BLUE + } + } +} + +/** + * Low-latency SoundPool Audio Engine for Mechanical Keyboard feedback. + */ +class MechanicalSoundManager private constructor(context: Context) { + + companion object { + private const val TAG = "MechanicalSoundManager" + const val PREF_KEYBOARD_SOUND_ENABLED = "KEYBOARD_SOUND_ENABLED" + const val PREF_KEYBOARD_SOUND_TYPE = "KEYBOARD_SOUND_TYPE" + const val PREF_KEYBOARD_SOUND_VOLUME = "KEYBOARD_SOUND_VOLUME" + const val PREF_KEYBOARD_VIBRATE_ENABLED = "KEYBOARD_VIBRATE_ENABLED" + + @Volatile + private var INSTANCE: MechanicalSoundManager? = null + + fun getInstance(context: Context): MechanicalSoundManager { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: MechanicalSoundManager(context.applicationContext).also { INSTANCE = it } + } + } + } + + private val appContext = context.applicationContext + private val audioManager = appContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + private var soundPool: SoundPool? = null + private val soundIdMap = ConcurrentHashMap() + private val loadedSounds = ConcurrentHashMap() + + init { + initSoundPool() + } + + private fun initSoundPool() { + val audioAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + + soundPool = SoundPool.Builder() + .setMaxStreams(8) + .setAudioAttributes(audioAttributes) + .build().apply { + setOnLoadCompleteListener { _, sampleId, status -> + if (status == 0) { + loadedSounds[sampleId] = true + } + } + } + + preloadSounds() + } + + private fun preloadSounds() { + val pool = soundPool ?: return + for (type in MechanicalSoundType.entries) { + if (type.rawResId > 0) { + try { + val soundId = pool.load(appContext, type.rawResId, 1) + soundIdMap[type] = soundId + } catch (e: Exception) { + Log.e(TAG, "Failed to load sound buffer for $type", e) + } + } + } + } + + fun playKeySound( + soundType: MechanicalSoundType = MechanicalSoundType.CHERRY_MX_BLUE, + volume: Float = 0.8f + ) { + if (soundType == MechanicalSoundType.OFF) return + + if (soundType == MechanicalSoundType.SYSTEM_CLICK) { + audioManager?.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD, volume) + return + } + + val pool = soundPool ?: return + val soundId = soundIdMap[soundType] ?: return + + // Slight human acoustic jitter (+/- 2% pitch variation) + val pitchVariation = 0.98f + Random.nextFloat() * 0.04f + val clampedVol = volume.coerceIn(0.05f, 1.0f) + + pool.play(soundId, clampedVol, clampedVol, 1, 0, pitchVariation) + } + + fun release() { + soundPool?.release() + soundPool = null + soundIdMap.clear() + loadedSounds.clear() + } +} diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt index 816d3d4..7b3099f 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt @@ -68,12 +68,23 @@ class ItemMainKeyboard { const val KEYCODE_DELETE = -5 const val KEYCODE_SPACE = 32 const val KEYCODE_EMOJI = -6 + const val KEYCODE_TAB = -7 + const val KEYCODE_ARROW_LEFT = -8 + const val KEYCODE_ARROW_RIGHT = -9 + const val KEYCODE_ARROW_UP = -10 + const val KEYCODE_ARROW_DOWN = -11 /** Whether haptic feedback is enabled on key press. Set via [MainKeyboard.vibrateOnKeypress]. */ - var VIBRATE_ON_KEYPRESS = false + var VIBRATE_ON_KEYPRESS = true /** Whether key sound feedback is enabled on key press. Set via [MainKeyboard.soundOnKeypress]. */ - var SOUND_ON_KEYPRESS = false + var SOUND_ON_KEYPRESS = true + + /** Selected mechanical switch sound profile id. Default is Cherry MX Blue */ + var MECHANICAL_SOUND_TYPE = "cherry_mx_blue" + + /** Sound volume multiplier (0.05f to 1.0f) */ + var SOUND_VOLUME = 0.8f const val SHOW_POPUP_ON_KEYPRESS = true diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt index 013ef81..f265cfc 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt @@ -32,12 +32,19 @@ import androidx.core.content.res.ResourcesCompat import com.frogobox.libkeyboard.R import com.frogobox.libkeyboard.common.ext.adjustAlpha import com.frogobox.libkeyboard.common.ext.applyColorFilter +import com.frogobox.libkeyboard.common.sound.MechanicalSoundManager +import com.frogobox.libkeyboard.common.sound.MechanicalSoundType +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_ARROW_DOWN +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_ARROW_LEFT +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_ARROW_RIGHT +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_ARROW_UP import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_DELETE import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_EMOJI import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_ENTER import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_MODE_CHANGE import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_SHIFT import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_SPACE +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_TAB import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.MAX_KEYS_PER_MINI_ROW import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.SHIFT_OFF import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.SHIFT_ON_ONE_CHAR @@ -304,10 +311,11 @@ class MainKeyboard @JvmOverloads constructor( } } - /** Plays a standard key click sound if sound feedback is enabled. */ + /** Plays mechanical keyboard sound if sound feedback is enabled. */ fun playSoundIfNeeded() { if (ItemMainKeyboard.SOUND_ON_KEYPRESS) { - mAudioManager?.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD) + val soundType = MechanicalSoundType.fromId(ItemMainKeyboard.MECHANICAL_SOUND_TYPE) + MechanicalSoundManager.getInstance(context).playKeySound(soundType, ItemMainKeyboard.SOUND_VOLUME) } } @@ -442,7 +450,12 @@ class MainKeyboard @JvmOverloads constructor( KEYCODE_SHIFT, KEYCODE_DELETE, KEYCODE_ENTER, - KEYCODE_MODE_CHANGE -> { + KEYCODE_MODE_CHANGE, + KEYCODE_TAB, + KEYCODE_ARROW_LEFT, + KEYCODE_ARROW_RIGHT, + KEYCODE_ARROW_UP, + KEYCODE_ARROW_DOWN -> { keyBackground = resources.getDrawable(R.drawable.keypad_action, context.theme) } @@ -505,7 +518,9 @@ class MainKeyboard @JvmOverloads constructor( } key.icon?.let { icon -> - if (code == KEYCODE_DELETE || code == KEYCODE_SHIFT || code == KEYCODE_EMOJI || code == KEYCODE_ENTER) { + if (code == KEYCODE_DELETE || code == KEYCODE_SHIFT || code == KEYCODE_EMOJI || code == KEYCODE_ENTER || + code == KEYCODE_TAB || code == KEYCODE_ARROW_LEFT || code == KEYCODE_ARROW_RIGHT || + code == KEYCODE_ARROW_UP || code == KEYCODE_ARROW_DOWN) { icon.applyColorFilter(mTextColor) } @@ -572,16 +587,17 @@ class MainKeyboard @JvmOverloads constructor( if (mCurrentKeyIndex != NOT_A_KEY && keys.size > mCurrentKeyIndex) { val newKey = keys[mCurrentKeyIndex] + val keyCode = newKey.code - val code = newKey.code - if (code == KEYCODE_SHIFT || code == KEYCODE_MODE_CHANGE || code == KEYCODE_DELETE || code == KEYCODE_ENTER || code == KEYCODE_SPACE) { + if (keyCode == KEYCODE_SHIFT || keyCode == KEYCODE_MODE_CHANGE || keyCode == KEYCODE_DELETE || keyCode == KEYCODE_ENTER || keyCode == KEYCODE_SPACE || + keyCode == KEYCODE_TAB || keyCode == KEYCODE_ARROW_LEFT || keyCode == KEYCODE_ARROW_RIGHT || keyCode == KEYCODE_ARROW_UP || keyCode == KEYCODE_ARROW_DOWN) { newKey.pressed = true } invalidateKey(mCurrentKeyIndex) sendAccessibilityEventForUnicodeCharacter( AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, - code + keyCode ) } } @@ -704,6 +720,11 @@ class MainKeyboard @JvmOverloads constructor( KEYCODE_ENTER -> context.getString(R.string.keycode_enter) KEYCODE_MODE_CHANGE -> context.getString(R.string.keycode_mode_change) KEYCODE_SHIFT -> context.getString(R.string.keycode_shift) + KEYCODE_TAB -> "Tab" + KEYCODE_ARROW_LEFT -> "Left Arrow" + KEYCODE_ARROW_RIGHT -> "Right Arrow" + KEYCODE_ARROW_UP -> "Up Arrow" + KEYCODE_ARROW_DOWN -> "Down Arrow" else -> code.toChar().toString() } event.text.add(text) diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_down.xml b/frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_down.xml new file mode 100644 index 0000000..50613c2 --- /dev/null +++ b/frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_down.xml @@ -0,0 +1,10 @@ + + + diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_up.xml b/frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_up.xml new file mode 100644 index 0000000..bd1add9 --- /dev/null +++ b/frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_tab.xml b/frogo-keyboard/src/main/res/drawable/ic_keyboard_tab.xml new file mode 100644 index 0000000..a79155f --- /dev/null +++ b/frogo-keyboard/src/main/res/drawable/ic_keyboard_tab.xml @@ -0,0 +1,10 @@ + + + diff --git a/frogo-keyboard/src/main/res/raw/mechanical_cherry_blue.wav b/frogo-keyboard/src/main/res/raw/mechanical_cherry_blue.wav new file mode 100644 index 0000000000000000000000000000000000000000..11354cf6abb13d25016c52c2f62e695b66fc66f4 GIT binary patch literal 4012 zcmWMp2T&B(8-MTZ-d^DjuFydQ1kqqY1+gPaKoo(XA!<-jL5;E3Skb7lps3Lh(I|?& zMkDzLyP}Dxh=?dCNRi^v%iY0kd+)#V&CYzYv$MbVE8jQs;v&Pt&)Na-U1vZag9Pd zn10GHVVAVtF{d{M@hyBN-Afb*(PU}Lgx9EaMYHmxA-mTB|GQp) z_@xHl3vw7YB5vjEgY!=>vo1HR4_I8Xur@Iz@l{-0bVyuQtRcEEX2!Ux;K@T)d0%kR ztGAG`){LgMCeqkYT~+nfr|6F{uO>WO_ay15Ik)Qxoxizw(TB9p{ftT@+u~?(@182G zfKzOn?ALo;?sFyJO>p3t#8Aiam%?KsCVia|88s<0$oJu2LaRn52K?l=!gI2t zT)S8P1eaK+xBqP%Se08W%kO;Ledp0%u~*|SDbF1|V>siQm33;-sg8jl4kByHDb7wp~^WV1TTz+k#^SkU1PbxZUA{zc|9cF&Y_9ChDedQwC zPx@aRHLlm(1&?1mbG*F0&0b5qd_6-vjBZxv84gK0ttL*9NJZgcEHW3h-f4JLi>elv zd6h)HZF#MH_27l(`Il$wp51shFYo2^=Px4*X1!sH-99}0^!)PxV{OZ8Qw!??MlZ~@UOgTj2BCFQy(7vSktA^NmQ;p;2M^(+0S(RTZ&sTL;U#lsun{S-bbhy>2 z^SvdPeIUq9cNwy4J;y-jTonb8}jDEFWDx1o}N^xuK<=@DA$b+$+`l zpw~dpMIMvg%Uryi{Oy76x+YBZf{CFj$aW!;9n-bYRNTI&^-yzXW;)lo z8r`!zw4VLFy1ahy-sK(Ro$FQO`FGDz9$VZ~T*IBq9KsCyv^ko$s(QJVwnw34x6saR z>mFwrZd%+?*_PgVq@}ERN%I8h`PjU#WozrrHn)zP&TZ!9U7M|^xnjYVd=1x7M`Xw3 z+mxX;#p*!YS$31OtTtJfrdy%Y>VDL2w@a|KsuOJXDbwT&WS-Om5Q+2n@oa8al)14Z zt8G)umZs|s2IGzT)Vhnc^4e=P$7%{{B5Rd(>iXC(g~mONtDDnW3)%xrS1iM=Z@J~7 z5*$Sivi0)&$}*b*%^^Dv-70;mVZJ?d_}*cSLySX{{UrO9h9rG&-CeuBw(HcFRWB4} zOg-I>5cDEb#7thrWmx07hjm3;GR&^#B2%uZ$u!CQ*?iS(Z8`&1afyn{G7jX*}Lo)0o(#ZEkPIEkSLE+BKc$ zOj9hn?lS9TZoM!L6LJ}jrS8&BOsssKB1)-LWvE!yS2q1@uLeNrACd|9t+0TXbGNPAx|6#WSW?aJOcAEK&g-4$I-hsCnU0$x%7wt@UgY zZxv49$$&w*)CQTq{Iz1Ps=_8j^RsQ1-5=VeItTqheYu{~H|Q_xN9)h(sC zd1_X*LD?XWVlL2tngQ>VzWBTl%vZ7Jth>9@x^gTE%T}|Oxye*v;!LB>=gosH)t2jB z>D}qp%j|28@I%EF_zI~23gk-#(7rOr7qO^Rnu&=J-V*YQh7bw^?+WqM}1YQAQ9 z-X(O8XOD3r|ASbHBfu@_Of9Ex%i0(_g`*Ox3RD|y81+*1Ep@T_jrxi@NzL1=wkcHU zRil(~ig@{CW`wLKji`E903MRN_+PPF&2nMT=st@}81Yx~N=Lg7T4azOq#@LvdAJ%P@?+ zOh*$`4ex(+Ho>^g2?ENSM!W{c^)>7B`B8eraK=FGb-{ks~v za=L%F?qs)cDf~`hzjzp@lLKHYoQHxbmbyTXl|7R6Wqx9=G0&Jh<~p;98O*$qeJ?Ab zhtUV9kH{Ux!~NhX;c+nDAU+ekggrbZDLjy?XK%3?Y&v^YV&~0m<;>h>-c`sGei6UH zenbWuKq34G{XuP_r^>vTYUWq@V8sJPkTP9asnn|asQgrFRjG2HvXAnb!b7o1p2yHk zplmw5mfDX_!EEr7)MEt>6c-DB@d|!9*TimQz1VW=pVp&NUAKO;y0OdHcWe|_Bq@u8 z3&Io;;ahkv(SU3a2fx5&s1e0eHzl3{bR0d0PN2urj&upNlk%o+pn)h0+Q3B;Hy5%P zKM)P#65$me%wOU9ad+7ntPNXWJ!?&~?zf(@KC@D69GlJhaaXtyzLHNBLPa9x;yol5 zIKy&y48>7^&Z37&Y6deWnNDVie6DkMtlZ__H`c%$EM1 z#ryF&Ts#LQo;%suY$Q9LjbVS3yewkfxm8>#7r{T~!-Q8tg4ibRz%Jw}83pn{G|Y#g z=n7I%GpRFF86~4V>Hf4o?M91K5p|rJPN}K?p=rnj_rf0VItT$pWC0=gBp#33#S7v> zF;LWq9YT#zEwl)X=r7I`kBhZpFg}dCCEwnXaBv?Ch1a1EN=GtkG4+b_rB~7a&~3D< zY`AQqEL!%ptiMblE1{3lF*GM(4xyf)$*2g%!8afpydslHA)bco#f_q~_&``D_zRu< zzx+A=5Wkl{$Y=2nc_Z&GOcyeRR$;t&LKMVV_%ZHH_K-#r0Zsuk7z=m8Txfwk(HInm z643W31`R__s0H4Jo8fTS1~S1UU?ImzsFct&JWN8%5aUF>_(8ZV><|_T-wCn8RAHX5 zSvV~e3QBR3_?u`HL-8q0kwv7Ki~*N{J=_GV;8=7VwW5(!3U!BSrVMldJ&vA8M@X~h zNn=UV6I3GQMirtI^p(VMEp&zV!CasKf00C@Cok|G9D{pcv-noLCtepXiPywCVxi=j z10ILh;y?QQI0%Y?Bb);F!H2LJ>QMj+M^n)Z6ptpNAmofX;lJ<@jEDBp zF53=*KpiIsY=yyrOL^M*Gh)Q@7E`)l z9EPXhAMkd32It`p>_MW*&*UG{MtXzU;3#+v7&rp{2#>+1uo0?J9~6SVK{L@@sS>0v z1ocH4)C}`rCR_!>paE8at6&`%59~k%xkggRRMMX)NrR+d4nB{M;a~AV=?us5MSNR& z_XX3$hlEQhJt(18NL$<=d?UG-E^X98U<4x2Ko^NqFX#o`pq+I2ZU%29?nl8|5DWTB N_wf>GS8OHmb*^Am9iV%*~nmgpK*d_SpVYg~r3t?YCjgBjN|oi`IU0C^0xW zDEe45o!vOgJuNe1@8Z<-JM#;(7O&X5z+v&Z6}f5SGxD>lrk_u*Pa#uxrmvd*JmJ># z`ia_PznIF=UE#OF_70yHnB^DmUF3N@V313!Ym)kuqCm3=91|oOO|MGWvQE6vddt|< zZ0>YuHg&YNqUPXwug3me^P8@;d$jfKDfy6Y7;7qSkLoNmc$nwNX0%E%MwN-Ks$w+D z6ssN5H3OZNx*v2L;5y4?pqH!mUf=ERobx-~AeVQ2l5{SPcU0X@7x~$`S9}v1NOQOw z@??W^Pprwe^KjcA4Pgxn>Pp^z_UcMyM1||CozFgblJ$F6ss8@$2P^L1D&BJ|wB+qS|rS2y0Bd9(2DsC%tN+WW5`PI@%|sY69+nOmi`y1u&T-4_il z?Ro9Ky^p0O4D9dWb&cL7#PztB+Ben5CqOYk)PFYOe2{r~e#pLY#o;+oeAM*`Pd=U& z*Eec=?1N~xk1xd*M_!IO6gexRBuX6JGFA$^6zV-(7t$x#RUaBSBk+N5rf*We%O1%d zKJKE+25q9AymuXS+|KxZCiT-dr$UgdJDQ+j6=I!4DklNG25u?KG%J| z=d$6UDar&*b*64{iku>6lWFn^_&xfW-^>r;-V4)I4ZY>a8R zQ&;^y^-c1hC;uvaHUCxfOZSRzD!j}6o`3StlzW9lAc~kv>hoOs^k0Rqrw6i$P91TIWc~8&@nS2u5MJJK>_zP*0B#Pa( z4BJI(wmH%woAY`T%+GpTdk^*QGw-ulE$x<6>kF%c7%a||;^n6}msHSLa03iQ2B_nl zg;{*AfEBM5!KyfAu`)unTy;d1qpDHms)E%=RP&U(6?#ReB2Spaf5TlzdqD?vpf&P* zX^QA#``FUht2cdTEa@59ZRq@Odq%se?PTk;=Kf8^jiVdV>krl)uXU*Pu1&1D^nOZB zP)%&j*qTW-uC)QRrM2O8Kh$q;FgI*$LM?W!fo)0c%^kbC%-zsvGu<&?v5pmI%CTey z7>+pJAZS%Ts53RY9rU_gj`yAHUG}=pbgOiC^r-Rp)>HDV?K{KkYp+Hxz4v49listv zIq&&i`CixiI`ma~MtHRL!R}>lO1IT6$DIA0e$^e&-f@_0@1-eKm#ZcyXDPx2D;Lc9 zqc%`SN7I!g5m(7bE|rSJb>bW`M!acjvQ^t2+Ai6QHdoP6JS|=jv!s7Yf_z9`iH$gi zxX^1f02G5L=!cTfXl@o)#1-(z`A&X{@U^gCunIQeXW@;oTeu*s76OEPo^mBzJDQFj zz+ku^?4-8{#c$+BsaEt5Lv0b(NfwPcsJEYKk@1vaX-`$ROSh(LcV~BpXUEp|sP@vf zthThax({zZ6n`*((6*(t-EaG??elg;$FYu}&Z5pOUE8{6_IzSUG#)S=>^)*`une$m z7dJ?6^M26avkoW-;89}Ka1vabx~*HVS_TzV?~S8|j!a;m&fz9RE@5x$Li zGMBs{K6E9mp_9N7;0rV1H8=pRM5mFG8^Qg9JI$TtUUMDXpIkXt$z9|QacSI8?j<_K zuc!F;0HJd4dO6&jn5H66m^Q#N-vf}Sdf2mifZK_QwpxULRid=4%p8*jlU@e|yI1ITo;lRPFW8biOL zzflBYz!p#fWDpJ4!V|C>3P_LQ&@8kZZ9+THF7!3ph!&v)6od#YhsWSz=*@WC0m48n z-AjklGO~c!@NS0dO*u)fk=97|(m63sY_V;%4YR$oeq)WZV#`xYz9rKVVd-ztSj=Xt z8Jm@szLqhT49gD7Lkq{o9I&=qV{8{}S}|8Nic6#xDO)zlYcV9di8K8d9R_ZKa99cx z&=VBTJ>*97XLv{Eo|nQ9#acy)0xHKVS1OMx?vM_e}N#@#{lkp>mQ4Cn;!gB&mne4rO-4vnPt^ewqbz9(y# z3MZ3sB#ew8A!ICxAt_`b*-Q?TLh_PWNk1A-v*{1?0d1qMU;@Yl`QR7u4nXJ&$H5tJ zCEN!0!+*ga;dyuto`nT44}QVsi-!Xt1+T$*un{BzC-9mcrD@cQR*~7uk+8ZS9WZ^awp9&wpCO^gtSh~A>R=qh@O0b+`ykZ6%VI7X(JsE4g&*7Wa~1(7_=X~MFY7k?mXAT`S8iC z$NtFw&bRY|;3epV(ZX0ETnH0_1RudpF!5FVE&c$X&ByZYd?R;_+ssYmoVY4<49!D> zQ3t%r_BR2lVFfq_mVpSM1r78LJwR8}PiYwSrFPUxTFHA-MgAb=Y(>?imb4KYQM26- zXK1F=HFO_6Pk*I#lu!>a5=;e)nO2X0pFt@@tQqt&bTy1Ici0EIv2Qgbpa;}|GNzQ{ zAQvnEvA`c-`kG#%+vqGhj8Zn!8M2ln5Kq#Mi}Ck(DUQM3*dkZTg)ECU%bDygBU%oX zhsXouezKn&AP31~AKsX2h_Mn&kMT_YfHtQ0a#MpAA2F73^IYc(G z42mN|i8GOKBQD4H@Fjc#@5ejv*LVY7hu5;tMwUjq@Bw@ZU&W=k3U^`!@gtFBHdAH+ zDIzt5r+PY#?dTXSWqxu7W58!@edoY4#;*$ug(=KWdGO!x9;}3|P=Xrdi2_kD8jT{* zcvg{UEDB|b)gSdiY9zuBunPVPuQ1nbhuO@HkuZ=c#{jBXkNywX3)ZsdPXwXB7uW$S zZKAK}WBLpIiJqc|X+GUWx6@pfmjACUOi4Q#(+3#lXPK^U(Gprlt7!x6rZQEsKGP5A z!6>GJM3BN*n#(l19IOIsz`DO{4Z|@TWPy3?UNVShz6b_>z?uEKF)&u1Gj`6i6xqu7 zN~aU)Nb1ktt|aD@*W?k)p&!W+mWEr%I+9H?8PaKFCP^hT*fEWrWwNX5NG{n!j*<)H z4k=?unwX#Z(NT=0g-ihl>1C#udWxwVn>PVuFy8lqe}lWA95ezeu!CMu568ebIF0FT zK3u|Z&S4#NJ*%~FHCzF+*wr+K|3sE*AHe|TJr%TpR`4fy%JS|UIK(=`If$ZFU{0CrD<00A|~n3p-viJbvprvy4KRTsL;K8x@l7!umk7tQ5ENOqjf&I zZn~kmqdM1$TNS%sW>p@0RrxyR&CR!I?>%ceYHRCS8x&1(Et}h(xA!s}?n35u)(&AI zuBH>w1HOlByQ19D%_&Kpu37Gu?lHuR>z4mntj`%=vtRds=)i!W)}VvIt|2Qz&WGFy zxe&5C#3Lj-xFaYyC?>FbK$l;xZ!e!?pMBJ7y&^rr+*~yuoO4tOjxXgir2pjx!_!2t zkF}jLw{;CL>}juV?caQ|QBl9GR#E-S+vF<#m9FAW`Ry`Ysr-3bNzv2PC-Cv}M|b}% zE#wPlJ*+C&R4}w4tYAn%W?>voZPvvW!F0knw#&U!udnW?Ysa5@f68ntYaP~F+Onc0w8h%| zzPYNot=X+*LQ7tYqIG+#bKB*%*`NH{JK9S-?&z-@Zg-Y+H5wJ>zLpH@37bxE5y#^L zVJNqge2hT?|2#_vC7HEnG^(30O;)`r>#b!^d> z7<{|d8DE$3Tx0 z?rm<-ZketZG_PEU+DF~bd6ZL{Dnq%{ah1aw#d`TSvMlK~$!`7tcM@HM55QY$CxK)f z-Y(v^OYD<`3pPjFT5GFiiN$Q*XYOsTG95BaHU*ogvB6krEH}O}wi}hEzNW8Cd8TGl ztoeZ1U{13du_oA_+NKIE!WO$$ye_6xWOwh1${tkIVpdT_mmuy zXr#NOM(GUMEt!jauKbL=UfxYHT(L;8U2#}(Lh+MguVSrYsv=B*3zu%$qhb~Kgtm_58a2}FcTD0Kf00>U=2fni26tR6Z&=fS^ClXB>f2eB>h7DPW@$lwO(tOX*g#P z4bwXR=?v{U+2v_GZVWK}VM;W=HE*=|SPQMoZQjB&#(6;g>TTjnRvlLsh{DHIN?9CQxhj@ulcIl3rEE4L`~m8HrSC1%-nR7q7f zWxevT@^|G%WwKJOEOXrJ81LBb@RLKFLzQBU!clQb-dlcCHdOXRI!5|JGEq{_PvXnD zNt_N%L9gL#*Z`ITfqqZB(LYEsX~4Vi7r0W~DGm^g_N(@F_5{1y-Y7f}P7Aw*HNscI z9ATC)S6C#h7xoFi3k5=hptAS3FS7q^uduUT6c33nMPK|C{tE*#j^vUK5>F4(IvNGG zgZ~15xD?)CkDTFX4=P3uToSj5`;)8XRD3j_#&6({@cH~dd@--%U-6ZE8DGTzhyRV= z%P;50@I82ud(P!>3%Fj~Cv*`lL;qIITq zz4e6kp0(NPWgBJNV!LIt*kXmvLZRSnpJ~sxWBUa0tVqPE_zG5%g`|)K(9N`pMuP*O z1th{8Xn@1fany`rxb0jq=fbBlc4fQ+OX5mNuH>miFL9MdN>ilsrJ2%g(tXlH(j#o` zWBW{Lx^%2GTB?=WSRSrOc1vbT!X$+0a+IILcjsHVKe$y~U(STCqctcR8R0dU3Hw45 zxB)hRLF|eClpdlp>F3l;9+6{YDM=(g#D-ttoA?yofxpHx@n}2*N8>Qu6MxQD7~2oQ zDR??wg1^Pt_!2I{br=$VGL&SH-Q*G}Cjtqiqv$G{LksCA>dx-70Q>;1f!Bb5KsXF8 zfIDCwya!*yPN+aWC<4W!v1mFOcW=Ji^XkX4x2RPVyCEKtf%2D{5yVz z&Dfu$kagrYQbs5VW9sdtH)#{&nFv;bQw*0G1i%!ynmPLbHZq327>~th7s^9-kdFC` zkrU_3vKhn0bIIHoZag=E)mUyMHyliJ160cs1}tl-*VZN%g`hg!*sx~8s3Me z;Z`^gCP07oa#juQfn4w{m;>TLAmBk0EunwXqby+=bPSE5fz+9bq?NoS&&dOFom^zN ze`Zb}A&1C8wvLdW$Vu`mIZv*VJEVxYS3}wfCMxPf!)XGYM8BjP>3;evy+Mm<4K-0Y z@Bxt^2}}b^Ko-mJuiz>u0A-*Cd;$WHKsD6D02m6xSkjgmp0`BbHvX$!SU3!+ygN=;GSQ^WCD5;gyky3{8Jj1+` ztR-KPX=D@`M50I!;!E6#6Oj{+P>ivN?d+3cL}Wxo+=w^nP9n%aHn*m*lxC9eNH#f3 zZZS&I0vS}5loTZ&=0ym z1iL^T(1AkM9eLm|*alXC`7F~3OyNMF0UR*Xj|@!-y+{9IdYoj6?4nt8EnQB(qI2nV zI+;#j$W!P@noLKqN@krmnvP?>O=ZcPPZzS2Ygig~&>vVTa_KpGmENI`SjSY+2HHVw zlmjZ@2?Ch%(O@u02ID~*m{f92lCm|=qlzjH@u!hrFSbC>+s!hjIM z5QAY!#Xp!DKCpvLv_KeHX<==*O%4?kvnAp6d|6;diLQGR|skNX- zd6V7d`Y^0-&r^M-buDVu*<-RM8vjb2l}~0v)tsD_X?0JXUu^wQQ2vI_HDq`>xEHC9 z*v3@1D85(VoINKJh% zKKK;B_TtfVy2}TX4!l~B8(WoC=V6>&)G_nyn@y>eDMISye_KCUlKk@K&a3{{m)#rw zN|ig_7-^0(_pdXTzRjDOot%}DeLTDNQ}UahF9$qc{y6qkTILT`cjQs(P={*uA=GHo zn9>`u$-%s~X>M&$`SY&@`Gv)0b?eX?mqP(GI3_qF;HFog>nGA71Q)2k|aOd#)Z+t^11b+2h;pOV%(IPV}s#{P$&A^QV@_OCqvatQGu%b|1 zXtUNifwTR_xd%C8r*aqKlhbm^H!VBnbm;NTfL7B2#`+xa4Dy)jmg0NcZEr6Z zqITNpE*YK;UdisiIXzQ{a93%TGEu%@t82PZv$Q;_xU%TavTHS4%!%@3TB3Bc`7|Ch zzN;uLdtVw{^5N^7FP%QWeLw$Q?uW#zo?ipX$5v-ln=67#A_^{j$o=RGFVXZhm+2&FZ)NK8EK!FPu~{ z(>T25Z1vp==d#3-l#&ajVP#uOw-?uZbuXAyu%&2hMQmN1<%#S?pOF{xR(nfZfX&_R zCq+u3_F(HUbA?H=T$TeltLA`Hs>?!GFBidiuD-@`kA9aEbDHFI!SRA_m-c(zDSftU zy?0E25*XUDXHc6K%>wHDKl)E-_D3_{W-I+V`Hb-@@jT-FoqxYT|JLW)h;6?M3kf~n z>RQV`f>s2L4>}!K+>810hemhYM1)4{L6x7^$6(crqrV0Nn2=QxHrT-C&>-f;qci@I|+gr4dm z4bpB==Lz|^Kbu23D(&Uh_9QDZM>Gc3Pp)fG-@Ng#X|H9Nb+2W=Nov?rcfaP8v7nk( z?XTQa9#Yz&ctFvgMVm^LvT2ohRk~_(rEf)WSzJka@tWce#k-1z7M?5c$RCmypEtFj zvGDH_Q<+S<<>>Y02giLrF++%c5h2tng%Um$H79g3(x$RJ*nIbxmMR zrLlKSNzI1ZTeWj)Kh5y!f@5>|QcJdy1sNzd5l4P=kv?S4F zCY^rVTbz;LmZbmws(B)aN41n;|Bd{XCK#v?jD|bo|&E# zJX1Wjdc=BIJ%)G=@yzoG_c-dl!d>sN&~t!yfbTB9cK#~=;eJznkNbr9U>~D*oVVSJ zdNz4<@o@Ht^LXaz;T`OA%x8$tByT^jG7pRUc=uel;ckmud${B|Uv;Y1XXwW}g*jI_ zcQfQT=R0lCs~w-~-fCU6<1{hqP%%ju#&_jzw#9DHSs+$5$<=9&5}sT8#IN6O1~esoKjp z&bZUK&p6lE$=J%c%-FVOcUsF|)8%Xaj12{ka+Z1Hq=M5;v>U)OBia%`D9a%{XnE z)?YVJH%_-!_erw<&JEZjW4-xOR8-ch$HSx*T^2b4f6aGz1tr7*-qJ8@yavxl|ig7&4tBoD-ZP zohtO(^t^tSW4r_J@UyO~E?Cz^*I(C4m#*!hU8hMIbb(Y5dZ#r=ep*dHucm)Ahg9|5zVY?@?b^_pI)1 zovwaSeQy1rhF=ssBq9a zc5pc9n7*yvQ_txu93MIEb{ytta4d89 z2$2Kv(&2=|NC&I#ly0z2(k5ybXg#z^nkY??dWX8VTBH6d{wZz~dx)P@vs9JBNh0;eCE?wR6E zai+tjZKe&TopATY#GAXBXP7sd513DzZcpcFQG8sztH%vF^84S_j)s z+sYvSlkErXX?7=Rf^hEkqBHh@Fzsk?PMpDbiVKxztxuOCRj#>?`fV?ZI|p`)rH1?Y7OcjkNW#b+Wa!1>1aVI-AW}Zq2f$ zTVGjUT3=Y7Tc28=z%|`kV6|G^Z5?gj*)y_bEeeIu;<%5JfT zNwcLhQl_Mr`^rn?({j3ul^)7+<*HJkxRYUI3wcNwiKP4J2kOrL!+v2;nZ!DyrRWb( zeRDhx@5PU?4R_&Ia*13c*PfrlALF0$wY;A&R9GdP7t(|V!9&$uH5oE>MfFZ)Q#BKN zi{r&b;zn`5cuq_ZpNMb8Z1JmDDprbhq6PG&R_oPHYONZnO)#1%z5>2q6_1J=#987{ zvAyUiGF7GOy((FCLbXygLe)|w3Gan#!p}mq&|0wYFZtvAe7-mD%2#lIbH8$vxmH{) zzK^%!k=PfPqFd-E)F0{4XLgCLX2Y02tDz6*ZW>JkX)XDO93-|)ifWKeQjYXgx+Wcx)=4qaIBAH~N9rbpOQBM8$xCvUv=Y1&eAJRo z(n~Hd7AUoYIisaH(i&;66f515o=I6!y`++y}?tefTcU#~Q8^ zHnsC;P>;_`G5IBUgF(^wnC&ZO;|1L6V3?SdGN@ct?kZ1}x5@{?Q{@7qNM#l(g*@S%@Km@12=5ox33EY z5ArMcv3ytFlQ(l2+#T)!w}>0Yg>Wja1V6!N@LD_ue}h#xAKgcXK_8JQ0NL0F(8w-0 z*8tX>DKv{F(Svje9Zo~3nwFD)$rZAjEFdFD2jWSHQmVXBl9hAH9%ZF6O&Jc%4N*K5 zwPKU2}Wd{a)4@5v9~X&U6aP_C7U?4bB5VMcb+~WN@ok zz~d`Js5v0=J=%y)gLCB|8}h`R@o2mR)N%zs$6v96J-Kjh2sfQu&F$mjxMc1%SHLxJ zJnzm2L)HfI(fllaDZh^2&hO)o@~8PY{wjZizs=v_lm0&v`2_wJV0no@%bx&Lckt`^ zWqb@jk&goIx8s}fdY*7b?hE&dyUU%2eOAMM!+;aM9LH7RcQ_fx;%#^VyjeHwk1;Mo zX`qcGXbqZz`hjCmsu+Gc}+^d|X11_iOc0gu4?N7s~J7uJbWRe#o zk;IY1WGh)gW<#Gjm_(3vB#3wsJrRhkG$}PoIdHl_$yIWcEG1L<43P<0&Q-oDB}%1I zr&tuCsE9N1A;G}>USue&Gm9)Eo5+502J-)iyeGxPOf=M=cA$}TGW~(>peN~V`jY0- zCaPnBtOpwf@4Jy512#Qpxy-~IP!Q^gMx%LX6X@hRdV(^M5n=3!!*D-57SF@$@UQqB zPQcIbM_i1XFvl6V0PY)b@knkoH-($aE#ua5Te-d5VeU8X40nOM!d>U$x!b_)M2I_F z0^G;L*j4T#cb5B|I|3Qs&TRl^T*%Gf#&N^BzL5J?oDb)~QCx=$@q1800_=JKZ^Dc5 zWIPmbXo(HbQB|VP=oz{V{Mn6uL^Dtn>W*3>XQZ$SmH|EE4fZ?R!B(;v>^s(rwP9XN z#Z0saT>1%3fKKKx-9}f@d2|vT0SI-WZD;^>qguepLTX7F$p^IF1D9WtC*bgZK?ih? zBmsK=jU;jxekuG({)SvXC$C64`AD+KSKxgeu@OvlfOi0_*ab8YMJLcXP|Y?$g*ZvC z(qus46D_4?Dlm7}igjg?Y&@ICeqwvrN$5xZX6Y=S)qz4>0FlmUAc{tF&?>Y89YdE; z5_*n4qC!-UC~^d+4#i#Zx8T{6@m#zdZ@@e8LHs+ufUo03`~W}2DZt^+I2STmiYu`Z zati+tz-Da0RtO8+HNi+7JgI~kg)lP{a-IeXe2DMiczhZ7e-!_Mx8OCP%~^N?9s&Jq z7ueSiJ3}93M&+R1*XSQWA`W=76O^$4v@rtpLE)$coPkGXR?f0mDx55yodZAK&epI6 zY$`BtAnV4$SO9ZnB9oz;DWhNLd-{t0L+{d?^b(Dw$LM~jI$P*^x|%Mdivh3MbO!w% zI6V=RH4f_0Scqu28xMJ&M5lu1%%U-L0Vr<;T?78JjqU-5JVDPuO^B!WVC{eDJDN?4 zX$`ef%p8FkL987pVi3I16hLAn`x&bE33id)W)E2k`^1V^Et8pu+`zTk0?!7bQEWD&+Kj5K8gf)Ri^4Ui?+aq=tICmaS z`zzqGo-JqdK`|59NH&=D1$4q$2x|eJ<_38Z!P#WsZ9O&8a>!o+xJedp`6J}>4Sh}1 zXevzs^!^(uaGwTa>GUlqE`w%54alQ~!23$bdn06Cp&U~)J*?~lJA|jh1;QSP)&vaDigs8=EJ#v tLYvVp$ii_Hi!K5>3GnOdAMlG5^cH2HY?P0RfVtIxR3ow=J5oST{|996bQu5u literal 0 HcmV?d00001 diff --git a/frogo-keyboard/src/main/res/xml/keys_letters_programmer.xml b/frogo-keyboard/src/main/res/xml/keys_letters_programmer.xml new file mode 100644 index 0000000..e7d4024 --- /dev/null +++ b/frogo-keyboard/src/main/res/xml/keys_letters_programmer.xml @@ -0,0 +1,253 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +