diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..ee1f102 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,82 @@ +# DESIGN.md — Frogo Keyboard Visual Direction & Design System + +> Anti-Slop Mode: DURING +> Dial: ENERGY 2 / RHYTHM 2 / MOTION 2 +> Reading this as: Native Android Keyboard & Tooling App for everyday typing and productivity users, in a Tactile & Modern Tooling Material 3 style. + +--- + +## 1. Brand Identity & Character + +Frogo Keyboard is a precision mobile input method and utility suite. Rather than appearing as a generic AI template application or a whimsical toy, the design embodies **Tactile & Modern Tooling**: +- **Tone:** Professional, reliable, tactile, and clear. +- **Surface Feel:** Solid, grounded surfaces with clean 1dp borders instead of muddy floating blur or excessive drop shadows. +- **Keycap Feel:** High-contrast keycaps with distinct touch feedback, crisp typographic legends, and ergonomic spacing. +- **Copywriting:** Direct, human, informative text. Strict ban on em dashes (`—`) and generic AI marketing buzzwords. + +--- + +## 2. Core Color Palette (WCAG AA Compliant) + +Capped at 2 core colors + 1 accent color (R-29), with neutral ground bases: + +### Light Theme +- **Primary (Mechanical Steel Blue):** `#2563EB` (Contrast ratio >= 4.5:1 on white/surface) +- **Primary Container:** `#EFF6FF` (Subtle tint for selected state) +- **Background:** `#F8FAFC` (Clean slate-white ground plane) +- **Surface:** `#FFFFFF` (Card surfaces) +- **Border / Outline:** `#E2E8F0` (Tactile 1dp border) +- **On Surface / Text Primary:** `#0F172A` (Slate 900, 14.2:1 contrast ratio) +- **On Surface Variant / Text Secondary:** `#475569` (Slate 600, 5.8:1 contrast ratio) + +### Dark Theme +- **Primary (Precision Steel Blue Dark):** `#3B82F6` (Contrast ratio >= 4.5:1 on dark surfaces) +- **Primary Container:** `#1E3A8A` (Deep navy container) +- **Background:** `#0F172A` (Deep slate ground plane) +- **Surface:** `#1E293B` (Elevated slate surface) +- **Border / Outline:** `#334155` (Crisp dark tactile border) +- **On Surface / Text Primary:** `#F8FAFC` (Crisp white-slate) +- **On Surface Variant / Text Secondary:** `#94A3B8` (Legible muted slate) + +### Semantic Status Colors +- **Success (Active / Enabled):** `#15803D` (Light) / `#22C55E` (Dark) +- **Warning (Attention / Action Needed):** `#B45309` (Light) / `#F59E0B` (Dark) +- **Error (Failed / Inactive):** `#B91C1C` (Light) / `#EF4444` (Dark) + +--- + +## 3. Shape, Radii, & Elevation Hierarchy + +No uniform pill shapes everywhere (R-11). Distinct functional radii: +- **Card Surfaces:** `12.dp` (Clean, grounded corners) +- **Chips, Keycaps, & Buttons:** `8.dp` (Ergonomic tap target) +- **Dialogs & Bottom Sheets:** `16.dp` (Modal focus) +- **Status Indicators / Avatars:** `CircleShape` (Only for circular glyph badges) +- **Elevation:** Flat `0.dp` or subtle `1.dp` accompanied by a `1.dp` crisp border (`BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)`). No generic floating clouds (R-12). + +--- + +## 4. Typography Scale + +- **Title Large:** 22sp, Bold, 28sp line height (Screen headers) +- **Title Medium:** 18sp, SemiBold, 24sp line height (Section titles) +- **Title Small:** 15sp, SemiBold, 20sp line height (Card titles) +- **Body Large:** 16sp, Normal, 24sp line height (Primary descriptions) +- **Body Medium:** 14sp, Normal, 20sp line height (Secondary descriptions, form labels) +- **Body Small:** 12sp, Normal, 16sp line height (Metadata, hints) +- **Label Large:** 14sp, Medium, 20sp line height (Button text, action labels) +- **Label Small:** 11sp, SemiBold, 14sp line height (Badges, indicators) + +--- + +## 5. Anti-Slop Enforcement Rules + +1. **R-01 (Color):** Zero generic purple-to-pink gradients or neon glow. All colors follow the palette above. +2. **R-02 (Copywriting):** Absolutely NO em dash (`—`) in any UI text or code comments. Use comma, period, colon, or parentheses. +3. **R-03 (Mobile Responsiveness):** Minimum 48dp tap target for interactive elements. Zero horizontal scroll overflow on standard screens. +4. **R-06 (Typography):** No uppercase tracking slop (e.g. replace `NO DATA FOUND` with clear descriptive copy). +5. **R-10 & R-13 (Glass & Glow):** No blur overlays or decorative glows. Clean, matte, tactile surfaces. +6. **R-16 (Buzzwords):** No words like "AI Powered", "Seamless", "Revolutionary", "Next-Gen". Use direct descriptive language. +7. **R-25 (Contrast):** Every text and icon pair satisfies WCAG AA (>= 4.5:1 for normal text). +8. **R-27 (UI States):** Empty, Loading, and Content states present and properly styled on every screen. +9. **R-34 (Theme Integrity):** Light and Dark themes both fully functional and tested. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 89e6f12..ec5575f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -115,19 +115,7 @@ 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.compose.material.icons.extended) - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.lifecycle.viewmodel.compose) - - implementation(project(DependencyGradle.MODULE_LIB_FROGO_KEYBOARD)) + implementation(project(DependencyGradle.MODULE_LIB_KEYBOARD)) implementation(libs.androidx.work.ktx) implementation(libs.google.hilt) @@ -136,10 +124,13 @@ dependencies { 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) ksp(libs.androidx.lifecycle.compiler) + + androidTestImplementation(composeBom) testImplementation(libs.junit) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0") + debugImplementation(libs.androidx.compose.ui.tooling) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 30cf068..abf26e6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,11 +2,14 @@ + + { + data class Success(val data: T) : Resource() + data class Error(val message: String, val cause: Throwable? = null, val code: Int? = null) : Resource() + data object Loading : Resource() +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/data/remote/DataApiService.kt b/app/src/main/java/com/frogobox/appkeyboard/data/remote/DataApiService.kt new file mode 100644 index 0000000..1bdfdf7 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/data/remote/DataApiService.kt @@ -0,0 +1,15 @@ +package com.frogobox.appkeyboard.data.remote + +import com.frogobox.appkeyboard.data.remote.model.DataApiResponse +import retrofit2.Response +import retrofit2.http.GET + +/** + * Retrofit network service interface for fetching data from remote endpoint. + */ +interface DataApiService { + + @GET("api/data.json") + suspend fun getData(): Response + +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/data/remote/model/DataApiResponse.kt b/app/src/main/java/com/frogobox/appkeyboard/data/remote/model/DataApiResponse.kt new file mode 100644 index 0000000..6275768 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/data/remote/model/DataApiResponse.kt @@ -0,0 +1,29 @@ +package com.frogobox.appkeyboard.data.remote.model + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName + +/** + * Root data model for remote API response from /api/data.json. + * Annotated with @Keep and @SerializedName for safe serialization/deserialization. + */ +@Keep +data class DataApiResponse( + @SerializedName("code") + val code: Int? = null, + + @SerializedName("status") + val status: String? = null, + + @SerializedName("message") + val message: String? = null, + + @SerializedName("total") + val total: Int? = null, + + @SerializedName("lastUpdated") + val lastUpdated: String? = null, + + @SerializedName("data") + val data: List? = null +) diff --git a/app/src/main/java/com/frogobox/appkeyboard/data/remote/model/DataItemResponse.kt b/app/src/main/java/com/frogobox/appkeyboard/data/remote/model/DataItemResponse.kt new file mode 100644 index 0000000..3db6536 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/data/remote/model/DataItemResponse.kt @@ -0,0 +1,78 @@ +package com.frogobox.appkeyboard.data.remote.model + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName + +/** + * Data model for individual items returned from remote API. + * Annotated with @Keep and @SerializedName to ensure ProGuard obfuscation safety. + */ +@Keep +data class DataItemResponse( + @SerializedName("id") + val id: Int? = null, + + @SerializedName("title") + val title: String? = null, + + @SerializedName("body") + val body: String? = null, + + @SerializedName("description") + val description: String? = null, + + @SerializedName("category") + val category: String? = null, + + @SerializedName("createdAt") + val createdAt: String? = null, + + @SerializedName("isActive") + val isActive: Boolean? = null, + + @SerializedName("rowIndex") + val rowIndex: Int? = null, + + @SerializedName("isVideo") + val isVideo: Boolean? = null, + + @SerializedName("fileSize") + val fileSize: String? = null, + + @SerializedName("caption") + val caption: String? = null, + + @SerializedName("uploaderName") + val uploaderName: String? = null, + + @SerializedName("uploadTimestamp") + val uploadTimestamp: String? = null, + + @SerializedName("driveLink") + val driveLink: String? = null, + + @SerializedName("thumbnailUrl") + val thumbnailUrl: String? = null +) { + val displayIndex: Int + get() = rowIndex ?: id ?: 0 + + val displayTitle: String + get() = caption?.takeIf { it.isNotBlank() } + ?: title?.takeIf { it.isNotBlank() } + ?: uploaderName?.takeIf { it.isNotBlank() } + ?: "Item #$displayIndex" + + val displaySubtitle: String? + get() = uploaderName?.takeIf { it.isNotBlank() } + ?: category?.takeIf { it.isNotBlank() } + + val displayBody: String? + get() = body?.takeIf { it.isNotBlank() } + ?: description?.takeIf { it.isNotBlank() } + ?: caption?.takeIf { it.isNotBlank() } + + val displayTimestamp: String? + get() = uploadTimestamp?.takeIf { it.isNotBlank() } + ?: createdAt?.takeIf { it.isNotBlank() } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/di/NetworkModule.kt b/app/src/main/java/com/frogobox/appkeyboard/di/NetworkModule.kt index a014346..ecda194 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/di/NetworkModule.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/di/NetworkModule.kt @@ -1,22 +1,30 @@ package com.frogobox.appkeyboard.di +import android.content.Context +import com.frogobox.appkeyboard.data.remote.ApiService +import com.frogobox.appkeyboard.data.remote.DataApiService import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton /** - * Created by Faisal Amir on 06/01/23 - * ----------------------------------------- - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - * Copyright (C) Frogobox ID / amirisback - * All rights reserved + * Hilt module providing network dependencies and services. */ - - @Module @InstallIn(SingletonComponent::class) class NetworkModule { + companion object { + const val BASE_URL = "http://192.168.100.6:3000/" + } + + @Provides + @Singleton + fun provideDataApiService(@ApplicationContext context: Context): DataApiService { + return ApiService.create(context, BASE_URL) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/di/RepositoryModule.kt b/app/src/main/java/com/frogobox/appkeyboard/di/RepositoryModule.kt index 5696078..af90583 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/di/RepositoryModule.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/di/RepositoryModule.kt @@ -2,21 +2,17 @@ package com.frogobox.appkeyboard.di import com.frogobox.appkeyboard.repository.autotext.AutoTextRepository import com.frogobox.appkeyboard.repository.autotext.AutoTextRepositoryImpl +import com.frogobox.appkeyboard.repository.data.DataApiRepository +import com.frogobox.appkeyboard.repository.data.DataApiRepositoryImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton /** - * Created by Faisal Amir on 06/01/23 - * ----------------------------------------- - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - * Copyright (C) Frogobox ID / amirisback - * All rights reserved + * Hilt module binding repository implementations to their interfaces. */ - @Module(includes = [ NetworkModule::class, ServiceModule::class, @@ -28,4 +24,8 @@ abstract class RepositoryModule { @Binds abstract fun getAutoTextRepository(repository: AutoTextRepositoryImpl): AutoTextRepository + @Binds + @Singleton + abstract fun bindDataApiRepository(repository: DataApiRepositoryImpl): DataApiRepository + } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/repository/data/DataApiRepository.kt b/app/src/main/java/com/frogobox/appkeyboard/repository/data/DataApiRepository.kt new file mode 100644 index 0000000..85510c9 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/repository/data/DataApiRepository.kt @@ -0,0 +1,16 @@ +package com.frogobox.appkeyboard.repository.data + +import com.frogobox.appkeyboard.common.core.Resource +import com.frogobox.appkeyboard.data.remote.model.DataApiResponse +import kotlinx.coroutines.flow.Flow + +/** + * Repository interface defining operations for fetching remote API data. + */ +interface DataApiRepository { + + fun fetchDataStream(): Flow> + + suspend fun fetchData(): Resource + +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/repository/data/DataApiRepositoryImpl.kt b/app/src/main/java/com/frogobox/appkeyboard/repository/data/DataApiRepositoryImpl.kt new file mode 100644 index 0000000..3e8e5c3 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/repository/data/DataApiRepositoryImpl.kt @@ -0,0 +1,73 @@ +package com.frogobox.appkeyboard.repository.data + +import com.frogobox.appkeyboard.common.core.Resource +import com.frogobox.appkeyboard.data.remote.DataApiService +import com.frogobox.appkeyboard.data.remote.model.DataApiResponse +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Implementation of DataApiRepository executing asynchronous network calls on Dispatchers.IO. + */ +@Singleton +class DataApiRepositoryImpl( + private val apiService: DataApiService, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO +) : DataApiRepository { + + @Inject + constructor(apiService: DataApiService) : this(apiService, Dispatchers.IO) + + override fun fetchDataStream(): Flow> = flow { + emit(Resource.Loading) + val result = executeFetch() + emit(result) + }.catch { e -> + emit(Resource.Error(message = formatErrorMessage(e), cause = e)) + }.flowOn(ioDispatcher) + + override suspend fun fetchData(): Resource = withContext(ioDispatcher) { + executeFetch() + } + + private suspend fun executeFetch(): Resource { + return try { + val response = apiService.getData() + if (response.isSuccessful) { + val body = response.body() + if (body != null) { + Resource.Success(body) + } else { + Resource.Error(message = "Empty response body from server", code = response.code()) + } + } else { + val errorBody = response.errorBody()?.string()?.takeIf { it.isNotBlank() } + val errorMsg = errorBody ?: response.message().takeIf { it.isNotBlank() } ?: "HTTP error" + Resource.Error(message = "HTTP ${response.code()}: $errorMsg", code = response.code()) + } + } catch (e: Exception) { + Resource.Error(message = formatErrorMessage(e), cause = e) + } + } + + private fun formatErrorMessage(throwable: Throwable): String { + return when (throwable) { + is ConnectException -> "Connection refused. Please ensure the local server is running at http://192.168.100.6:3000." + is SocketTimeoutException -> "Connection timed out while communicating with the server." + is UnknownHostException -> "Unable to resolve host 192.168.100.6. Check your Wi-Fi network." + is IOException -> throwable.localizedMessage ?: "Network I/O error occurred." + else -> throwable.localizedMessage ?: "Unexpected error occurred." + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/services/ImeLifecycleOwner.kt b/app/src/main/java/com/frogobox/appkeyboard/services/ImeLifecycleOwner.kt new file mode 100644 index 0000000..54952d4 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/services/ImeLifecycleOwner.kt @@ -0,0 +1,61 @@ +package com.frogobox.appkeyboard.services + +import android.os.Bundle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner + +/** + * Custom [LifecycleOwner], [ViewModelStoreOwner], and [SavedStateRegistryOwner] implementation + * for [android.inputmethodservice.InputMethodService]. + * + * Enables Jetpack Compose [androidx.compose.ui.platform.ComposeView] to be safely hosted within + * the keyboard service window without missing lifecycle owners or leaking memory. + */ +class ImeLifecycleOwner : LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner { + + private val lifecycleRegistry = LifecycleRegistry(this) + private val store = ViewModelStore() + private val savedStateRegistryController = SavedStateRegistryController.create(this) + + override val lifecycle: Lifecycle + get() = lifecycleRegistry + + override val viewModelStore: ViewModelStore + get() = store + + override val savedStateRegistry: SavedStateRegistry + get() = savedStateRegistryController.savedStateRegistry + + fun onCreate() { + savedStateRegistryController.performRestore(Bundle()) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + } + + fun onStart() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) + } + + fun onResume() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + + fun onPause() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + } + + fun onStop() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + } + + fun onDestroy() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + store.clear() + } + +} 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 5276f7a..b1810f4 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt @@ -1,39 +1,72 @@ package com.frogobox.appkeyboard.services import android.content.Intent -import android.view.LayoutInflater +import android.graphics.Paint +import android.graphics.Typeface 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 androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.emoji2.text.EmojiCompat +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.frogobox.api.movie.ConsumeMovieApi +import com.frogobox.api.news.ConsumeNewsApi import com.frogobox.appkeyboard.R -import com.frogobox.appkeyboard.databinding.ItemKeyboardHeaderBinding -import com.frogobox.appkeyboard.databinding.KeyboardImeBinding +import com.frogobox.appkeyboard.model.AutoTextEntity import com.frogobox.appkeyboard.model.KeyboardFeatureModel import com.frogobox.appkeyboard.model.KeyboardFeatureType import com.frogobox.appkeyboard.model.ThemeType +import com.frogobox.appkeyboard.suggestion.SuggestionResult +import com.frogobox.appkeyboard.suggestion.WordSuggestionEngine +import com.frogobox.appkeyboard.ui.autotext.AutoTextActivity +import com.frogobox.appkeyboard.ui.keyboard.autotext.AutoTextKeyboardViewModel +import com.frogobox.appkeyboard.ui.keyboard.root.KeyboardImeRootScreen +import com.frogobox.appkeyboard.ui.keyboard.root.KeyboardPanelState import com.frogobox.appkeyboard.ui.main.MainActivity +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.coreapi.ConsumeApiResponse +import com.frogobox.coresdk.response.FrogoDataResponse +import com.frogobox.coreutil.movie.MovieUrl +import com.frogobox.coreutil.movie.model.TrendingMovie +import com.frogobox.coreutil.movie.response.Trending +import com.frogobox.coreutil.news.NewsConstant.CATEGORY_HEALTH +import com.frogobox.coreutil.news.NewsConstant.COUNTRY_ID +import com.frogobox.coreutil.news.NewsUrl +import com.frogobox.coreutil.news.model.Article +import com.frogobox.coreutil.news.response.ArticleResponse 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.emoji.EmojiCategoryType +import com.frogobox.libkeyboard.ui.emoji.getEmojiCategory +import com.frogobox.libkeyboard.ui.emoji.parseRawEmojiSpecsFile 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 +import com.frogobox.libkeyboard.ui.main.MainKeyboard 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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject - +/** + * Modern 100% Full Pure Jetpack Compose InputMethodService. + * Uses a unified ComposeView, reactive panel navigation, and direct Compose feature integrations. + */ @AndroidEntryPoint -class KeyboardIME : BaseKeyboardIME() { +class KeyboardIME : BaseKeyboardIME() { + + private val imeLifecycleOwner = ImeLifecycleOwner() @Inject lateinit var pref: PreferenceDelegates @@ -44,241 +77,294 @@ class KeyboardIME : BaseKeyboardIME() { @Inject lateinit var suggestionEngine: WordSuggestionEngine - override fun setupViewBinding(): KeyboardImeBinding { - return KeyboardImeBinding.inflate(LayoutInflater.from(this), null, false) + // Reactive State Holders + private val activePanelStateFlow = MutableStateFlow(KeyboardPanelState.MAIN) + private val themeTypeFlow = MutableStateFlow(ThemeType.COLOR) + private val themeBackgroundResFlow = MutableStateFlow(R.color.color_bg_keyboard_default) + private val featuresFlow = MutableStateFlow>(emptyList()) + private val isSuggestionVisibleFlow = MutableStateFlow(false) + private val suggestionResultFlow = MutableStateFlow(SuggestionResult.EMPTY) + + // Sub-Screen Reactive Data Flows + private val autoTextListFlow = MutableStateFlow>(emptyList()) + private val newsArticlesFlow = MutableStateFlow>(emptyList()) + private val isNewsLoadingFlow = MutableStateFlow(false) + private val movieListFlow = MutableStateFlow>(emptyList()) + private val isMovieLoadingFlow = MutableStateFlow(false) + + // Emoji State Flows + private val emojisFlow = MutableStateFlow>(emptyList()) + private val selectedEmojiCategoryFlow = MutableStateFlow(EmojiCategoryType.GENERAL) + private val isEmojiLoadingFlow = MutableStateFlow(false) + private val emojiCache = mutableMapOf>() + private val systemFontPaint = Paint().apply { typeface = Typeface.DEFAULT } + private val emojiCompatMetadataVersion = 0 + + // Coroutine Scope & View Models + private val serviceJob = SupervisorJob() + private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob) + private val autoTextViewModel by lazy { AutoTextKeyboardViewModel(this) } + + // Interop MainKeyboard Instance + private var mainKeyboardInstance: MainKeyboard? = null + private val currentKeyboardFlow = MutableStateFlow(null) + + override fun onCreate() { + super.onCreate() + imeLifecycleOwner.onCreate() } - private val featureKeyboardCallback = object : - IFrogoBindingAdapter { - - override fun areContentsTheSame( - oldItem: KeyboardFeatureModel, - newItem: KeyboardFeatureModel - ): Boolean { - return oldItem == newItem - } + override fun onWindowShown() { + super.onWindowShown() + imeLifecycleOwner.onStart() + imeLifecycleOwner.onResume() + applySoundAndHapticSettings() + setupTheme() + setupFeatureKeyboard() + loadAutoText() + showMainKeyboard() + } - override fun areItemsTheSame( - oldItem: KeyboardFeatureModel, - newItem: KeyboardFeatureModel - ): Boolean { - return oldItem.id == newItem.id - } + override fun onWindowHidden() { + super.onWindowHidden() + imeLifecycleOwner.onPause() + imeLifecycleOwner.onStop() + } - override fun setViewBinding(parent: ViewGroup): ItemKeyboardHeaderBinding { - return ItemKeyboardHeaderBinding.inflate( - LayoutInflater.from(parent.context), parent, false - ) - } + override fun onDestroy() { + super.onDestroy() + serviceJob.cancelChildren() + imeLifecycleOwner.onDestroy() + mainKeyboardInstance = null + emojiCache.clear() + MechanicalSoundManager.getInstance(this).release() + } - 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 onCreateInputView(): View { + setupBinding() + initCurrentInputConnection() + initView() + + val composeView = ComposeView(this).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool) + setViewTreeLifecycleOwner(imeLifecycleOwner) + setViewTreeViewModelStoreOwner(imeLifecycleOwner) + setViewTreeSavedStateRegistryOwner(imeLifecycleOwner) + setContent { + FrogoKeyboardTheme { + val activePanel by activePanelStateFlow.collectAsState() + val themeType by themeTypeFlow.collectAsState() + val themeBg by themeBackgroundResFlow.collectAsState() + val features by featuresFlow.collectAsState() + val isSuggestionVisible by isSuggestionVisibleFlow.collectAsState() + val suggestionResult by suggestionResultFlow.collectAsState() + val currentKeyboard by currentKeyboardFlow.collectAsState() + + val autoTextList by autoTextListFlow.collectAsState() + val newsArticles by newsArticlesFlow.collectAsState() + val isNewsLoading by isNewsLoadingFlow.collectAsState() + val movieList by movieListFlow.collectAsState() + val isMovieLoading by isMovieLoadingFlow.collectAsState() + + val emojis by emojisFlow.collectAsState() + val selectedEmojiCategory by selectedEmojiCategoryFlow.collectAsState() + val isEmojiLoading by isEmojiLoadingFlow.collectAsState() + + KeyboardImeRootScreen( + activePanelState = activePanel, + themeType = themeType, + themeBackgroundRes = themeBg, + features = features, + onFeatureClick = { feature -> handleFeatureClick(feature) }, + isSuggestionVisible = isSuggestionVisible, + suggestionResult = suggestionResult, + onCandidateSelected = { word, _ -> handleCandidateSelected(word) }, + onSwitchSuggestionMenu = { + isSuggestionVisibleFlow.value = false + }, + onCloseSuggestion = { + suggestionResultFlow.value = SuggestionResult.EMPTY + isSuggestionVisibleFlow.value = false + }, + currentKeyboard = currentKeyboard, + onKeyboardActionListener = this@KeyboardIME, + onMainKeyboardInit = { view -> + mainKeyboardInstance = view + currentKeyboard?.let { view.setKeyboard(it) } + }, + autoTextList = autoTextList, + onManageAutoText = { + val intent = Intent(this@KeyboardIME, AutoTextActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + startActivity(intent) + }, + newsArticles = newsArticles, + isNewsLoading = isNewsLoading, + movieList = movieList, + isMovieLoading = isMovieLoading, + emojis = emojis, + selectedEmojiCategory = selectedEmojiCategory, + emojiCategories = getEmojiCategory(), + isEmojiLoading = isEmojiLoading, + onSelectEmojiCategory = { cat -> + selectedEmojiCategoryFlow.value = cat + loadEmojis(cat.path) + }, + onEmojiClicked = { emoji -> + getActiveInputConnection()?.commitText(emoji, 1) + this@KeyboardIME.onText(emoji) + this@KeyboardIME.onActionUp() + }, + onCommitText = { text -> + getActiveInputConnection()?.commitText(text, 1) + }, + onBackToMain = { + showMainKeyboard() + } + ) + } } - } - 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() - } + window?.window?.decorView?.let { decor -> + decor.setViewTreeLifecycleOwner(imeLifecycleOwner) + decor.setViewTreeViewModelStoreOwner(imeLifecycleOwner) + decor.setViewTreeSavedStateRegistryOwner(imeLifecycleOwner) + } - KeyboardFeatureType.TEMPLATE_TEXT_LOVE -> { - hideMainKeyboard() - this@KeyboardIME.binding?.keyboardTemplateText?.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_LOVE) - this@KeyboardIME.binding?.keyboardTemplateText?.visible() - } + return composeView + } - KeyboardFeatureType.TEMPLATE_TEXT_GREETING -> { - hideMainKeyboard() - this@KeyboardIME.binding?.keyboardTemplateText?.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_GREETING) - this@KeyboardIME.binding?.keyboardTemplateText?.visible() - } + override fun initialSetupKeyboard() { + applySoundAndHapticSettings() + currentKeyboardFlow.value = keyboard + mainKeyboardInstance?.let { view -> + keyboard?.let { view.setKeyboard(it) } + } + } - KeyboardFeatureType.SUGGESTION -> { - this@KeyboardIME.showSuggestionBar() - } + override fun invalidateAllKeys() { + mainKeyboardInstance?.invalidateAllKeys() + } - KeyboardFeatureType.CHANGE_KEYBOARD -> { - (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker() - } + override fun showMainKeyboard() { + activePanelStateFlow.value = KeyboardPanelState.MAIN + isSuggestionVisibleFlow.value = false + suggestionResultFlow.value = SuggestionResult.EMPTY + } - KeyboardFeatureType.SETTING -> { - binding.root.context.startActivity(Intent( - binding.root.context, MainActivity::class.java - ).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - }) - } + override fun hideMainKeyboard() { + // Managed declaratively via activePanelStateFlow transitions + } - } + override fun showOnlyKeyboard() { + activePanelStateFlow.value = KeyboardPanelState.MAIN + } - } + override fun hideOnlyKeyboard() { + // Declarative state management + } + override fun runEmojiBoard() { + activePanelStateFlow.value = KeyboardPanelState.EMOJI + openEmojiPalette() } override fun setupTheme() { - binding?.apply { - - val background = pref.getPrefInt( - KeyboardUtil.KEYBOARD_COLOR, - R.color.color_bg_keyboard_default - ) - - 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) - } - } - } - } + val background = pref.getPrefInt( + KeyboardUtil.KEYBOARD_COLOR, + R.color.color_bg_keyboard_default + ) - override fun onWindowShown() { - super.onWindowShown() - applySoundAndHapticSettings() - } + val typeString = pref.getPrefString( + KeyboardUtil.KEYBOARD_COLOR_TYPE, + ThemeType.COLOR.name + ) - override fun initialSetupKeyboard() { - applySoundAndHapticSettings() - binding?.keyboardMain?.setKeyboard(keyboard!!) - } + val backgroundType = runCatching { + ThemeType.valueOf(typeString) + }.getOrDefault(ThemeType.COLOR) - override fun setupBinding() { - super.setupBinding() - binding?.apply { - keyboardMain.mOnKeyboardActionListener = this@KeyboardIME - keyboardEmoji.mOnKeyboardActionListener = this@KeyboardIME - } + themeTypeFlow.value = backgroundType + themeBackgroundResFlow.value = background + } + override fun setupFeatureKeyboard() { + featuresFlow.value = keyboardUtil.menuKeyboard() } override fun invalidateKeyboard() { - binding?.keyboardAutotext?.initData() + setupTheme() setupFeatureKeyboard() + loadAutoText() } - override fun initCurrentInputConnection() { - binding?.apply { - keyboardAutotext.setInputConnection(currentInputConnection) - keyboardNews.setInputConnection(currentInputConnection) - keyboardMoview.setInputConnection(currentInputConnection) - keyboardWebview.setInputConnection(currentInputConnection) - keyboardForm.setInputConnection(currentInputConnection) - keyboardEmoji.setInputConnection(currentInputConnection) - keyboardTemplateText.setInputConnection(currentInputConnection) - } + override fun initView() { + suggestionEngine.loadDictionaryFromAsset(this) + setupTheme() + setupFeatureKeyboard() + loadAutoText() + openEmojiPalette() } - override fun hideMainKeyboard() { - binding?.apply { - keyboardMain.invisible() - keyboardHeader.invisible() - keyboardSuggestion.gone() + 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) + suggestionResultFlow.value = suggestions + isSuggestionVisibleFlow.value = true + } else { + suggestionResultFlow.value = SuggestionResult.EMPTY + if (code == ItemMainKeyboard.KEYCODE_SPACE || + code == ItemMainKeyboard.KEYCODE_ENTER || + code == ItemMainKeyboard.KEYCODE_DELETE) { + isSuggestionVisibleFlow.value = false + } + } } } - override fun showMainKeyboard() { - binding?.apply { - keyboardMain.visible() - keyboardSuggestion.gone() - if (keyboardUtil.menuKeyboard().isEmpty()) { - keyboardHeader.gone() + override fun deleteWordsBeforeCursor(count: Int) { + val ic = getActiveInputConnection() ?: return + if (count <= 0) return + val textBefore = ic.getTextBeforeCursor(120, 0)?.toString() ?: return + if (textBefore.isEmpty()) return + + var remainingWords = count + var deleteLen = 0 + var inWord = false + + for (i in textBefore.length - 1 downTo 0) { + val ch = textBefore[i] + if (ch.isWhitespace() || !ch.isLetterOrDigit()) { + if (inWord) { + remainingWords-- + if (remainingWords <= 0) { + deleteLen++ + break + } + inWord = false + } } else { - keyboardHeader.visible() + inWord = true } - keyboardAutotext.gone() - keyboardNews.gone() - keyboardMoview.gone() - keyboardWebview.gone() - keyboardForm.gone() - keyboardEmoji.gone() - keyboardEmoji.binding.emojiList.scrollToPosition(0) + deleteLen++ } - } - override fun showOnlyKeyboard() { - binding?.keyboardMain?.visible() + if (deleteLen > 0) { + ic.deleteSurroundingText(deleteLen, 0) + } } - override fun hideOnlyKeyboard() { - binding?.keyboardMain?.gone() + override fun getKeyboardLayoutXML(): Int { + return pref.getPrefInt( + KeyboardUtil.KEYBOARD_TYPE, com.frogobox.libkeyboard.R.xml.keys_letters_qwerty + ) } override fun EditText.showKeyboardExt() { @@ -292,188 +378,182 @@ class KeyboardIME : BaseKeyboardIME() { } } - override fun initBackToMainKeyboard() { - binding?.apply { - keyboardAutotext.binding.toolbarBack.setOnClickListener { - keyboardAutotext.gone() - showMainKeyboard() + private fun getActiveInputConnection(): InputConnection? { + return currentInputConnection + } + + private fun getWordBeforeCursor(ic: InputConnection): String { + val text = ic.getTextBeforeCursor(40, 0)?.toString() ?: "" + return text.takeLastWhile { it.isLetterOrDigit() || it == '\'' } + } + + private fun handleCandidateSelected(selectedWord: String) { + val ic = getActiveInputConnection() + if (ic != null) { + val currentWord = getWordBeforeCursor(ic) + if (currentWord.isNotEmpty()) { + ic.deleteSurroundingText(currentWord.length, 0) } + ic.commitText("$selectedWord ", 1) + suggestionResultFlow.value = SuggestionResult.EMPTY + isSuggestionVisibleFlow.value = false + } + } - keyboardNews.binding.toolbarBack.setOnClickListener { - keyboardNews.gone() - showMainKeyboard() + private fun handleFeatureClick(feature: KeyboardFeatureModel) { + val featureType = KeyboardFeatureType.from(feature.id) + when (featureType) { + KeyboardFeatureType.AUTO_TEXT -> { + loadAutoText() + activePanelStateFlow.value = KeyboardPanelState.AUTO_TEXT } - keyboardMoview.binding.toolbarBack.setOnClickListener { - keyboardMoview.gone() - showMainKeyboard() + KeyboardFeatureType.TEMPLATE_TEXT_GAME, + KeyboardFeatureType.TEMPLATE_TEXT_APP, + KeyboardFeatureType.TEMPLATE_TEXT_SALE, + KeyboardFeatureType.TEMPLATE_TEXT_LOVE, + KeyboardFeatureType.TEMPLATE_TEXT_GREETING -> { + KeyboardPanelState.fromFeature(featureType)?.let { panel -> + activePanelStateFlow.value = panel + } } - keyboardWebview.binding.toolbarBack.setOnClickListener { - keyboardWebview.gone() - showMainKeyboard() + KeyboardFeatureType.NEWS -> { + fetchNews() + activePanelStateFlow.value = KeyboardPanelState.NEWS } - keyboardForm.binding.toolbarBack.setOnClickListener { - keyboardForm.gone() - showMainKeyboard() + KeyboardFeatureType.MOVIE -> { + fetchMovies() + activePanelStateFlow.value = KeyboardPanelState.MOVIE } - keyboardEmoji.binding.toolbarBack.setOnClickListener { - keyboardEmoji.gone() - keyboardEmoji.binding.emojiList.scrollToPosition(0) - showMainKeyboard() + KeyboardFeatureType.WEB -> { + activePanelStateFlow.value = KeyboardPanelState.WEBVIEW } - keyboardTemplateText.binding.toolbarBack.setOnClickListener { - keyboardTemplateText.gone() - showMainKeyboard() + KeyboardFeatureType.FORM -> { + activePanelStateFlow.value = KeyboardPanelState.FORM } - } - } + KeyboardFeatureType.SUGGESTION -> { + isSuggestionVisibleFlow.value = !isSuggestionVisibleFlow.value + } - override fun setupFeatureKeyboard() { - val maxMenu = 4 - val gridSize = if (keyboardUtil.menuKeyboard().size <= maxMenu) { - keyboardUtil.menuKeyboard().size - } else if (keyboardUtil.menuKeyboard().size.mod(maxMenu) == 0) { - maxMenu - } else { - maxMenu + 1 - } + KeyboardFeatureType.CHANGE_KEYBOARD -> { + (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker() + } - binding?.apply { - if (keyboardUtil.menuKeyboard().isEmpty()) { - keyboardHeader.gone() - } else { - keyboardHeader.visible() - keyboardHeader.injectorBinding() - .addData(keyboardUtil.menuKeyboard()) - .addCallback(featureKeyboardCallback) - .createLayoutGrid(gridSize).build() + KeyboardFeatureType.SETTING -> { + startActivity(Intent(this, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }) } } } - - private fun getActiveInputConnection(): InputConnection? { - val formView = binding?.keyboardForm - if (formView?.visibility == View.VISIBLE) { - val et1 = formView.binding.etText - val et2 = formView.binding.etText2 - val et3 = formView.binding.etText3 - - if (et1.isFocused) { - return et1.onCreateInputConnection(EditorInfo()) - } else if (et2.isFocused) { - return et2.onCreateInputConnection(EditorInfo()) - } else if (et3.isFocused) { - return et3.onCreateInputConnection(EditorInfo()) - } - } else if (binding?.keyboardWebview?.visibility == View.VISIBLE) { - return binding?.keyboardWebview?.binding?.webview?.onCreateInputConnection(EditorInfo()) + private fun loadAutoText() { + autoTextViewModel.getAutoText { items -> + autoTextListFlow.value = items } - return currentInputConnection } - private fun getWordBeforeCursor(ic: InputConnection): String { - val text = ic.getTextBeforeCursor(40, 0)?.toString() ?: "" - return text.takeLastWhile { it.isLetterOrDigit() || it == '\'' } - } + private fun fetchNews() { + isNewsLoadingFlow.value = true + val consumeNewsApi = ConsumeNewsApi(NewsUrl.API_KEY) + consumeNewsApi.getTopHeadline( + null, + null, + CATEGORY_HEALTH, + COUNTRY_ID, + null, + null, + object : ConsumeApiResponse { + override fun onSuccess(data: ArticleResponse) { + isNewsLoadingFlow.value = false + data.articles?.let { newsArticlesFlow.value = 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() + override fun onFailed(statusCode: Int, errorMessage: String) { + isNewsLoadingFlow.value = false } - } - onSwitchMenuClicked = { - showFeatureHeader() - } + override fun onFinish() { + isNewsLoadingFlow.value = false + } + + override fun onShowProgress() { + isNewsLoadingFlow.value = true + } - onCloseClicked = { - clearSuggestions() - showFeatureHeader() + override fun onHideProgress() { + isNewsLoadingFlow.value = false + } } - } + ) } - private fun showSuggestionBar() { - binding?.apply { - keyboardHeader.gone() - keyboardSuggestion.visible() - } - } + private fun fetchMovies() { + isMovieLoadingFlow.value = true + val consumeMovieApi = ConsumeMovieApi(MovieUrl.API_KEY) + consumeMovieApi.getTrendingMovieDay(object : FrogoDataResponse> { + override fun onSuccess(data: Trending) { + isMovieLoadingFlow.value = false + data.results?.let { movieListFlow.value = it } + } - private fun showFeatureHeader() { - binding?.apply { - keyboardSuggestion.gone() - if (keyboardUtil.menuKeyboard().isEmpty()) { - keyboardHeader.gone() - } else { - keyboardHeader.visible() + override fun onFailed(statusCode: Int, errorMessage: String) { + isMovieLoadingFlow.value = false } - } - } - override fun onKey(code: Int) { - val ic = getActiveInputConnection() ?: return - onKeyExt(code, ic) + override fun onFinish() { + isMovieLoadingFlow.value = false + } - 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 onShowProgress() { + isMovieLoadingFlow.value = true } - } - } - override fun initView() { - suggestionEngine.loadDictionaryFromAsset(this) - setupSuggestionBar() - setupFeatureKeyboard() - initBackToMainKeyboard() + override fun onHideProgress() { + isMovieLoadingFlow.value = false + } + }) } - override fun invalidateAllKeys() { - binding?.keyboardMain?.invalidateAllKeys() + private fun openEmojiPalette() { + selectedEmojiCategoryFlow.value = EmojiCategoryType.GENERAL + loadEmojis(EmojiCategoryType.GENERAL.path) } - - override fun runEmojiBoard() { - binding?.keyboardEmoji?.visible() - binding?.keyboardMain?.invisible() - binding?.keyboardHeader?.gone() - binding?.keyboardEmoji?.openEmojiPalette() - } + private fun loadEmojis(path: String) { + val cached = emojiCache[path] + if (cached != null) { + emojisFlow.value = cached + return + } - override fun getKeyboardLayoutXML(): Int { - return pref.getPrefInt( - KeyboardUtil.KEYBOARD_TYPE, com.frogobox.libkeyboard.R.xml.keys_letters_qwerty - ) - } + isEmojiLoadingFlow.value = true + serviceScope.launch(Dispatchers.IO) { + val fullEmojiList = parseRawEmojiSpecsFile(this@KeyboardIME, path) + + val isEmojiCompatReady = EmojiCompat.isConfigured() && + EmojiCompat.get().loadState == EmojiCompat.LOAD_STATE_SUCCEEDED + + val emojis = fullEmojiList.filter { emoji -> + systemFontPaint.hasGlyph(emoji) || (isEmojiCompatReady && + EmojiCompat.get().getEmojiMatch( + emoji, + emojiCompatMetadataVersion + ) == EmojiCompat.EMOJI_SUPPORTED) + } + + emojiCache[path] = emojis - private fun getStateToggle(key: String): Boolean { - return pref.getPrefBoolean(key, true) + withContext(Dispatchers.Main) { + emojisFlow.value = emojis + isEmojiLoadingFlow.value = false + } + } } private fun applySoundAndHapticSettings() { @@ -491,9 +571,4 @@ class KeyboardIME : BaseKeyboardIME() { 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/suggestion/KeyboardSuggestionView.kt b/app/src/main/java/com/frogobox/appkeyboard/suggestion/KeyboardSuggestionView.kt deleted file mode 100644 index c0a90b3..0000000 --- a/app/src/main/java/com/frogobox/appkeyboard/suggestion/KeyboardSuggestionView.kt +++ /dev/null @@ -1,115 +0,0 @@ -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/ui/autotext/AutoTextDetailScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailScreen.kt index 7b47779..4999299 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 @@ -106,13 +106,14 @@ fun AutoTextDetailScreen( bottomBar = { Surface( color = MaterialTheme.colorScheme.surface, - tonalElevation = 3.dp, - shadowElevation = 8.dp + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { // Delete Button @@ -120,23 +121,23 @@ fun AutoTextDetailScreen( onClick = { showDeleteDialog = true }, modifier = Modifier .weight(1f) - .height(52.dp), - shape = RoundedCornerShape(16.dp), + .height(48.dp), + shape = RoundedCornerShape(8.dp), colors = ButtonDefaults.outlinedButtonColors( contentColor = FrogoStatusFailed ), - border = BorderStroke(1.dp, FrogoStatusFailed.copy(alpha = 0.4f)) + border = BorderStroke(1.dp, FrogoStatusFailed.copy(alpha = 0.6f)) ) { Icon( imageVector = Icons.Default.Delete, - contentDescription = "Delete", + contentDescription = "Hapus", modifier = Modifier.size(18.dp) ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = "Delete", + text = "Hapus", style = MaterialTheme.typography.labelLarge.copy( - fontSize = 15.sp, + fontSize = 14.sp, fontWeight = FontWeight.SemiBold ), color = FrogoStatusFailed @@ -148,8 +149,8 @@ fun AutoTextDetailScreen( onClick = onEditClick, modifier = Modifier .weight(1f) - .height(52.dp), - shape = RoundedCornerShape(16.dp), + .height(48.dp), + shape = RoundedCornerShape(8.dp), colors = ButtonDefaults.buttonColors( containerColor = FrogoPrimary ) @@ -162,9 +163,9 @@ fun AutoTextDetailScreen( ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = "Edit", + text = "Ubah", style = MaterialTheme.typography.labelLarge.copy( - fontSize = 15.sp, + fontSize = 14.sp, fontWeight = FontWeight.SemiBold ), color = Color.White @@ -186,17 +187,17 @@ fun AutoTextDetailScreen( // Header Hero Card Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { Column( modifier = Modifier .fillMaxWidth() - .padding(20.dp) + .padding(16.dp) ) { Row( modifier = Modifier.fillMaxWidth(), @@ -205,8 +206,8 @@ fun AutoTextDetailScreen( ) { Box( modifier = Modifier - .size(44.dp) - .clip(RoundedCornerShape(12.dp)) + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) .background(FrogoPrimary.copy(alpha = 0.12f)), contentAlignment = Alignment.Center ) { @@ -214,26 +215,26 @@ fun AutoTextDetailScreen( imageVector = Icons.AutoMirrored.Filled.ShortText, contentDescription = null, tint = FrogoPrimary, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(22.dp) ) } IconButton(onClick = onShareSnippet) { Icon( imageVector = Icons.Default.Share, - contentDescription = "Share", + contentDescription = "Bagikan", tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } - Spacer(modifier = Modifier.height(14.dp)) + Spacer(modifier = Modifier.height(12.dp)) Text( text = data.title, style = MaterialTheme.typography.titleLarge.copy( fontWeight = FontWeight.Bold, - fontSize = 22.sp + fontSize = 20.sp ), color = MaterialTheme.colorScheme.onSurface ) @@ -246,11 +247,12 @@ fun AutoTextDetailScreen( verticalAlignment = Alignment.CenterVertically ) { Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) ) { Text( - text = "${data.body.length} characters", + text = "${data.body.length} karakter", style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Medium, fontSize = 11.sp @@ -261,11 +263,12 @@ fun AutoTextDetailScreen( } Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) ) { Text( - text = "$wordCount words", + text = "$wordCount kata", style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Medium, fontSize = 11.sp @@ -276,15 +279,15 @@ fun AutoTextDetailScreen( } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(14.dp)) // Primary Copy Button Button( onClick = onCopySnippet, modifier = Modifier .fillMaxWidth() - .height(46.dp), - shape = RoundedCornerShape(12.dp), + .height(44.dp), + shape = RoundedCornerShape(8.dp), colors = ButtonDefaults.buttonColors( containerColor = FrogoPrimary.copy(alpha = 0.12f), contentColor = FrogoPrimary @@ -293,12 +296,12 @@ fun AutoTextDetailScreen( ) { Icon( imageVector = Icons.Default.ContentCopy, - contentDescription = "Copy text", + contentDescription = "Salin teks", modifier = Modifier.size(18.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Copy to Clipboard", + text = "Salin ke Clipboard", style = MaterialTheme.typography.labelLarge.copy( fontWeight = FontWeight.Bold, fontSize = 14.sp @@ -313,11 +316,10 @@ fun AutoTextDetailScreen( modifier = Modifier.fillMaxWidth() ) { Text( - text = "EXPANDED CONTENT", + text = "Isi Template", style = MaterialTheme.typography.labelMedium.copy( fontWeight = FontWeight.Bold, - letterSpacing = 1.sp, - fontSize = 12.sp + fontSize = 13.sp ), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) @@ -325,23 +327,23 @@ fun AutoTextDetailScreen( Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { Box( modifier = Modifier .fillMaxWidth() - .padding(20.dp) + .padding(16.dp) ) { Text( text = data.body, style = MaterialTheme.typography.bodyLarge.copy( fontSize = 15.sp, - lineHeight = 24.sp + lineHeight = 22.sp ), color = MaterialTheme.colorScheme.onSurface ) @@ -357,7 +359,7 @@ fun AutoTextDetailScreen( icon = { Box( modifier = Modifier - .size(48.dp) + .size(44.dp) .clip(CircleShape) .background(FrogoStatusFailed.copy(alpha = 0.12f)), contentAlignment = Alignment.Center @@ -366,20 +368,20 @@ fun AutoTextDetailScreen( imageVector = Icons.Default.Delete, contentDescription = null, tint = FrogoStatusFailed, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(22.dp) ) } }, title = { Text( - text = "Delete Template?", + text = "Hapus Template?", style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.Bold ) ) }, text = { - Text("Are you sure you want to delete \"${data.title}\"? This action cannot be undone.") + Text("Apakah Anda yakin ingin menghapus template \"${data.title}\"? Tindakan ini tidak dapat dibatalkan.") }, confirmButton = { Button( @@ -390,10 +392,10 @@ fun AutoTextDetailScreen( colors = ButtonDefaults.buttonColors( containerColor = FrogoStatusFailed ), - shape = RoundedCornerShape(10.dp) + shape = RoundedCornerShape(8.dp) ) { Text( - text = "Delete", + text = "Hapus", color = Color.White, fontWeight = FontWeight.Bold ) @@ -403,7 +405,7 @@ fun AutoTextDetailScreen( TextButton( onClick = { showDeleteDialog = false } ) { - Text("Cancel") + Text("Batal") } }, shape = RoundedCornerShape(16.dp) 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 959214f..508586b 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 @@ -2,6 +2,7 @@ package com.frogobox.appkeyboard.ui.autotext import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -81,13 +82,14 @@ fun AutoTextEditorScreen( bottomBar = { Surface( color = MaterialTheme.colorScheme.surface, - tonalElevation = 3.dp, - shadowElevation = 8.dp + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { Box( modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .padding(horizontal = 16.dp, vertical = 12.dp) ) { Button( onClick = { @@ -97,8 +99,8 @@ fun AutoTextEditorScreen( }, modifier = Modifier .fillMaxWidth() - .height(52.dp), - shape = RoundedCornerShape(16.dp), + .height(48.dp), + shape = RoundedCornerShape(8.dp), enabled = isFormValid, colors = ButtonDefaults.buttonColors( containerColor = FrogoPrimary @@ -108,13 +110,13 @@ fun AutoTextEditorScreen( imageVector = Icons.Default.Check, contentDescription = null, tint = Color.White, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(18.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = if (isEditMode) "Update Template" else "Save Template", + text = if (isEditMode) "Simpan Perubahan" else "Simpan Template", style = MaterialTheme.typography.labelLarge.copy( - fontSize = 15.sp, + fontSize = 14.sp, fontWeight = FontWeight.SemiBold ), color = Color.White @@ -144,8 +146,8 @@ fun AutoTextEditorScreen( value = title, onValueChange = { if (it.length <= 50) title = it }, modifier = Modifier.fillMaxWidth(), - label = { Text("Shortcut Title") }, - placeholder = { Text("e.g. Greeting, BCA Rekening, Email") }, + label = { Text("Judul Pintasan") }, + placeholder = { Text("Contoh: Sapaan Pagi, Rekening BCA, Email Kantor") }, leadingIcon = { Icon( imageVector = Icons.AutoMirrored.Filled.ShortText, @@ -158,7 +160,7 @@ fun AutoTextEditorScreen( IconButton(onClick = { title = "" }) { Icon( imageVector = Icons.Default.Clear, - contentDescription = "Clear Title", + contentDescription = "Hapus Judul", tint = MaterialTheme.colorScheme.onSurfaceVariant ) } @@ -169,17 +171,18 @@ fun AutoTextEditorScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { - Text("Appears in keyboard quick bar") + Text("Tampil pada bilah cepat keyboard") Text("${title.length} / 50") } }, singleLine = true, - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(8.dp), keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Sentences ), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = FrogoPrimary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, focusedLabelColor = FrogoPrimary, focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface @@ -191,8 +194,8 @@ fun AutoTextEditorScreen( value = body, onValueChange = { body = it }, modifier = Modifier.fillMaxWidth(), - label = { Text("Template Content") }, - placeholder = { Text("Enter the full text that will be inserted when tapped...") }, + label = { Text("Isi Template") }, + placeholder = { Text("Tulis teks lengkap yang akan disisipkan saat tombol diketuk...") }, leadingIcon = { Icon( imageVector = Icons.AutoMirrored.Filled.Notes, @@ -205,7 +208,7 @@ fun AutoTextEditorScreen( IconButton(onClick = { body = "" }) { Icon( imageVector = Icons.Default.Clear, - contentDescription = "Clear Content", + contentDescription = "Hapus Isi", tint = MaterialTheme.colorScheme.onSurfaceVariant ) } @@ -216,17 +219,18 @@ fun AutoTextEditorScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { - Text("Will be committed to input field") - Text("${body.length} characters") + Text("Akan langsung dimasukkan ke input teks") + Text("${body.length} karakter") } }, - minLines = 6, - shape = RoundedCornerShape(16.dp), + minLines = 5, + shape = RoundedCornerShape(8.dp), keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Sentences ), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = FrogoPrimary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, focusedLabelColor = FrogoPrimary, focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface @@ -236,11 +240,12 @@ fun AutoTextEditorScreen( // Helper Guidance Card Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( - containerColor = FrogoPrimary.copy(alpha = 0.07f) + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) ), - border = BorderStroke(1.dp, FrogoPrimary.copy(alpha = 0.20f)) + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { Row( modifier = Modifier @@ -251,8 +256,8 @@ fun AutoTextEditorScreen( Box( modifier = Modifier .size(32.dp) - .clip(CircleShape) - .background(FrogoPrimary.copy(alpha = 0.16f)), + .clip(RoundedCornerShape(8.dp)) + .background(FrogoPrimary.copy(alpha = 0.12f)), contentAlignment = Alignment.Center ) { Icon( @@ -267,7 +272,7 @@ fun AutoTextEditorScreen( Column { Text( - text = "Keyboard Tip", + text = "Tips Keyboard", style = MaterialTheme.typography.labelMedium.copy( fontWeight = FontWeight.Bold ), @@ -275,7 +280,7 @@ fun AutoTextEditorScreen( ) 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.", + text = "Judul yang ringkas dan jelas mempermudah pemilihan template langsung dari keyboard Frogo saat sedang mengetik pesan atau mengisi formulir.", style = MaterialTheme.typography.bodySmall.copy( lineHeight = 16.sp ), @@ -298,17 +303,17 @@ private fun LiveKeyboardPreviewCard( ) { Card( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(12.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) + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .padding(14.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -324,11 +329,10 @@ private fun LiveKeyboardPreviewCard( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "LIVE KEYBOARD PREVIEW", + text = "Pratinjau Keyboard", style = MaterialTheme.typography.labelMedium.copy( fontWeight = FontWeight.Bold, - letterSpacing = 0.8.sp, - fontSize = 11.sp + fontSize = 12.sp ), color = FrogoPrimary ) @@ -336,10 +340,11 @@ private fun LiveKeyboardPreviewCard( Surface( shape = RoundedCornerShape(6.dp), - color = FrogoPrimary.copy(alpha = 0.12f) + color = FrogoPrimary.copy(alpha = 0.12f), + border = BorderStroke(1.dp, FrogoPrimary.copy(alpha = 0.25f)) ) { Text( - text = "Interactive", + text = "Interaktif", style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.SemiBold, fontSize = 10.sp, @@ -350,32 +355,36 @@ private fun LiveKeyboardPreviewCard( } } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(10.dp)) // Simulated Keyboard Item Box Box( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .padding(12.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)) + .border( + BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + RoundedCornerShape(8.dp) + ) + .padding(10.dp) ) { Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(10.dp), + shape = RoundedCornerShape(8.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)) + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp) + .padding(horizontal = 12.dp, vertical = 10.dp) ) { Text( - text = if (title.isBlank()) "Template Title" else title, + text = if (title.isBlank()) "Judul Template" else title, style = MaterialTheme.typography.titleSmall.copy( fontWeight = FontWeight.Bold, fontSize = 14.sp @@ -388,7 +397,7 @@ private fun LiveKeyboardPreviewCard( Spacer(modifier = Modifier.height(2.dp)) Text( - text = if (body.isBlank()) "Expanded text preview will appear here..." else body, + text = if (body.isBlank()) "Isi teks template akan ditampilkan di sini..." else body, style = MaterialTheme.typography.bodySmall.copy( fontSize = 12.sp ), 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 8da8755..f171d83 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 @@ -6,6 +6,7 @@ import android.content.Context import android.widget.Toast import androidx.compose.foundation.BorderStroke 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 @@ -108,16 +109,16 @@ fun AutoTextScreen( onClick = onAddClick, containerColor = FrogoPrimary, contentColor = Color.White, - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(12.dp), icon = { Icon( imageVector = Icons.Default.Add, - contentDescription = "Add Template" + contentDescription = "Tambah Template" ) }, text = { Text( - text = "Add Template", + text = "Tambah Template", style = MaterialTheme.typography.labelLarge.copy( fontSize = 14.sp, fontWeight = FontWeight.SemiBold @@ -154,7 +155,7 @@ fun AutoTextScreen( modifier = Modifier.fillMaxWidth(), placeholder = { Text( - text = "Search templates by title or content...", + text = "Cari template berdasarkan judul atau isi...", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -162,7 +163,7 @@ fun AutoTextScreen( leadingIcon = { Icon( imageVector = Icons.Default.Search, - contentDescription = "Search", + contentDescription = "Cari", tint = FrogoPrimary ) }, @@ -171,17 +172,17 @@ fun AutoTextScreen( IconButton(onClick = { searchQuery = "" }) { Icon( imageVector = Icons.Default.Clear, - contentDescription = "Clear Search", + contentDescription = "Hapus Pencarian", tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } }, singleLine = true, - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(8.dp), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = FrogoPrimary, - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface ) @@ -202,8 +203,10 @@ fun AutoTextScreen( } autoTextList.isEmpty() -> { FrogoEmptyView( - title = "NO AUTO TEXT FOUND", - subtitle = "Tap 'Add Template' below to create a quick template.", + title = "Belum Ada Auto Text", + subtitle = "Buat template pesan cepat untuk mempercepat pengetikan Anda.", + actionButtonText = "Tambah Template", + onActionClick = onAddClick, modifier = Modifier.align(Alignment.Center) ) } @@ -223,7 +226,7 @@ fun AutoTextScreen( top = 4.dp, bottom = 88.dp // Space for Extended FAB ), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(10.dp) ) { items(filteredList, key = { it.id }) { item -> AutoTextCard( @@ -249,11 +252,12 @@ private fun AutoTextHeaderBanner( ) { Card( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( - containerColor = FrogoPrimary.copy(alpha = 0.08f) + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) ), - border = BorderStroke(1.dp, FrogoPrimary.copy(alpha = 0.22f)) + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { Row( modifier = Modifier @@ -268,14 +272,14 @@ private fun AutoTextHeaderBanner( ) { Box( modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(FrogoPrimary.copy(alpha = 0.16f)), + .size(38.dp) + .clip(RoundedCornerShape(8.dp)) + .background(FrogoPrimary.copy(alpha = 0.12f)), contentAlignment = Alignment.Center ) { Icon( imageVector = Icons.AutoMirrored.Filled.ShortText, - contentDescription = "Templates Icon", + contentDescription = "Ikon Template", tint = FrogoPrimary, modifier = Modifier.size(22.dp) ) @@ -285,7 +289,7 @@ private fun AutoTextHeaderBanner( Column { Text( - text = "Quick Templates", + text = "Template Cepat", style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.Bold, fontSize = 15.sp @@ -293,7 +297,7 @@ private fun AutoTextHeaderBanner( color = MaterialTheme.colorScheme.onSurface ) Text( - text = "Instant shortcuts on your keyboard", + text = "Pintasan teks instan pada keyboard", style = MaterialTheme.typography.bodySmall.copy( fontSize = 12.sp ), @@ -303,11 +307,12 @@ private fun AutoTextHeaderBanner( } Surface( - shape = RoundedCornerShape(10.dp), - color = FrogoPrimary.copy(alpha = 0.16f) + shape = RoundedCornerShape(8.dp), + color = FrogoPrimary.copy(alpha = 0.12f), + border = BorderStroke(1.dp, FrogoPrimary.copy(alpha = 0.25f)) ) { Text( - text = if (isSearching) "$filteredCount / $totalCount" else "$totalCount Saved", + text = if (isSearching) "$filteredCount / $totalCount" else "$totalCount Tersimpan", style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Bold, fontSize = 11.sp, @@ -331,17 +336,17 @@ private fun AutoTextCard( modifier = modifier .fillMaxWidth() .clickable { onClick() }, - shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .padding(14.dp) ) { Row( modifier = Modifier.fillMaxWidth(), @@ -354,8 +359,8 @@ private fun AutoTextCard( ) { Box( modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(10.dp)) + .size(34.dp) + .clip(RoundedCornerShape(8.dp)) .background(FrogoPrimary.copy(alpha = 0.12f)), contentAlignment = Alignment.Center ) { @@ -363,17 +368,17 @@ private fun AutoTextCard( imageVector = Icons.AutoMirrored.Filled.Notes, contentDescription = null, tint = FrogoPrimary, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(18.dp) ) } - Spacer(modifier = Modifier.width(12.dp)) + Spacer(modifier = Modifier.width(10.dp)) Text( text = item.title, style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.Bold, - fontSize = 16.sp + fontSize = 15.sp ), color = MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -383,25 +388,29 @@ private fun AutoTextCard( IconButton( onClick = onCopy, - modifier = Modifier.size(36.dp) + modifier = Modifier.size(34.dp) ) { Icon( imageVector = Icons.Default.ContentCopy, - contentDescription = "Copy text", + contentDescription = "Salin teks", tint = FrogoPrimary, modifier = Modifier.size(18.dp) ) } } - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(8.dp)) // Styled Snippet Container Box( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) + .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)) + .border( + BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + RoundedCornerShape(8.dp) + ) .padding(horizontal = 12.dp, vertical = 8.dp) ) { Text( @@ -434,23 +443,23 @@ private fun EmptySearchState( ) { Box( modifier = Modifier - .size(64.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant), + .size(56.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)), contentAlignment = Alignment.Center ) { Icon( imageVector = Icons.Default.SearchOff, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(28.dp) ) } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(14.dp)) Text( - text = "No templates found", + text = "Template tidak ditemukan", style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.Bold ), @@ -460,7 +469,7 @@ private fun EmptySearchState( Spacer(modifier = Modifier.height(4.dp)) Text( - text = "No template matches \"$query\"", + text = "Tidak ada template yang cocok dengan \"$query\"", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -469,7 +478,7 @@ private fun EmptySearchState( TextButton(onClick = onClearSearch) { Text( - text = "Clear Search", + text = "Hapus Pencarian", color = FrogoPrimary, fontWeight = FontWeight.SemiBold ) 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 1cd69ef..2926287 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 @@ -3,123 +3,72 @@ 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.ItemKeyboardAutotextBinding -import com.frogobox.appkeyboard.databinding.KeyboardAutotextBinding +import android.view.inputmethod.InputConnection +import android.widget.FrameLayout +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy 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 -import com.frogobox.recycler.ext.injectorBinding +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import kotlinx.coroutines.flow.MutableStateFlow /** - * Created by Faisal Amir on 11/03/23 - * https://github.com/amirisback + * Modern Jetpack Compose-based AutoText Keyboard panel. */ - -class AutoTextKeyboard( +class AutoTextKeyboard @JvmOverloads constructor( context: Context, - attrs: AttributeSet?, -) : BaseKeyboard(context, attrs) { + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { - override fun setupViewBinding(inflater: LayoutInflater, parent: LinearLayout): KeyboardAutotextBinding { - return KeyboardAutotextBinding.inflate(LayoutInflater.from(context), this, true) - } + private val viewModel by lazy { AutoTextKeyboardViewModel(context) } + private val autoTextState = MutableStateFlow>(emptyList()) - override fun initUI() { - 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) + var currentInputConnection: InputConnection? = null + var onBackClick: (() -> Unit)? = null + + private val composeView = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool) + setContent { + FrogoKeyboardTheme { + val list by autoTextState.collectAsState() + AutoTextKeyboardScreen( + autoTextList = list, + onCommitText = { text -> + currentInputConnection?.commitText(text, 1) + }, + onBackClick = { + onBackClick?.invoke() + }, + onManageClick = { + val intent = Intent(context, AutoTextActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + context.startActivity(intent) + } + ) } } } - override fun initData() { - super.initData() - val viewModel = AutoTextKeyboardViewModel(context) - viewModel.getAutoText { - setupRv(it) - } + init { + addView(composeView) + initData() } - 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 { - override fun onItemClicked( - binding: ItemKeyboardAutotextBinding, - data: AutoTextEntity, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - val output = data.body - currentInputConnection?.commitText(output, 1) - } - - override fun onItemLongClicked( - binding: ItemKeyboardAutotextBinding, - data: AutoTextEntity, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - } - - 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): ItemKeyboardAutotextBinding { - return ItemKeyboardAutotextBinding.inflate( - LayoutInflater.from(context), - parent, - false - ) - } + fun setInputConnection(inputConnection: InputConnection?) { + currentInputConnection = inputConnection + } - override fun setupInitComponent( - binding: ItemKeyboardAutotextBinding, - data: AutoTextEntity, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - binding.apply { - tvItemKeyboardMain.text = data.title - tvItemKeyboardBody.text = data.body - } - } - } + fun setOnBackClickListener(listener: () -> Unit) { + onBackClick = listener + } - rvKeyboardMain.injectorBinding() - .addData(data) - .createLayoutLinearVertical(false) - .addCallback(adapterCallback) - .build() + fun initData() { + viewModel.getAutoText { items -> + autoTextState.value = items } } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardScreen.kt new file mode 100644 index 0000000..cb32df5 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardScreen.kt @@ -0,0 +1,136 @@ +package com.frogobox.appkeyboard.ui.keyboard.autotext + +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.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.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.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.model.AutoTextEntity +import com.frogobox.appkeyboard.ui.keyboard.common.KeyboardFeatureToolbar + +@Composable +fun AutoTextKeyboardScreen( + autoTextList: List, + onCommitText: (String) -> Unit, + onBackClick: () -> Unit, + onManageClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + ) { + KeyboardFeatureToolbar( + title = "Auto Text", + subtitle = "Tap to insert shortcut into chat", + onBackClick = onBackClick, + action = { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .clickable { onManageClick() } + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Manage Auto Text", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + ) + + if (autoTextList.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + com.frogobox.appkeyboard.ui.theme.compose.FrogoEmptyView( + title = "No Auto Text Found", + subtitle = "Create reusable snippets to save time while typing" + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + items( + items = autoTextList, + key = { it.id } + ) { item -> + Card( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable { onCommitText(item.body) }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + ), + shape = RoundedCornerShape(10.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = item.title, + style = MaterialTheme.typography.titleSmall.copy( + fontSize = 13.5.sp, + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = item.body, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.5.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/common/AsyncGlideImage.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/common/AsyncGlideImage.kt new file mode 100644 index 0000000..fde8485 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/common/AsyncGlideImage.kt @@ -0,0 +1,91 @@ +package com.frogobox.appkeyboard.ui.keyboard.common + +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition + +@Composable +fun AsyncGlideImage( + url: String?, + contentDescription: String?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Crop +) { + val context = LocalContext.current + var bitmap by remember(url) { mutableStateOf(null) } + var isLoading by remember(url) { mutableStateOf(!url.isNullOrBlank()) } + + DisposableEffect(url) { + if (url.isNullOrBlank()) { + isLoading = false + return@DisposableEffect onDispose {} + } + + val target = object : CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + bitmap = resource + isLoading = false + } + + override fun onLoadCleared(placeholder: Drawable?) { + bitmap = null + isLoading = false + } + + override fun onLoadFailed(errorDrawable: Drawable?) { + isLoading = false + } + } + + Glide.with(context) + .asBitmap() + .load(url) + .into(target) + + onDispose { + Glide.with(context).clear(target) + } + } + + Box( + modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center + ) { + val currentBitmap = bitmap + if (currentBitmap != null) { + Image( + bitmap = currentBitmap.asImageBitmap(), + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale + ) + } else if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/common/KeyboardFeatureToolbar.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/common/KeyboardFeatureToolbar.kt new file mode 100644 index 0000000..075e00b --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/common/KeyboardFeatureToolbar.kt @@ -0,0 +1,105 @@ +package com.frogobox.appkeyboard.ui.keyboard.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.HorizontalDivider +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.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun KeyboardFeatureToolbar( + title: String, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, + action: (@Composable () -> Unit)? = null +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Tactile Back Button + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .clickable { onBackClick() } + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + Spacer(modifier = Modifier.width(10.dp)) + + // Title & Subtitle block + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 15.sp, + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 10.5.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + if (action != null) { + Spacer(modifier = Modifier.width(8.dp)) + action() + } + } + + HorizontalDivider( + thickness = 0.8.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + } +} 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 3a253e8..c112922 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 @@ -2,41 +2,50 @@ package com.frogobox.appkeyboard.ui.keyboard.form import android.content.Context import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.LinearLayout -import com.frogobox.appkeyboard.databinding.KeyboardFormBinding -import com.frogobox.appkeyboard.ui.keyboard.movie.MovieKeyboard -import com.frogobox.appkeyboard.util.KeyboardNavigationHelper -import com.frogobox.libkeyboard.common.core.BaseKeyboard +import android.view.inputmethod.InputConnection +import android.widget.FrameLayout +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme /** - * Created by Faisal Amir on 07/11/22 - * ----------------------------------------- - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - * Copyright (C) Frogobox ID / amirisback - * All rights reserved + * Modern Jetpack Compose-based Quick Form Keyboard panel. */ - -class FormKeyboard( +class FormKeyboard @JvmOverloads constructor( context: Context, - attrs: AttributeSet?, -) : BaseKeyboard(context, attrs) { - - private val destinationKeyboard = MovieKeyboard(context, attrs) + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { - override fun setupViewBinding(inflater: LayoutInflater, parent: LinearLayout): KeyboardFormBinding { - return KeyboardFormBinding.inflate(LayoutInflater.from(context), this, true) - } + var currentInputConnection: InputConnection? = null + var onBackClick: (() -> Unit)? = null - override fun initUI() { - super.initUI() - binding.apply { - btnSubmit.setOnClickListener { - KeyboardNavigationHelper.navigateTo(this@FormKeyboard, destinationKeyboard) + private val composeView = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool) + setContent { + FrogoKeyboardTheme { + FormKeyboardScreen( + onCommitText = { text -> + currentInputConnection?.commitText(text, 1) + }, + onBackClick = { + onBackClick?.invoke() + } + ) } } } + init { + addView(composeView) + } + + fun setInputConnection(inputConnection: InputConnection?) { + currentInputConnection = inputConnection + } + + fun setOnBackClickListener(listener: () -> Unit) { + onBackClick = listener + } + } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/form/FormKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/form/FormKeyboardScreen.kt new file mode 100644 index 0000000..c45447a --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/form/FormKeyboardScreen.kt @@ -0,0 +1,191 @@ +package com.frogobox.appkeyboard.ui.keyboard.form + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +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.width +import androidx.compose.foundation.rememberScrollState +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.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +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.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.ui.keyboard.common.KeyboardFeatureToolbar + +@Composable +fun FormKeyboardScreen( + onCommitText: (String) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + var subject by remember { mutableStateOf("") } + var details by remember { mutableStateOf("") } + var refNumber by remember { mutableStateOf("") } + + val focusManager = LocalFocusManager.current + val scrollState = rememberScrollState() + + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + ) { + KeyboardFeatureToolbar( + title = "Quick Form Snippet", + subtitle = "Fill and insert structured message", + onBackClick = onBackClick + ) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + // Subject Field + OutlinedTextField( + value = subject, + onValueChange = { subject = it }, + label = { Text("Title / Subject", fontSize = 12.sp) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) } + ), + shape = RoundedCornerShape(8.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, + focusedLabelColor = MaterialTheme.colorScheme.primary + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Details Field + OutlinedTextField( + value = details, + onValueChange = { details = it }, + label = { Text("Details / Full Description", fontSize = 12.sp) }, + maxLines = 3, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) } + ), + shape = RoundedCornerShape(8.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, + focusedLabelColor = MaterialTheme.colorScheme.primary + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Ref Number Field + OutlinedTextField( + value = refNumber, + onValueChange = { refNumber = it }, + label = { Text("Number / Amount / Code", fontSize = 12.sp) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { focusManager.clearFocus() } + ), + shape = RoundedCornerShape(8.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, + focusedLabelColor = MaterialTheme.colorScheme.primary + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Action Buttons Row + Row( + modifier = Modifier.fillMaxWidth() + ) { + OutlinedButton( + onClick = { + subject = "" + details = "" + refNumber = "" + }, + modifier = Modifier + .weight(1f) + .height(44.dp), + shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Text("Clear", fontSize = 12.sp) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + val sb = StringBuilder() + if (subject.isNotBlank()) sb.append("Subject: ${subject.trim()}\n") + if (details.isNotBlank()) sb.append("Details: ${details.trim()}\n") + if (refNumber.isNotBlank()) sb.append("Ref/No: ${refNumber.trim()}\n") + + if (sb.isNotEmpty()) { + onCommitText(sb.toString()) + } + }, + modifier = Modifier + .weight(2f) + .height(44.dp), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text( + "Insert to Chat", + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} 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 6f3a610..54d65d7 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 @@ -2,146 +2,93 @@ package com.frogobox.appkeyboard.ui.keyboard.movie import android.content.Context import android.util.AttributeSet -import android.util.Log -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.LinearLayout -import com.bumptech.glide.Glide +import android.view.inputmethod.InputConnection +import android.widget.FrameLayout +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import com.frogobox.api.movie.ConsumeMovieApi -import com.frogobox.appkeyboard.databinding.ItemKeyboardMovieBinding -import com.frogobox.appkeyboard.databinding.KeyboardGridBinding import com.frogobox.coresdk.response.FrogoDataResponse import com.frogobox.coreutil.movie.MovieUrl import com.frogobox.coreutil.movie.model.TrendingMovie import com.frogobox.coreutil.movie.response.Trending -import com.frogobox.libkeyboard.common.core.BaseKeyboard -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.visible +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import kotlinx.coroutines.flow.MutableStateFlow /** - * Created by Faisal Amir on 07/11/22 - * ----------------------------------------- - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - * Copyright (C) Frogobox ID / amirisback - * All rights reserved + * Modern Jetpack Compose-based Trending Movies Keyboard panel. */ - -class MovieKeyboard( +class MovieKeyboard @JvmOverloads constructor( context: Context, - attrs: AttributeSet?, -) : BaseKeyboard(context, attrs) { + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private val movieListState = MutableStateFlow>(emptyList()) + private val isLoadingState = MutableStateFlow(false) + + var currentInputConnection: InputConnection? = null + var onBackClick: (() -> Unit)? = null + + private val composeView = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool) + setContent { + FrogoKeyboardTheme { + val movies by movieListState.collectAsState() + val loading by isLoadingState.collectAsState() + + MovieKeyboardScreen( + movieList = movies, + isLoading = loading, + onCommitText = { text -> + currentInputConnection?.commitText(text, 1) + }, + onBackClick = { + onBackClick?.invoke() + } + ) + } + } + } - override fun setupViewBinding(inflater: LayoutInflater, parent: LinearLayout): KeyboardGridBinding { - return KeyboardGridBinding.inflate(LayoutInflater.from(context), this, true) + init { + addView(composeView) + fetchMovies() } - override fun initUI() { - super.initUI() - binding.apply { - tvToolbarTitle.text = "Movie Api" - } + fun setInputConnection(inputConnection: InputConnection?) { + currentInputConnection = inputConnection } - override fun initData() { - super.initData() + fun setOnBackClickListener(listener: () -> Unit) { + onBackClick = listener + } - val consumeMovieApi = ConsumeMovieApi(MovieUrl.API_KEY) // Your API_KEY + fun fetchMovies() { + val consumeMovieApi = ConsumeMovieApi(MovieUrl.API_KEY) consumeMovieApi.getTrendingMovieDay(object : FrogoDataResponse> { - override fun onFailed(statusCode: Int, errorMessage: String) {} - override fun onFinish() {} + override fun onFailed(statusCode: Int, errorMessage: String) { + isLoadingState.value = false + } + + override fun onFinish() { + isLoadingState.value = false + } override fun onHideProgress() { - binding.progressBar.gone() + isLoadingState.value = false } override fun onShowProgress() { - binding.progressBar.visible() + isLoadingState.value = true } override fun onSuccess(data: Trending) { - data.results?.let { setupRv(it) } + isLoadingState.value = false + data.results?.let { movieListState.value = it } } - }) } - private fun setupRv(data: List) { - binding.apply { - - val adapterCallback = - object : IFrogoBindingAdapter { - override fun onItemClicked( - binding: ItemKeyboardMovieBinding, - data: TrendingMovie, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - // Your Clicked - Log.d("FrogoKeyboard", "onItemClicked: ${data.title}") - Log.d("FrogoKeyboard", "currentInputConnection: $currentInputConnection") - val output = "Movie: ${data.title} - ${data.release_date} \n " + - "Overview: ${data.overview} \n " + - "Vote: ${data.vote_average} \n " + - "${MovieUrl.BASE_URL_IMAGE_ORIGINAL}${data.poster_path}" + - "\n" + - "Terima Kasih" - currentInputConnection?.commitText(output, 1) - } - - override fun onItemLongClicked( - binding: ItemKeyboardMovieBinding, - data: TrendingMovie, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - } - - override fun areContentsTheSame( - oldItem: TrendingMovie, - newItem: TrendingMovie - ): Boolean { - return oldItem == newItem - } - - override fun areItemsTheSame( - oldItem: TrendingMovie, - newItem: TrendingMovie - ): Boolean { - return oldItem.id == newItem.id - } - - override fun setViewBinding(parent: ViewGroup): ItemKeyboardMovieBinding { - return ItemKeyboardMovieBinding.inflate(LayoutInflater.from(context), - parent, - false) - } - - override fun setupInitComponent( - binding: ItemKeyboardMovieBinding, - data: TrendingMovie, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - binding.apply { - frogoRvGridType6TvTitle.text = data.title - frogoRvGridType6TvSubtitle.text = data.release_date - frogoRvGridType6TvDesc.text = data.overview - Glide.with(context).load("${MovieUrl.BASE_URL_IMAGE_ORIGINAL}${data.poster_path}").into(frogoRvGridType6CivPoster) - } - } - } - - rvKeyboardMain.injectorBinding() - .addData(data) - .createLayoutStaggeredGrid(2) - .addCallback(adapterCallback) - .build() - } - } - } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/movie/MovieKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/movie/MovieKeyboardScreen.kt new file mode 100644 index 0000000..18498db --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/movie/MovieKeyboardScreen.kt @@ -0,0 +1,173 @@ +package com.frogobox.appkeyboard.ui.keyboard.movie + +import java.util.Locale +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.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.coreutil.movie.MovieUrl +import com.frogobox.coreutil.movie.model.TrendingMovie +import com.frogobox.appkeyboard.ui.keyboard.common.AsyncGlideImage +import com.frogobox.appkeyboard.ui.keyboard.common.KeyboardFeatureToolbar + +@Composable +fun MovieKeyboardScreen( + movieList: List, + isLoading: Boolean, + onCommitText: (String) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + ) { + KeyboardFeatureToolbar( + title = "Trending Movies", + subtitle = "Tap to share movie recommendation", + onBackClick = onBackClick + ) + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp + ) + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + items = movieList, + key = { it.id ?: it.hashCode() } + ) { movie -> + val posterUrl = "${MovieUrl.BASE_URL_IMAGE_ORIGINAL}${movie.poster_path ?: ""}" + + Card( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + val output = "Movie: ${movie.title ?: ""} - ${movie.release_date ?: ""} \n " + + "Overview: ${movie.overview ?: ""} \n " + + "Vote: ${movie.vote_average ?: 0.0} \n " + + posterUrl + + "\n" + + "Terima Kasih" + onCommitText(output) + }, + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Poster + AsyncGlideImage( + url = posterUrl, + contentDescription = movie.title ?: "Movie poster", + modifier = Modifier + .fillMaxWidth() + .height(110.dp), + contentScale = ContentScale.Crop + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + // Title + Text( + text = movie.title ?: "", + style = MaterialTheme.typography.titleSmall.copy( + fontSize = 12.5.sp, + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(2.dp)) + + // Rating & Release Date + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = "Rating", + tint = Color(0xFFFFB300), + modifier = Modifier.size(12.dp) + ) + Spacer(modifier = Modifier.width(3.dp)) + Text( + text = String.format(Locale.US, "%.1f", movie.vote_average ?: 0.0), + fontSize = 10.5.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = movie.release_date ?: "", + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/news/NewsKeyboard.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/news/NewsKeyboard.kt index eb016bd..c563c42 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/news/NewsKeyboard.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/news/NewsKeyboard.kt @@ -2,60 +2,74 @@ package com.frogobox.appkeyboard.ui.keyboard.news import android.content.Context import android.util.AttributeSet -import android.util.Log -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.LinearLayout +import android.view.inputmethod.InputConnection +import android.widget.FrameLayout +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import com.frogobox.api.news.ConsumeNewsApi -import com.frogobox.appkeyboard.databinding.ItemKeyboardNewsBinding -import com.frogobox.appkeyboard.databinding.KeyboardListBinding import com.frogobox.coreapi.ConsumeApiResponse import com.frogobox.coreutil.news.NewsConstant.CATEGORY_HEALTH import com.frogobox.coreutil.news.NewsConstant.COUNTRY_ID import com.frogobox.coreutil.news.NewsUrl import com.frogobox.coreutil.news.model.Article import com.frogobox.coreutil.news.response.ArticleResponse - -import com.frogobox.libkeyboard.common.core.BaseKeyboard -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.visible +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import kotlinx.coroutines.flow.MutableStateFlow /** - * Created by Faisal Amir on 07/11/22 - * ----------------------------------------- - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - * Copyright (C) Frogobox ID / amirisback - * All rights reserved + * Modern Jetpack Compose-based News Headlines Keyboard panel. */ - -class NewsKeyboard( +class NewsKeyboard @JvmOverloads constructor( context: Context, - attrs: AttributeSet?, -) : BaseKeyboard(context, attrs) { + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private val articleListState = MutableStateFlow>(emptyList()) + private val isLoadingState = MutableStateFlow(false) + + var currentInputConnection: InputConnection? = null + var onBackClick: (() -> Unit)? = null + + private val composeView = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool) + setContent { + FrogoKeyboardTheme { + val articles by articleListState.collectAsState() + val loading by isLoadingState.collectAsState() + + NewsKeyboardScreen( + articles = articles, + isLoading = loading, + onCommitText = { text -> + currentInputConnection?.commitText(text, 1) + }, + onBackClick = { + onBackClick?.invoke() + } + ) + } + } + } - override fun setupViewBinding(inflater: LayoutInflater, parent: LinearLayout): KeyboardListBinding { - return KeyboardListBinding.inflate(LayoutInflater.from(context), this, true) + init { + addView(composeView) + fetchNews() } - override fun onCreate() { - setupData() - initView() + fun setInputConnection(inputConnection: InputConnection?) { + currentInputConnection = inputConnection } - private fun initView() { - binding.apply { - tvToolbarTitle.text = "News Api" - } + fun setOnBackClickListener(listener: () -> Unit) { + onBackClick = listener } - private fun setupData() { - val consumeNewsApi = ConsumeNewsApi(NewsUrl.API_KEY) // Your API_KEY - consumeNewsApi.getTopHeadline( // Adding Base Parameter on main function + fun fetchNews() { + val consumeNewsApi = ConsumeNewsApi(NewsUrl.API_KEY) + consumeNewsApi.getTopHeadline( null, null, CATEGORY_HEALTH, @@ -64,96 +78,27 @@ class NewsKeyboard( null, object : ConsumeApiResponse { override fun onSuccess(data: ArticleResponse) { - // Your Ui or data - data.articles?.let { setupRv(it) } + isLoadingState.value = false + data.articles?.let { articleListState.value = it } } override fun onFailed(statusCode: Int, errorMessage: String) { - // Your failed to do + isLoadingState.value = false } override fun onFinish() { - // Your finish to do + isLoadingState.value = false } override fun onShowProgress() { - // Your Progress Show - binding.progressBar.visible() + isLoadingState.value = true } override fun onHideProgress() { - // Your Progress Hide - binding.progressBar.gone() - } - - }) - - } - - private fun setupRv(data: List
) { - binding.apply { - - val adapterCallback = object : IFrogoBindingAdapter { - override fun onItemClicked( - binding: ItemKeyboardNewsBinding, - data: Article, - position: Int, - notifyListener: FrogoRecyclerNotifyListener
, - ) { - // Your Clicked - Log.d("NewsKeyboard", "onItemClicked: ${data.title}") - Log.d("FrogoKeyboard", "currentInputConnection: $currentInputConnection") - val output = "${data.title}\n" + - "${data.author}\n" + - "\n" + - "${data.description}\n" + - "\n" + - "Sumber : ${data.url}" + - "\n" + - "Terima Kasih" - currentInputConnection?.commitText(output, 1) - } - - override fun onItemLongClicked( - binding: ItemKeyboardNewsBinding, - data: Article, - position: Int, - notifyListener: FrogoRecyclerNotifyListener
, - ) { - } - - override fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean { - return oldItem == newItem - } - - override fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean { - return oldItem.title == newItem.title - } - - override fun setViewBinding(parent: ViewGroup): ItemKeyboardNewsBinding { - return ItemKeyboardNewsBinding.inflate(LayoutInflater.from(context), - parent, - false) - } - - override fun setupInitComponent( - binding: ItemKeyboardNewsBinding, - data: Article, - position: Int, - notifyListener: FrogoRecyclerNotifyListener
, - ) { - binding.apply { - tvItemKeyboardMain.text = data.title - } + isLoadingState.value = false } } - - rvKeyboardMain.injectorBinding() - .addData(data) - .createLayoutLinearVertical(false) - .addCallback(adapterCallback) - .build() - } + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/news/NewsKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/news/NewsKeyboardScreen.kt new file mode 100644 index 0000000..bdfec54 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/news/NewsKeyboardScreen.kt @@ -0,0 +1,163 @@ +package com.frogobox.appkeyboard.ui.keyboard.news + +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.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +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.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.coreutil.news.model.Article +import com.frogobox.appkeyboard.ui.keyboard.common.AsyncGlideImage +import com.frogobox.appkeyboard.ui.keyboard.common.KeyboardFeatureToolbar + +@Composable +fun NewsKeyboardScreen( + articles: List
, + isLoading: Boolean, + onCommitText: (String) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + ) { + KeyboardFeatureToolbar( + title = "Top Headlines", + subtitle = "Tap to share article & source link", + onBackClick = onBackClick + ) + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + items = articles, + key = { it.url ?: it.title ?: "" } + ) { article -> + Card( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + val output = "${article.title}\n" + + "${article.author ?: "News"}\n" + + "\n" + + "${article.description ?: ""}\n" + + "\n" + + "Sumber : ${article.url}" + + "\n" + + "Terima Kasih" + onCommitText(output) + }, + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (!article.urlToImage.isNullOrBlank()) { + AsyncGlideImage( + url = article.urlToImage, + contentDescription = article.title, + modifier = Modifier + .size(68.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(10.dp)) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = article.title ?: "", + style = MaterialTheme.typography.titleSmall.copy( + fontSize = 12.5.sp, + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = if (!article.author.isNullOrBlank()) article.author!! else "Top News", + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 10.5.sp, + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (!article.description.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = article.description!!, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 10.5.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardFeatureHeader.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardFeatureHeader.kt new file mode 100644 index 0000000..7cd3880 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardFeatureHeader.kt @@ -0,0 +1,199 @@ +package com.frogobox.appkeyboard.ui.keyboard.root + +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.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +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.draw.clip +import androidx.compose.ui.layout.ContentScale +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 +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.model.KeyboardFeatureModel +import com.frogobox.appkeyboard.model.KeyboardFeatureType +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme + +/** + * Modern Jetpack Compose Feature Header Bar. + * Renders feature shortcuts with clean, balanced positioning: + * - When items fit comfortably (<= 5 items), items are evenly distributed across full width with equal columns. + * - When there are more items, renders a smooth horizontal scrollable row with uniform item slots. + */ +@Composable +fun KeyboardFeatureHeader( + features: List, + onFeatureClick: (KeyboardFeatureModel) -> Unit, + modifier: Modifier = Modifier +) { + if (features.isEmpty()) return + + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .height(56.dp) + .background(MaterialTheme.colorScheme.surface) + ) { + val totalWidth = maxWidth + val minComfortableWidth = 70.dp + val canFitEvenly = features.size <= 5 && (totalWidth / features.size) >= minComfortableWidth + + Column( + modifier = Modifier.fillMaxSize() + ) { + if (canFitEvenly) { + // Symmetrical equal-width distribution across the full keyboard width + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 4.dp, vertical = 3.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + features.forEach { feature -> + KeyboardFeatureHeaderItem( + feature = feature, + onClick = { onFeatureClick(feature) }, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) + } + } + } else { + // Horizontally scrollable row with uniform item width for consistent spacing & peeking indicator + val itemWidth = (totalWidth / 4.25f).coerceIn(72.dp, 92.dp) + LazyRow( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + horizontalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically + ) { + items( + items = features, + key = { it.id } + ) { feature -> + KeyboardFeatureHeaderItem( + feature = feature, + onClick = { onFeatureClick(feature) }, + modifier = Modifier + .width(itemWidth) + .fillMaxHeight() + ) + } + } + } + + HorizontalDivider( + thickness = 0.8.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f) + ) + } + } +} + +@Composable +private fun KeyboardFeatureHeaderItem( + feature: KeyboardFeatureModel, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 4.dp, vertical = 3.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = feature.icon), + contentDescription = feature.text, + modifier = Modifier.size(22.dp), + contentScale = ContentScale.Fit + ) + } + + Text( + text = feature.text, + fontSize = 9.5.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp) + ) + } +} + +@Preview(name = "Header 4 Items Even Distribution", showBackground = true, widthDp = 360) +@Composable +private fun KeyboardFeatureHeaderFourItemsPreview() { + FrogoKeyboardTheme { + val sampleFeatures = listOf( + KeyboardFeatureType.AUTO_TEXT.mapToModel(), + KeyboardFeatureType.MOVIE.mapToModel(), + KeyboardFeatureType.CHANGE_KEYBOARD.mapToModel(), + KeyboardFeatureType.SETTING.mapToModel() + ) + KeyboardFeatureHeader( + features = sampleFeatures, + onFeatureClick = {} + ) + } +} + +@Preview(name = "Header Scrollable Many Items", showBackground = true, widthDp = 360) +@Composable +private fun KeyboardFeatureHeaderManyItemsPreview() { + FrogoKeyboardTheme { + val sampleFeatures = listOf( + KeyboardFeatureType.AUTO_TEXT.mapToModel(), + KeyboardFeatureType.MOVIE.mapToModel(), + KeyboardFeatureType.NEWS.mapToModel(), + KeyboardFeatureType.WEB.mapToModel(), + KeyboardFeatureType.FORM.mapToModel(), + KeyboardFeatureType.CHANGE_KEYBOARD.mapToModel(), + KeyboardFeatureType.SETTING.mapToModel() + ) + KeyboardFeatureHeader( + features = sampleFeatures, + onFeatureClick = {} + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardImeRootScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardImeRootScreen.kt new file mode 100644 index 0000000..37e2a9f --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardImeRootScreen.kt @@ -0,0 +1,253 @@ +package com.frogobox.appkeyboard.ui.keyboard.root + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import com.frogobox.appkeyboard.model.AutoTextEntity +import com.frogobox.appkeyboard.model.KeyboardFeatureModel +import com.frogobox.appkeyboard.model.KeyboardFeatureType +import com.frogobox.appkeyboard.model.ThemeType +import com.frogobox.appkeyboard.suggestion.SuggestionResult +import com.frogobox.appkeyboard.ui.keyboard.autotext.AutoTextKeyboardScreen +import com.frogobox.appkeyboard.ui.keyboard.form.FormKeyboardScreen +import com.frogobox.appkeyboard.ui.keyboard.movie.MovieKeyboardScreen +import com.frogobox.appkeyboard.ui.keyboard.news.NewsKeyboardScreen +import com.frogobox.appkeyboard.ui.keyboard.templatetext.TemplateTextKeyboardScreen +import com.frogobox.appkeyboard.ui.keyboard.webview.WebviewKeyboardScreen +import com.frogobox.coreutil.movie.model.TrendingMovie +import com.frogobox.coreutil.news.model.Article +import com.frogobox.libkeyboard.ui.emoji.EmojiCategory +import com.frogobox.libkeyboard.ui.emoji.EmojiCategoryType +import com.frogobox.libkeyboard.ui.emoji.EmojiKeyboardScreen +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard +import com.frogobox.libkeyboard.ui.main.MainKeyboard +import com.frogobox.libkeyboard.ui.main.MainKeyboardView +import com.frogobox.libkeyboard.ui.main.OnKeyboardActionListener + +/** + * Root composable for KeyboardIME unifying the entire keyboard window: + * - Dynamic background rendering (Solid color vs Scrimmed wallpaper image) + * - Animated Top Bar transitioning between [KeyboardFeatureHeader] and [KeyboardSuggestionBar] (180ms) + * - Smooth [Crossfade] (180ms) panel navigation across QWERTY and feature overlay screens + */ +@Composable +fun KeyboardImeRootScreen( + activePanelState: KeyboardPanelState, + themeType: ThemeType, + themeBackgroundRes: Int, + features: List, + onFeatureClick: (KeyboardFeatureModel) -> Unit, + isSuggestionVisible: Boolean, + suggestionResult: SuggestionResult, + onCandidateSelected: (String, CandidateType) -> Unit, + onSwitchSuggestionMenu: () -> Unit, + onCloseSuggestion: () -> Unit, + currentKeyboard: ItemMainKeyboard?, + onKeyboardActionListener: OnKeyboardActionListener?, + onMainKeyboardInit: (MainKeyboard) -> Unit, + autoTextList: List, + onManageAutoText: () -> Unit, + newsArticles: List
, + isNewsLoading: Boolean, + movieList: List, + isMovieLoading: Boolean, + emojis: List, + selectedEmojiCategory: EmojiCategoryType, + emojiCategories: List, + isEmojiLoading: Boolean, + onSelectEmojiCategory: (EmojiCategoryType) -> Unit, + onEmojiClicked: (String) -> Unit, + onCommitText: (String) -> Unit, + onBackToMain: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + // 1. Background Layer (Adaptive Color or Image with Scrim) + if (themeType == ThemeType.IMAGE) { + Image( + painter = painterResource(id = themeBackgroundRes), + contentDescription = null, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .matchParentSize() + .background(Color.Black.copy(alpha = 0.25f)) + ) + } else { + val context = LocalContext.current + val backgroundColor = remember(themeBackgroundRes) { + try { + Color(ContextCompat.getColor(context, themeBackgroundRes)) + } catch (_: Exception) { + Color(themeBackgroundRes) + } + } + Box( + modifier = Modifier + .matchParentSize() + .background(backgroundColor) + ) + } + + // 2. Content Column Layer + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + // Top Bar Area: Animated transition between Feature Header and Candidate Strip + // Rendered when in MAIN keyboard mode + if (activePanelState == KeyboardPanelState.MAIN) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + ) { + AnimatedContent( + targetState = isSuggestionVisible && suggestionResult.hasSuggestions(), + transitionSpec = { + fadeIn( + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing) + ) togetherWith fadeOut( + animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing) + ) + }, + label = "TopBarTransition" + ) { showSuggestions -> + if (showSuggestions) { + KeyboardSuggestionBar( + result = suggestionResult, + onCandidateSelected = onCandidateSelected, + onSwitchMenu = onSwitchSuggestionMenu, + onClose = onCloseSuggestion + ) + } else if (features.isNotEmpty()) { + KeyboardFeatureHeader( + features = features, + onFeatureClick = onFeatureClick + ) + } + } + } + } + + // Main Content Area: Crossfade transition across active keyboard panels + Crossfade( + targetState = activePanelState, + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + label = "PanelCrossfade" + ) { panel -> + when (panel) { + KeyboardPanelState.MAIN -> { + MainKeyboardView( + keyboard = currentKeyboard, + onActionListener = onKeyboardActionListener, + onInit = onMainKeyboardInit + ) + } + + KeyboardPanelState.EMOJI -> { + EmojiKeyboardScreen( + emojis = emojis, + selectedCategory = selectedEmojiCategory, + categories = emojiCategories, + onCategorySelected = onSelectEmojiCategory, + onEmojiClicked = onEmojiClicked, + onBackClicked = onBackToMain, + isLoading = isEmojiLoading + ) + } + + KeyboardPanelState.AUTO_TEXT -> { + Box(modifier = Modifier.fillMaxWidth().height(270.dp)) { + AutoTextKeyboardScreen( + autoTextList = autoTextList, + onCommitText = onCommitText, + onBackClick = onBackToMain, + onManageClick = onManageAutoText + ) + } + } + + KeyboardPanelState.TEMPLATE_TEXT_GAME, + KeyboardPanelState.TEMPLATE_TEXT_APP, + KeyboardPanelState.TEMPLATE_TEXT_SALE, + KeyboardPanelState.TEMPLATE_TEXT_LOVE, + KeyboardPanelState.TEMPLATE_TEXT_GREETING -> { + Box(modifier = Modifier.fillMaxWidth().height(270.dp)) { + TemplateTextKeyboardScreen( + initialType = panel.templateFeatureType ?: KeyboardFeatureType.TEMPLATE_TEXT_GAME, + onCommitText = onCommitText, + onBackClick = onBackToMain + ) + } + } + + KeyboardPanelState.NEWS -> { + Box(modifier = Modifier.fillMaxWidth().height(270.dp)) { + NewsKeyboardScreen( + articles = newsArticles, + isLoading = isNewsLoading, + onCommitText = onCommitText, + onBackClick = onBackToMain + ) + } + } + + KeyboardPanelState.MOVIE -> { + Box(modifier = Modifier.fillMaxWidth().height(270.dp)) { + MovieKeyboardScreen( + movieList = movieList, + isLoading = isMovieLoading, + onCommitText = onCommitText, + onBackClick = onBackToMain + ) + } + } + + KeyboardPanelState.WEBVIEW -> { + WebviewKeyboardScreen( + onCommitText = onCommitText, + onBackClick = onBackToMain + ) + } + + KeyboardPanelState.FORM -> { + Box(modifier = Modifier.fillMaxWidth().height(270.dp)) { + FormKeyboardScreen( + onCommitText = onCommitText, + onBackClick = onBackToMain + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardPanelState.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardPanelState.kt new file mode 100644 index 0000000..6585042 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardPanelState.kt @@ -0,0 +1,62 @@ +package com.frogobox.appkeyboard.ui.keyboard.root + +import com.frogobox.appkeyboard.model.KeyboardFeatureType + +/** + * Single source of truth reactive state for active keyboard panel in [com.frogobox.appkeyboard.services.KeyboardIME]. + */ +enum class KeyboardPanelState { + MAIN, + EMOJI, + AUTO_TEXT, + TEMPLATE_TEXT_GAME, + TEMPLATE_TEXT_APP, + TEMPLATE_TEXT_SALE, + TEMPLATE_TEXT_LOVE, + TEMPLATE_TEXT_GREETING, + NEWS, + MOVIE, + WEBVIEW, + FORM; + + val isTemplate: Boolean + get() = this in TEMPLATE_STATES + + val templateFeatureType: KeyboardFeatureType? + get() = when (this) { + TEMPLATE_TEXT_GAME -> KeyboardFeatureType.TEMPLATE_TEXT_GAME + TEMPLATE_TEXT_APP -> KeyboardFeatureType.TEMPLATE_TEXT_APP + TEMPLATE_TEXT_SALE -> KeyboardFeatureType.TEMPLATE_TEXT_SALE + TEMPLATE_TEXT_LOVE -> KeyboardFeatureType.TEMPLATE_TEXT_LOVE + TEMPLATE_TEXT_GREETING -> KeyboardFeatureType.TEMPLATE_TEXT_GREETING + else -> null + } + + companion object { + private val TEMPLATE_STATES = setOf( + TEMPLATE_TEXT_GAME, + TEMPLATE_TEXT_APP, + TEMPLATE_TEXT_SALE, + TEMPLATE_TEXT_LOVE, + TEMPLATE_TEXT_GREETING + ) + + fun fromFeature(featureType: KeyboardFeatureType): KeyboardPanelState? { + return when (featureType) { + KeyboardFeatureType.AUTO_TEXT -> AUTO_TEXT + KeyboardFeatureType.TEMPLATE_TEXT_GAME -> TEMPLATE_TEXT_GAME + KeyboardFeatureType.TEMPLATE_TEXT_APP -> TEMPLATE_TEXT_APP + KeyboardFeatureType.TEMPLATE_TEXT_SALE -> TEMPLATE_TEXT_SALE + KeyboardFeatureType.TEMPLATE_TEXT_LOVE -> TEMPLATE_TEXT_LOVE + KeyboardFeatureType.TEMPLATE_TEXT_GREETING -> TEMPLATE_TEXT_GREETING + KeyboardFeatureType.NEWS -> NEWS + KeyboardFeatureType.MOVIE -> MOVIE + KeyboardFeatureType.WEB -> WEBVIEW + KeyboardFeatureType.FORM -> FORM + KeyboardFeatureType.SUGGESTION, + KeyboardFeatureType.CHANGE_KEYBOARD, + KeyboardFeatureType.SETTING -> null + } + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardSuggestionBar.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardSuggestionBar.kt new file mode 100644 index 0000000..9c3b6a0 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardSuggestionBar.kt @@ -0,0 +1,231 @@ +package com.frogobox.appkeyboard.ui.keyboard.root + +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +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.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.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.R +import com.frogobox.appkeyboard.suggestion.SuggestionResult +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusSuccess + +/** + * Candidate types corresponding to the 3 predictive word strip sections. + */ +enum class CandidateType { + USER_WORD, + PREDICTED_WORD, + AUTOCORRECT_WORD +} + +/** + * Modern Jetpack Compose Candidate Suggestion Bar. + * Presents 3 distinct candidate sections (literal user input, hero predicted word, auto-correct alternative) + * along with quick switch and dismiss action controls. + */ +@Composable +fun KeyboardSuggestionBar( + result: SuggestionResult, + onCandidateSelected: (String, CandidateType) -> Unit, + onSwitchMenu: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .height(56.dp) + .background(MaterialTheme.colorScheme.surface) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 4.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + // Switch Menu Button (Far Left) + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .clickable(onClick = onSwitchMenu) + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_suggestion_switch), + contentDescription = "Switch to Feature Menu", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.width(2.dp)) + + // Section 1: User's Word (Literal Raw Input) + val hasUserWord = result.userWord.isNotEmpty() + if (hasUserWord) { + Box( + modifier = Modifier + .weight(1.0f) + .fillMaxHeight() + .clip(RoundedCornerShape(8.dp)) + .clickable { onCandidateSelected(result.userWord, CandidateType.USER_WORD) } + .padding(horizontal = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "“${result.userWord}”", + fontSize = 13.sp, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + } else { + Spacer(modifier = Modifier.weight(1.0f)) + } + + // Divider 1 + if (hasUserWord && result.predictedWord.isNotEmpty()) { + VerticalDivider( + modifier = Modifier + .height(20.dp) + .width(1.dp) + .padding(horizontal = 1.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + } + + // Section 2: Predicted Word (Center Hero Candidate Pill) + val hasPredicted = result.predictedWord.isNotEmpty() + if (hasPredicted) { + Surface( + modifier = Modifier + .weight(1.2f) + .height(32.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable { onCandidateSelected(result.predictedWord, CandidateType.PREDICTED_WORD) }, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)), + shadowElevation = 0.dp + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 6.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = result.predictedWord, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + } + } else { + Spacer(modifier = Modifier.weight(1.2f)) + } + + // Divider 2 + val hasAutocorrect = result.autoCorrectWord.isNotEmpty() && + result.autoCorrectWord != result.predictedWord + if (hasAutocorrect && (hasPredicted || hasUserWord)) { + VerticalDivider( + modifier = Modifier + .height(20.dp) + .width(1.dp) + .padding(horizontal = 1.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + } + + // Section 3: Auto-Correct Word (Alternative Fix) + if (hasAutocorrect) { + Box( + modifier = Modifier + .weight(1.0f) + .fillMaxHeight() + .clip(RoundedCornerShape(8.dp)) + .clickable { onCandidateSelected(result.autoCorrectWord, CandidateType.AUTOCORRECT_WORD) } + .padding(horizontal = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = result.autoCorrectWord, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = FrogoStatusSuccess, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + } else { + Spacer(modifier = Modifier.weight(1.0f)) + } + + Spacer(modifier = Modifier.width(2.dp)) + + // Close Button (Far Right) + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .clickable(onClick = onClose) + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_close_suggestion), + contentDescription = "Clear Suggestions", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + } + } + + HorizontalDivider( + thickness = 0.8.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextKeyboard.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextKeyboard.kt index 64a1957..d8b34f4 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextKeyboard.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextKeyboard.kt @@ -2,153 +2,79 @@ package com.frogobox.appkeyboard.ui.keyboard.templatetext import android.content.Context import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.LinearLayout -import com.frogobox.appkeyboard.databinding.ItemKeyboardNewsBinding -import com.frogobox.appkeyboard.databinding.KeyboardAutotextBinding +import android.view.inputmethod.InputConnection +import android.widget.FrameLayout +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import com.frogobox.appkeyboard.model.KeyboardFeatureType -import com.frogobox.appkeyboard.model.KeyboardFeatureType.TEMPLATE_TEXT_APP -import com.frogobox.appkeyboard.model.KeyboardFeatureType.TEMPLATE_TEXT_GAME -import com.frogobox.appkeyboard.model.KeyboardFeatureType.TEMPLATE_TEXT_GREETING -import com.frogobox.appkeyboard.model.KeyboardFeatureType.TEMPLATE_TEXT_LOVE -import com.frogobox.appkeyboard.model.KeyboardFeatureType.TEMPLATE_TEXT_SALE -import com.frogobox.appkeyboard.model.TemplateText -import com.frogobox.libkeyboard.common.core.BaseKeyboard -import com.frogobox.recycler.core.FrogoRecyclerNotifyListener -import com.frogobox.recycler.core.IFrogoBindingAdapter -import com.frogobox.recycler.ext.injectorBinding - -class TemplateTextKeyboard( +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import kotlinx.coroutines.flow.MutableStateFlow + +data class TemplateCategoryItem( + val type: KeyboardFeatureType, + val icon: String, + val title: String, + val isSelected: Boolean = false +) + +/** + * Modern Jetpack Compose-based TemplateText Keyboard panel. + */ +class TemplateTextKeyboard @JvmOverloads constructor( context: Context, - attrs: AttributeSet?, -) : BaseKeyboard(context, attrs) { - - private var typePlayStore: KeyboardFeatureType? = null - - override fun setupViewBinding(inflater: LayoutInflater, parent: LinearLayout): KeyboardAutotextBinding { - return KeyboardAutotextBinding.inflate(LayoutInflater.from(context), this, true) - } - - override fun initUI() { - super.initUI() - typePlayStore?.let { setupContent(it) } + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + companion object { + val CATEGORIES = listOf( + Pair(KeyboardFeatureType.TEMPLATE_TEXT_GAME, Pair("🎮", "Game")), + Pair(KeyboardFeatureType.TEMPLATE_TEXT_APP, Pair("📱", "App")), + Pair(KeyboardFeatureType.TEMPLATE_TEXT_SALE, Pair("💰", "Sale")), + Pair(KeyboardFeatureType.TEMPLATE_TEXT_GREETING, Pair("👋", "Greeting")), + Pair(KeyboardFeatureType.TEMPLATE_TEXT_LOVE, Pair("❤️", "Love")) + ) } - fun setupTemplateTextType(templateTextType: KeyboardFeatureType) { - this.typePlayStore = templateTextType - setupContent(templateTextType) - } - - private fun setupContent(templateTextType: KeyboardFeatureType) { - val title: String - val list: List - - when (templateTextType) { - TEMPLATE_TEXT_GAME -> { - title = getTitleText(TEMPLATE_TEXT_GAME.name) - list = TemplateTextUtils.getTextGame(context) - } - - TEMPLATE_TEXT_APP -> { - title = getTitleText(TEMPLATE_TEXT_APP.name) - list = TemplateTextUtils.getTextApp(context) - } - - TEMPLATE_TEXT_SALE -> { - title = getTitleText(TEMPLATE_TEXT_SALE.name) - list = TemplateTextUtils.getTextSale(context) - } - - TEMPLATE_TEXT_GREETING -> { - title = getTitleText(TEMPLATE_TEXT_GREETING.name) - list = TemplateTextUtils.getTextGreeting(context) - } - - TEMPLATE_TEXT_LOVE -> { - title = getTitleText(TEMPLATE_TEXT_LOVE.name) - list = TemplateTextUtils.getTextLove(context) - } - - else -> { - title = "" - list = listOf() + private val currentCategoryState = MutableStateFlow(KeyboardFeatureType.TEMPLATE_TEXT_GAME) + + var currentInputConnection: InputConnection? = null + var onBackClick: (() -> Unit)? = null + + private val composeView = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool) + setContent { + FrogoKeyboardTheme { + val category by currentCategoryState.collectAsState() + TemplateTextKeyboardScreen( + initialType = category, + onCommitText = { text -> + currentInputConnection?.commitText(text, 1) + }, + onBackClick = { + onBackClick?.invoke() + } + ) } - } - - binding.tvToolbarTitle.text = title - setupRv(list) } - private fun getTitleText(title: String): String { - return title.replace("TEMPLATE_TEXT_", "") + init { + addView(composeView) } - private fun setupRv(data: List) { - binding.apply { - - val adapterCallback = object : - IFrogoBindingAdapter { - override fun onItemClicked( - binding: ItemKeyboardNewsBinding, - data: TemplateText, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - // Your Clicked - val output = data.text - currentInputConnection?.commitText(output, 1) - } - - override fun onItemLongClicked( - binding: ItemKeyboardNewsBinding, - data: TemplateText, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - } - - override fun areContentsTheSame( - oldItem: TemplateText, - newItem: TemplateText - ): Boolean { - return oldItem == newItem - } - - override fun areItemsTheSame( - oldItem: TemplateText, - newItem: TemplateText - ): Boolean { - return oldItem.id == newItem.id - } - - override fun setViewBinding(parent: ViewGroup): ItemKeyboardNewsBinding { - return ItemKeyboardNewsBinding.inflate( - LayoutInflater.from(context), - parent, - false - ) - } + fun setupTemplateTextType(templateTextType: KeyboardFeatureType) { + currentCategoryState.value = templateTextType + } - override fun setupInitComponent( - binding: ItemKeyboardNewsBinding, - data: TemplateText, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - binding.apply { - tvItemKeyboardMain.text = data.text - } - } - } + fun setInputConnection(inputConnection: InputConnection?) { + currentInputConnection = inputConnection + } - rvKeyboardMain.injectorBinding() - .addData(data) - .createLayoutLinearVertical(false) - .addCallback(adapterCallback) - .build() - } + fun setOnBackClickListener(listener: () -> Unit) { + onBackClick = listener } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextKeyboardScreen.kt new file mode 100644 index 0000000..fe4c9d0 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextKeyboardScreen.kt @@ -0,0 +1,170 @@ +package com.frogobox.appkeyboard.ui.keyboard.templatetext + +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.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +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.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.model.KeyboardFeatureType +import com.frogobox.appkeyboard.model.KeyboardFeatureType.TEMPLATE_TEXT_APP +import com.frogobox.appkeyboard.model.KeyboardFeatureType.TEMPLATE_TEXT_GAME +import com.frogobox.appkeyboard.model.KeyboardFeatureType.TEMPLATE_TEXT_GREETING +import com.frogobox.appkeyboard.model.KeyboardFeatureType.TEMPLATE_TEXT_LOVE +import com.frogobox.appkeyboard.model.KeyboardFeatureType.TEMPLATE_TEXT_SALE +import com.frogobox.appkeyboard.ui.keyboard.common.KeyboardFeatureToolbar + +data class TemplateCategory( + val type: KeyboardFeatureType, + val icon: String, + val title: String +) + +val CATEGORIES = listOf( + TemplateCategory(TEMPLATE_TEXT_GAME, "🎮", "Game"), + TemplateCategory(TEMPLATE_TEXT_APP, "📱", "App"), + TemplateCategory(TEMPLATE_TEXT_SALE, "💰", "Sale"), + TemplateCategory(TEMPLATE_TEXT_GREETING, "👋", "Greeting"), + TemplateCategory(TEMPLATE_TEXT_LOVE, "❤️", "Love") +) + +@Composable +fun TemplateTextKeyboardScreen( + onCommitText: (String) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + initialType: KeyboardFeatureType = TEMPLATE_TEXT_GAME +) { + val context = LocalContext.current + var selectedType by remember(initialType) { mutableStateOf(initialType) } + + val currentCategoryTitle = when (selectedType) { + TEMPLATE_TEXT_GAME -> "Game Templates" + TEMPLATE_TEXT_APP -> "App Templates" + TEMPLATE_TEXT_SALE -> "Sale / Store Templates" + TEMPLATE_TEXT_GREETING -> "Greeting Templates" + TEMPLATE_TEXT_LOVE -> "Love & Sweet Templates" + else -> "Quick Templates" + } + + val templateList = remember(selectedType) { + when (selectedType) { + TEMPLATE_TEXT_GAME -> TemplateTextUtils.getTextGame(context) + TEMPLATE_TEXT_APP -> TemplateTextUtils.getTextApp(context) + TEMPLATE_TEXT_SALE -> TemplateTextUtils.getTextSale(context) + TEMPLATE_TEXT_GREETING -> TemplateTextUtils.getTextGreeting(context) + TEMPLATE_TEXT_LOVE -> TemplateTextUtils.getTextLove(context) + else -> emptyList() + } + } + + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + ) { + KeyboardFeatureToolbar( + title = currentCategoryTitle, + subtitle = "Tap any template to paste into chat", + onBackClick = onBackClick + ) + + // Categories Chip Row + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + contentPadding = PaddingValues(horizontal = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + items(CATEGORIES) { category -> + val isSelected = category.type == selectedType + FilterChip( + selected = isSelected, + onClick = { selectedType = category.type }, + label = { + Text( + text = "${category.icon} ${category.title}", + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + }, + shape = RoundedCornerShape(8.dp), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = isSelected, + borderColor = MaterialTheme.colorScheme.outlineVariant, + selectedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + ) + ) + } + } + + HorizontalDivider( + thickness = 0.8.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + ) + + // Template List + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + items( + items = templateList, + key = { it.id } + ) { template -> + Card( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onCommitText(template.text) }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + ), + shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)) + ) { + Text( + text = template.text, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 12.5.sp + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp) + ) + } + } + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextUtils.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextUtils.kt index 8fbe299..bd41991 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextUtils.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextUtils.kt @@ -4,7 +4,6 @@ import android.content.Context import com.frogobox.appkeyboard.model.KeyboardFeatureType import com.frogobox.appkeyboard.model.TemplateText import com.frogobox.sdk.ext.getDataFromJsonAsset -import com.frogobox.sdk.util.FrogoFunc /** * Created by Faisal Amir on 24/10/22 diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/webview/WebiewKeyboard.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/webview/WebiewKeyboard.kt index 2a51fcc..81a48e0 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/webview/WebiewKeyboard.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/webview/WebiewKeyboard.kt @@ -2,36 +2,50 @@ package com.frogobox.appkeyboard.ui.keyboard.webview import android.content.Context import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.LinearLayout -import com.frogobox.appkeyboard.databinding.KeyboardWebviewBinding -import com.frogobox.libkeyboard.common.core.BaseKeyboard -import com.frogobox.sdk.ext.loadUrlFrogoExt +import android.view.inputmethod.InputConnection +import android.widget.FrameLayout +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme /** - * Created by Faisal Amir on 07/11/22 - * ----------------------------------------- - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - * Copyright (C) Frogobox ID / amirisback - * All rights reserved + * Modern Jetpack Compose-based Webview Keyboard panel. */ - -class WebiewKeyboard( +class WebiewKeyboard @JvmOverloads constructor( context: Context, - attrs: AttributeSet?, -) : BaseKeyboard(context, attrs) { + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { - override fun setupViewBinding(inflater: LayoutInflater, parent: LinearLayout): KeyboardWebviewBinding { - return KeyboardWebviewBinding.inflate(LayoutInflater.from(context), this, true) - } + var currentInputConnection: InputConnection? = null + var onBackClick: (() -> Unit)? = null - override fun initUI() { - super.initUI() - binding.apply { - webview.loadUrlFrogoExt("https://www.google.com") + private val composeView = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool) + setContent { + FrogoKeyboardTheme { + WebviewKeyboardScreen( + onCommitText = { text -> + currentInputConnection?.commitText(text, 1) + }, + onBackClick = { + onBackClick?.invoke() + } + ) + } } } + init { + addView(composeView) + } + + fun setInputConnection(inputConnection: InputConnection?) { + currentInputConnection = inputConnection + } + + fun setOnBackClickListener(listener: () -> Unit) { + onBackClick = listener + } + } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/webview/WebviewKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/webview/WebviewKeyboardScreen.kt new file mode 100644 index 0000000..a5e6283 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/webview/WebviewKeyboardScreen.kt @@ -0,0 +1,259 @@ +package com.frogobox.appkeyboard.ui.keyboard.webview + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.view.ViewGroup +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import java.net.URLEncoder + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun WebviewKeyboardScreen( + onCommitText: (String) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + var urlInput by remember { mutableStateOf("https://www.google.com") } + var currentWebUrl by remember { mutableStateOf("https://www.google.com") } + var webProgress by remember { mutableIntStateOf(0) } + var webViewInstance by remember { mutableStateOf(null) } + + val focusManager = LocalFocusManager.current + + val loadUrlOrSearch: () -> Unit = { + val query = urlInput.trim() + val targetUrl = when { + query.startsWith("http://") || query.startsWith("https://") -> query + query.contains(".") && !query.contains(" ") -> "https://$query" + query.isNotEmpty() -> "https://www.google.com/search?q=" + URLEncoder.encode(query, "UTF-8") + else -> "https://www.google.com" + } + urlInput = targetUrl + webViewInstance?.loadUrl(targetUrl) + focusManager.clearFocus() + } + + Column( + modifier = modifier + .fillMaxWidth() + .height(270.dp) + .background(MaterialTheme.colorScheme.surface) + ) { + // Web Navigation Bar + Row( + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Back Button + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .clickable { onBackClick() } + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + Spacer(modifier = Modifier.width(6.dp)) + + // Search / URL input bar + Row( + modifier = Modifier + .weight(1f) + .height(34.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + + Spacer(modifier = Modifier.width(4.dp)) + + BasicTextField( + value = urlInput, + onValueChange = { urlInput = it }, + singleLine = true, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.5.sp, + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { loadUrlOrSearch() }), + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.width(6.dp)) + + // Reload Button + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .clickable { webViewInstance?.reload() } + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Reload", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + Spacer(modifier = Modifier.width(6.dp)) + + // Paste URL Pill + Row( + modifier = Modifier + .height(30.dp) + .clip(RoundedCornerShape(15.dp)) + .background(MaterialTheme.colorScheme.primary) + .clickable { + val activeUrl = webViewInstance?.url ?: currentWebUrl + if (activeUrl.isNotBlank()) { + onCommitText(activeUrl) + } + } + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.ContentPaste, + contentDescription = "Paste", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(12.dp) + ) + Spacer(modifier = Modifier.width(3.dp)) + Text( + text = "Paste", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimary + ) + } + } + + // Web Loading Progress Bar + if (webProgress in 1..99) { + LinearProgressIndicator( + progress = { webProgress / 100f }, + modifier = Modifier + .fillMaxWidth() + .height(2.5.dp), + color = MaterialTheme.colorScheme.primary + ) + } else { + HorizontalDivider( + thickness = 0.8.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + ) + } + + // Native WebView embedded in Compose + AndroidView( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + factory = { ctx -> + WebView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + url?.let { + urlInput = it + currentWebUrl = it + } + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + webProgress = 100 + } + } + + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + super.onProgressChanged(view, newProgress) + webProgress = newProgress + } + } + + loadUrl("https://www.google.com") + webViewInstance = this + } + }, + update = { webViewInstance = it } + ) + } +} 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 f5fc81a..ec7e0ae 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 @@ -4,6 +4,7 @@ import android.os.Bundle import androidx.activity.viewModels 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 @@ -16,7 +17,7 @@ class KeyboardLanguageActivity : BaseComposeActivity() { private val viewModel: KeyboardLanguageViewModel by viewModels() private var languageList by mutableStateOf>(emptyList()) - private var activeLanguageXml by mutableStateOf(0) + private var activeLanguageXml by mutableIntStateOf(0) override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) 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 2daf85d..ce9a9be 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 @@ -198,14 +198,14 @@ fun KeyboardLanguageScreen( verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( - text = "Are you sure you want to switch your active typing layout?", + text = "Apakah Anda yakin ingin mengganti tata letak keyboard aktif?", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) // Selected Language Preview Box Surface( - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), modifier = Modifier.fillMaxWidth() ) { @@ -219,7 +219,7 @@ fun KeyboardLanguageScreen( modifier = Modifier .size(40.dp) .clip(CircleShape) - .background(FrogoPrimary.copy(alpha = 0.2f)), + .background(FrogoPrimary.copy(alpha = 0.15f)), contentAlignment = Alignment.Center ) { Text( @@ -261,10 +261,10 @@ fun KeyboardLanguageScreen( colors = ButtonDefaults.buttonColors( containerColor = FrogoPrimary ), - shape = RoundedCornerShape(10.dp) + shape = RoundedCornerShape(8.dp) ) { Text( - text = "Apply Layout", + text = "Terapkan Layout", fontWeight = FontWeight.Bold ) } @@ -273,10 +273,10 @@ fun KeyboardLanguageScreen( TextButton( onClick = { selectedLanguageForDialog = null } ) { - Text("Cancel") + Text("Batal") } }, - shape = RoundedCornerShape(18.dp) + shape = RoundedCornerShape(16.dp) ) } } @@ -312,11 +312,11 @@ private fun ActiveLanguageHeroCard( ) { Card( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(18.dp), + shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( - containerColor = FrogoPrimary.copy(alpha = 0.08f) + containerColor = MaterialTheme.colorScheme.surface ), - border = BorderStroke(1.5.dp, FrogoPrimary.copy(alpha = 0.35f)), + border = BorderStroke(1.dp, FrogoPrimary.copy(alpha = 0.5f)), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Column( @@ -332,8 +332,8 @@ private fun ActiveLanguageHeroCard( horizontalArrangement = Arrangement.SpaceBetween ) { Surface( - shape = RoundedCornerShape(8.dp), - color = FrogoStatusSuccess.copy(alpha = 0.15f) + shape = RoundedCornerShape(6.dp), + color = FrogoStatusSuccess.copy(alpha = 0.12f) ) { Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), @@ -347,7 +347,7 @@ private fun ActiveLanguageHeroCard( .background(FrogoStatusSuccess) ) Text( - text = "CURRENT ACTIVE", + text = "LAYOUT AKTIF", style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Bold, fontSize = 10.sp, @@ -358,7 +358,7 @@ private fun ActiveLanguageHeroCard( } Text( - text = "Default Input Method", + text = "Input Default", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -448,10 +448,10 @@ private fun SearchAndFilterHeader( value = query, onValueChange = onQueryChange, modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(8.dp), placeholder = { Text( - text = "Search language, code, or layout...", + text = "Cari bahasa, kode, atau tata letak...", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) @@ -459,7 +459,7 @@ private fun SearchAndFilterHeader( leadingIcon = { Icon( imageVector = Icons.Default.Search, - contentDescription = "Search", + contentDescription = "Cari", tint = FrogoPrimary, modifier = Modifier.size(20.dp) ) @@ -469,7 +469,7 @@ private fun SearchAndFilterHeader( IconButton(onClick = { onQueryChange("") }) { Icon( imageVector = Icons.Default.Close, - contentDescription = "Clear Search", + contentDescription = "Hapus Pencarian", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp) ) @@ -479,7 +479,7 @@ private fun SearchAndFilterHeader( singleLine = true, colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = FrogoPrimary, - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface ) @@ -494,7 +494,7 @@ private fun SearchAndFilterHeader( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = if (query.isBlank()) "Available Languages" else "Search Results", + text = if (query.isBlank()) "Daftar Bahasa Tersedia" else "Hasil Pencarian", style = MaterialTheme.typography.titleSmall.copy( fontWeight = FontWeight.Bold ), @@ -502,11 +502,11 @@ private fun SearchAndFilterHeader( ) Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceVariant ) { Text( - text = if (query.isBlank()) "$totalLanguages available" else "$filteredCount found", + text = if (query.isBlank()) "$totalLanguages bahasa" else "$filteredCount ditemukan", style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Medium ), @@ -531,11 +531,10 @@ private fun LanguageCard( Card( modifier = modifier .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) .clickable { onClick() }, - shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation( - defaultElevation = if (isSelected) 3.dp else 1.dp - ), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), colors = CardDefaults.cardColors( containerColor = if (isSelected) { FrogoPrimary.copy(alpha = 0.04f) @@ -544,11 +543,11 @@ private fun LanguageCard( } ), border = BorderStroke( - width = if (isSelected) 2.dp else 1.dp, + width = if (isSelected) 1.5.dp else 1.dp, color = if (isSelected) { FrogoPrimary } else { - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f) + MaterialTheme.colorScheme.outlineVariant } ) ) { @@ -657,11 +656,11 @@ private fun LanguageCard( horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Surface( - shape = RoundedCornerShape(8.dp), - color = FrogoStatusSuccess.copy(alpha = 0.15f) + shape = RoundedCornerShape(6.dp), + color = FrogoStatusSuccess.copy(alpha = 0.12f) ) { Text( - text = "ACTIVE", + text = "Aktif", style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Bold, fontSize = 10.sp, 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 a096b1a..9004140 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 @@ -5,7 +5,6 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.frogobox.appkeyboard.common.base.BaseViewModel 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 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 3e18982..84c56d7 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 @@ -26,6 +26,7 @@ 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.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -59,23 +60,23 @@ enum class KeyboardStatus( val icon: ImageVector ) { ACTIVE( - title = "Frogo Keyboard Active", - description = "Keyboard is activated and set as default input method.", - badge = "ACTIVE", + title = "Keyboard Siap Digunakan", + description = "Frogo Keyboard aktif dan terpilih sebagai metode input utama.", + badge = "AKTIF", 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", + title = "Bukan Keyboard Utama", + description = "Keyboard sudah aktif di sistem, namun belum dipilih sebagai input default.", + badge = "PILIH 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", + title = "Keyboard Belum Aktif", + description = "Aktifkan Frogo Keyboard melalui Pengaturan Sistem Android.", + badge = "BELUM AKTIF", color = FrogoStatusFailed, icon = Icons.Default.Info ) @@ -110,26 +111,27 @@ fun MainScreen( .fillMaxSize() .verticalScroll(scrollState) .padding(paddingValues) - .padding(horizontal = 20.dp, vertical = 12.dp), + .padding(horizontal = 16.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.", + text = "Konfigurasi metode input, kelola pintasan teks cepat, suara mekanik, dan personalisasi tema keyboard Anda.", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 20.sp ) Spacer(modifier = Modifier.height(16.dp)) - // Reactive Status Card + // Tactile Status Card (Hardware-inspired Precision System Status) Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( - containerColor = status.color.copy(alpha = 0.12f) + containerColor = MaterialTheme.colorScheme.surface ), - border = BorderStroke(1.dp, status.color.copy(alpha = 0.35f)), + border = BorderStroke(1.dp, status.color.copy(alpha = 0.5f)), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Row( @@ -140,16 +142,16 @@ fun MainScreen( ) { Box( modifier = Modifier - .size(42.dp) + .size(40.dp) .clip(CircleShape) - .background(status.color.copy(alpha = 0.2f)), + .background(status.color.copy(alpha = 0.12f)), contentAlignment = Alignment.Center ) { Icon( imageVector = status.icon, contentDescription = null, tint = status.color, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(22.dp) ) } @@ -164,15 +166,15 @@ fun MainScreen( Text( text = status.title, style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.SemiBold, fontSize = 15.sp ), - color = status.color + color = MaterialTheme.colorScheme.onSurface ) Surface( shape = RoundedCornerShape(6.dp), - color = status.color.copy(alpha = 0.2f) + color = status.color.copy(alpha = 0.12f) ) { Text( text = status.badge, @@ -181,17 +183,18 @@ fun MainScreen( fontSize = 10.sp ), color = status.color, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + modifier = Modifier.padding(horizontal = 7.dp, vertical = 2.dp) ) } } - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(3.dp)) Text( text = status.description, style = MaterialTheme.typography.bodySmall.copy( - fontSize = 12.sp + fontSize = 12.sp, + lineHeight = 16.sp ), color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -201,15 +204,15 @@ fun MainScreen( Spacer(modifier = Modifier.height(16.dp)) - // Activation Steps Row (Step 1: Settings, Step 2: Switch Keyboard) + // Activation Steps Row (Tactile Hardware Steps) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { ActivationCard( stepNumber = "1", - title = "Enable Keyboard", - subtitle = "System Settings", + title = "Aktifkan Keyboard", + subtitle = "Pengaturan Sistem", iconRes = R.drawable.ic_menu_setting, onClick = onGoToSettings, modifier = Modifier.weight(1f) @@ -217,19 +220,19 @@ fun MainScreen( ActivationCard( stepNumber = "2", - title = "Set Default", - subtitle = "Switch Keyboard", + title = "Pilih Default", + subtitle = "Ganti Input Aktif", iconRes = R.drawable.ic_menu_keyboard, onClick = onChangeKeyboard, modifier = Modifier.weight(1f) ) } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(22.dp)) // Section Header Text( - text = "Features & Customization", + text = "Fitur dan Personalisasi", style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.Bold, fontSize = 16.sp @@ -237,51 +240,51 @@ fun MainScreen( color = MaterialTheme.colorScheme.onBackground ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(10.dp)) - // Feature Menu List (Material 3 Cards with Icon, Subtitle, and Chevron) + // Feature Menu List (Tactile 1dp Border Cards) Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp) ) { MainMenuItemCard( title = "Auto Text", - subtitle = "Quick snippets & repetitive text templates", + subtitle = "Template teks cepat dan pintasan ketikan berulang", iconRes = R.drawable.ic_menu_auto_text, onClick = onNavigateAutoText ) MainMenuItemCard( - title = "Toggle Function", - subtitle = "Keyboard shortcuts & toolbar switches", + title = "Toggle Fitur", + subtitle = "Kustomisasi tombol pintas dan toolbar keyboard", iconRes = R.drawable.ic_menu_form, onClick = onNavigateToggle ) MainMenuItemCard( - title = "Sound & Haptics", - subtitle = "Mechanical switch sounds & key vibration", + title = "Suara dan Haptic", + subtitle = "Suara switch mechanical keyboard dan feedback getaran", iconRes = R.drawable.ic_menu_sound, onClick = onNavigateSound ) MainMenuItemCard( - title = "Keyboard Language", - subtitle = "Multilingual input languages & layouts", + title = "Bahasa Keyboard", + subtitle = "Dukungan multibahasa dan layout QWERTY internasional", iconRes = R.drawable.ic_menu_website, onClick = onNavigateLanguage ) MainMenuItemCard( - title = "Keyboard Theme", - subtitle = "Personalized colors, themes & wallpapers", + title = "Tema Keyboard", + subtitle = "Pilihan tema warna solid, wallpaper, dan preview interaktif", iconRes = R.drawable.ic_menu_ps_game, onClick = onNavigateTheme ) MainMenuItemCard( - title = "Do Some Test", - subtitle = "Interactive typing test & keyboard sandbox", + title = "Playground Uji Coba", + subtitle = "Uji kecepatan ketik (WPM), akurasi, dan sandbox keyboard", iconRes = R.drawable.ic_menu_keyboard, onClick = onNavigateTest ) @@ -303,18 +306,19 @@ private fun ActivationCard( ) { Card( modifier = modifier + .clip(RoundedCornerShape(12.dp)) .clickable(onClick = onClick), - shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .padding(14.dp), horizontalAlignment = Alignment.Start ) { Row( @@ -325,15 +329,15 @@ private fun ActivationCard( Image( painter = painterResource(id = iconRes), contentDescription = title, - modifier = Modifier.size(36.dp) + modifier = Modifier.size(32.dp) ) Surface( shape = RoundedCornerShape(6.dp), - color = FrogoPrimary.copy(alpha = 0.1f) + color = FrogoPrimary.copy(alpha = 0.12f) ) { Text( - text = "STEP $stepNumber", + text = "LANGKAH $stepNumber", style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Bold, fontSize = 10.sp @@ -344,12 +348,12 @@ private fun ActivationCard( } } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(10.dp)) Text( text = title, style = MaterialTheme.typography.labelLarge.copy( - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.SemiBold, fontSize = 14.sp ), color = MaterialTheme.colorScheme.onSurface @@ -379,24 +383,25 @@ private fun MainMenuItemCard( Card( modifier = modifier .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) .clickable(onClick = onClick), - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(12.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) + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), + .padding(horizontal = 14.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically ) { Image( painter = painterResource(id = iconRes), contentDescription = title, - modifier = Modifier.size(36.dp) + modifier = Modifier.size(32.dp) ) Spacer(modifier = Modifier.width(14.dp)) @@ -416,7 +421,8 @@ private fun MainMenuItemCard( Text( text = subtitle, style = MaterialTheme.typography.bodySmall.copy( - fontSize = 12.sp + fontSize = 12.sp, + lineHeight = 16.sp ), color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, 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 index 62e609b..b47dfd9 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundActivity.kt @@ -4,6 +4,7 @@ import android.os.Bundle import androidx.activity.viewModels 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 @@ -17,7 +18,7 @@ class SoundActivity : BaseComposeActivity() { private var soundEnabled by mutableStateOf(true) private var selectedSoundType by mutableStateOf(MechanicalSoundType.CHERRY_MX_BLUE) - private var soundVolume by mutableStateOf(80) + private var soundVolume by mutableIntStateOf(80) private var vibrateEnabled by mutableStateOf(true) override fun onCreateExt(savedInstanceState: Bundle?) { 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 index 75b2b8e..5c3a54d 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundScreen.kt @@ -2,7 +2,6 @@ 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 @@ -44,7 +43,6 @@ 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 @@ -105,17 +103,16 @@ fun SoundScreen( 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( + text = "Profil Suara Switch Mekanik", + style = MaterialTheme.typography.titleSmall.copy( fontWeight = FontWeight.Bold, - fontSize = 12.sp, - letterSpacing = 0.8.sp + fontSize = 14.sp ), color = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Authentic tactile & acoustic mechanical switch models", + text = "Model suara dan karakter akustik mechanical switch autentik", style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -140,11 +137,10 @@ fun SoundScreen( item(span = { GridItemSpan(maxLineSpan) }) { Column(modifier = Modifier.padding(top = 10.dp, bottom = 2.dp)) { Text( - text = "VOLUME & HAPTIC FEEDBACK", - style = MaterialTheme.typography.labelMedium.copy( + text = "Volume dan Feedback Haptic", + style = MaterialTheme.typography.titleSmall.copy( fontWeight = FontWeight.Bold, - fontSize = 12.sp, - letterSpacing = 0.8.sp + fontSize = 14.sp ), color = MaterialTheme.colorScheme.primary ) @@ -205,11 +201,11 @@ private fun MasterSoundCard( Card( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(18.dp), + shape = RoundedCornerShape(12.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) + color = if (soundEnabled) primaryColor.copy(alpha = 0.4f) else MaterialTheme.colorScheme.outlineVariant ), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { @@ -277,7 +273,7 @@ private fun SoundProfileCard( ) { val primaryColor = MaterialTheme.colorScheme.primary val borderColor by animateColorAsState( - targetValue = if (isSelected) primaryColor else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), + targetValue = if (isSelected) primaryColor else MaterialTheme.colorScheme.outlineVariant, label = "border" ) val containerColor by animateColorAsState( @@ -288,9 +284,9 @@ private fun SoundProfileCard( Card( modifier = modifier .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(12.dp)) .clickable(onClick = onClick), - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = containerColor), border = BorderStroke(if (isSelected) 1.5.dp else 1.dp, borderColor), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) @@ -306,21 +302,21 @@ private fun SoundProfileCard( verticalAlignment = Alignment.CenterVertically ) { Surface( - shape = RoundedCornerShape(8.dp), + shape = RoundedCornerShape(6.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" + 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 + fontWeight = FontWeight.SemiBold, + fontSize = 10.sp ), color = if (isSelected) FrogoPrimary else MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) @@ -372,9 +368,9 @@ private fun VolumeControlCard( ) { Card( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Column( @@ -427,9 +423,9 @@ private fun HapticVibrationCard( ) { Card( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Row( @@ -457,7 +453,7 @@ private fun HapticVibrationCard( Column(modifier = Modifier.weight(1f)) { Text( - text = "Keypress Vibration (Haptic)", + text = "Getaran Tombol (Haptic)", style = MaterialTheme.typography.titleSmall.copy( fontWeight = FontWeight.Bold, fontSize = 14.sp @@ -466,7 +462,7 @@ private fun HapticVibrationCard( ) Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Subtle tactile pulse on key touch", + text = "Getaran tactile halus saat tombol disentuh", style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -493,11 +489,11 @@ private fun LiveSoundPlaygroundCard( ) { Card( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(18.dp), + shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) + containerColor = MaterialTheme.colorScheme.surface ), - border = BorderStroke(1.dp, FrogoPrimary.copy(alpha = 0.3f)), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Column( @@ -511,27 +507,26 @@ private fun LiveSoundPlaygroundCard( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "LIVE MECHANICAL KEYBOARD PLAYGROUND", - style = MaterialTheme.typography.labelSmall.copy( + text = "Playground Suara Mekanik", + style = MaterialTheme.typography.titleSmall.copy( fontWeight = FontWeight.Bold, - fontSize = 11.sp, - letterSpacing = 0.8.sp + fontSize = 14.sp ), color = FrogoPrimary ) Surface( shape = RoundedCornerShape(6.dp), - color = FrogoPrimary.copy(alpha = 0.15f) + color = FrogoPrimary.copy(alpha = 0.12f) ) { Text( - text = "TEST ACOUSTICS", + text = "Uji Suara", style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Bold, - fontSize = 9.sp + fontSize = 10.sp ), color = FrogoPrimary, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) ) } } @@ -539,7 +534,7 @@ private fun LiveSoundPlaygroundCard( Spacer(modifier = Modifier.height(6.dp)) Text( - text = "Tap keycaps below to experience ${selectedSoundType.title} acoustics and tactile vibration:", + text = "Sentuh tombol di bawah untuk mendengarkan karakter suara ${selectedSoundType.title} dan getaran haptic:", style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -561,7 +556,7 @@ private fun LiveSoundPlaygroundCard( } PlaygroundKeycap( - label = "SPACE", + label = "SPASI", onClick = onKeyTap, modifier = Modifier.weight(2f) ) @@ -587,15 +582,15 @@ private fun PlaygroundKeycap( Surface( modifier = modifier .height(44.dp) - .clip(RoundedCornerShape(10.dp)) + .clip(RoundedCornerShape(8.dp)) .clickable(onClick = onClick), - shape = RoundedCornerShape(10.dp), - color = if (isAction) FrogoPrimary else MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(8.dp), + color = if (isAction) FrogoPrimary else MaterialTheme.colorScheme.surfaceVariant, border = BorderStroke( 1.dp, - if (isAction) FrogoPrimary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + if (isAction) FrogoPrimary else MaterialTheme.colorScheme.outlineVariant ), - shadowElevation = 2.dp + shadowElevation = 0.dp ) { Box( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/test/DataApiUiState.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/test/DataApiUiState.kt new file mode 100644 index 0000000..345e6f9 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/test/DataApiUiState.kt @@ -0,0 +1,19 @@ +package com.frogobox.appkeyboard.ui.test + +import androidx.annotation.Keep +import com.frogobox.appkeyboard.data.remote.model.DataItemResponse + +/** + * UI State machine representing Remote API fetch lifecycle in Test Playground. + */ +@Keep +sealed interface DataApiUiState { + data object Idle : DataApiUiState + data object Loading : DataApiUiState + data class Success( + val total: Int, + val lastUpdatedWib: String, + val items: List + ) : DataApiUiState + data class Error(val message: String) : DataApiUiState +} 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 7f82c6b..2a643b6 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 @@ -22,7 +22,6 @@ 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 @@ -40,7 +39,6 @@ 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 @@ -51,8 +49,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults -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 @@ -71,8 +67,6 @@ 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.ui.text.input.ImeAction @@ -137,7 +131,7 @@ fun TestScreen( modifier = modifier.fillMaxSize(), topBar = { FrogoTopAppBar( - title = "Test Area", + title = "Playground Uji Coba", onBackClick = onBackClick ) } @@ -160,9 +154,9 @@ fun TestScreen( // 2. Real-time Live Typing Metrics Card LiveMetricsCard(metrics = metrics) - // 3. Tab Navigation Row + // 3. Tab Navigation Row (3 Tabs) PrimaryTabRow( - selectedTabIndex = activeTab, + selectedTabIndex = activeTab.coerceIn(0, 2), containerColor = MaterialTheme.colorScheme.surface, contentColor = FrogoPrimary ) { @@ -175,7 +169,7 @@ fun TestScreen( Tab( selected = activeTab == 1, onClick = { onTabSelected(1) }, - text = { Text("Inputs", fontWeight = FontWeight.SemiBold) }, + text = { Text("Input Khusus", fontWeight = FontWeight.SemiBold) }, icon = { Icon(Icons.Default.Keyboard, contentDescription = null, modifier = Modifier.size(20.dp)) } ) Tab( @@ -198,7 +192,7 @@ fun TestScreen( 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() + Toast.makeText(context, "Teks disalin ke clipboard", Toast.LENGTH_SHORT).show() } } ) @@ -223,7 +217,7 @@ fun TestScreen( snippets = autoTextList, onSnippetClicked = { snippet -> onInsertText(snippet.body) - Toast.makeText(context, "Inserted '${snippet.title}' to sandbox", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Disisipkan '${snippet.title}' ke sandbox", Toast.LENGTH_SHORT).show() onTabSelected(0) } ) @@ -246,21 +240,22 @@ private fun KeyboardStatusCard( modifier: Modifier = Modifier ) { val statusColor = if (isFrogoActive) FrogoStatusSuccess else FrogoStatusWarning - val statusTitle = if (isFrogoActive) "Frogo Keyboard Active" else "Other Keyboard Active" + val statusTitle = if (isFrogoActive) "Frogo Keyboard Aktif" else "Keyboard Lain Aktif" val statusDesc = if (isFrogoActive) { - "Frogo Keyboard is default. Testing real keyboard layouts." + "Frogo Keyboard aktif sebagai input default. Anda sedang menguji tata letak asli." } else { - "External keyboard is active. Switch to test Frogo features." + "Metode input lain sedang aktif. Ganti untuk menguji fitur Frogo Keyboard." } val icon = if (isFrogoActive) Icons.Default.CheckCircle else Icons.Default.Warning Card( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( - containerColor = statusColor.copy(alpha = 0.08f) + containerColor = MaterialTheme.colorScheme.surface ), - border = BorderStroke(1.dp, statusColor.copy(alpha = 0.25f)) + border = BorderStroke(1.dp, statusColor.copy(alpha = 0.4f)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Column( modifier = Modifier @@ -276,7 +271,7 @@ private fun KeyboardStatusCard( modifier = Modifier .size(38.dp) .clip(CircleShape) - .background(statusColor.copy(alpha = 0.16f)), + .background(statusColor.copy(alpha = 0.12f)), contentAlignment = Alignment.Center ) { Icon( @@ -313,21 +308,21 @@ private fun KeyboardStatusCard( Button( onClick = onChangeKeyboard, modifier = Modifier.weight(1f), - shape = RoundedCornerShape(10.dp), + shape = RoundedCornerShape(8.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) + Text("Ganti Keyboard", fontSize = 13.sp) } OutlinedButton( onClick = onGoToSettings, - shape = RoundedCornerShape(10.dp) + shape = RoundedCornerShape(8.dp) ) { Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(4.dp)) - Text("Settings", fontSize = 13.sp) + Text("Pengaturan", fontSize = 13.sp) } } } @@ -342,13 +337,14 @@ private fun LiveMetricsCard( metrics: TypingMetrics, modifier: Modifier = Modifier ) { - ElevatedCard( + Card( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.elevatedCardColors( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp) + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Column( modifier = Modifier @@ -473,11 +469,12 @@ private fun SandboxTabContent( 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), + .height(180.dp), + placeholder = { Text("Ketik di area ini dengan keyboard Anda untuk menguji metrik pengetikan, kecepatan, dan responsivitas tombol...") }, + shape = RoundedCornerShape(8.dp), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = FrogoPrimary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface ) @@ -491,27 +488,27 @@ private fun SandboxTabContent( OutlinedButton( onClick = onCopyText, modifier = Modifier.weight(1f), - shape = RoundedCornerShape(10.dp) + shape = RoundedCornerShape(8.dp) ) { Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(6.dp)) - Text("Copy", fontSize = 13.sp) + Text("Salin", fontSize = 13.sp) } OutlinedButton( onClick = onResetTimer, modifier = Modifier.weight(1f), - shape = RoundedCornerShape(10.dp) + shape = RoundedCornerShape(8.dp) ) { Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(6.dp)) - Text("Reset WPM", fontSize = 13.sp) + Text("Reset", fontSize = 13.sp) } Button( onClick = onClearText, modifier = Modifier.weight(1f), - shape = RoundedCornerShape(10.dp), + shape = RoundedCornerShape(8.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer @@ -519,7 +516,7 @@ private fun SandboxTabContent( ) { Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(6.dp)) - Text("Clear", fontSize = 13.sp) + Text("Hapus", fontSize = 13.sp) } } } @@ -568,7 +565,7 @@ private fun SpecializedInputsTabContent( } }, singleLine = true, - shape = RoundedCornerShape(14.dp), + shape = RoundedCornerShape(8.dp), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Text, imeAction = ImeAction.Search @@ -580,10 +577,10 @@ private fun SpecializedInputsTabContent( value = numberInput, onValueChange = onNumberChange, modifier = Modifier.fillMaxWidth(), - label = { Text("Numeric Keypad (KeyboardType.Number)") }, + label = { Text("Input Angka (KeyboardType.Number)") }, placeholder = { Text("12345678") }, singleLine = true, - shape = RoundedCornerShape(14.dp), + shape = RoundedCornerShape(8.dp), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Next @@ -595,10 +592,10 @@ private fun SpecializedInputsTabContent( value = phoneInput, onValueChange = onPhoneChange, modifier = Modifier.fillMaxWidth(), - label = { Text("Phone Keypad (KeyboardType.Phone)") }, + label = { Text("Input Telepon (KeyboardType.Phone)") }, placeholder = { Text("+62 812-3456-7890") }, singleLine = true, - shape = RoundedCornerShape(14.dp), + shape = RoundedCornerShape(8.dp), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Phone, imeAction = ImeAction.Next @@ -610,10 +607,10 @@ private fun SpecializedInputsTabContent( value = passwordInput, onValueChange = onPasswordChange, modifier = Modifier.fillMaxWidth(), - label = { Text("Password (Visibility Toggle)") }, - placeholder = { Text("Enter secret password...") }, + label = { Text("Password (Toggle Visibilitas)") }, + placeholder = { Text("Masukkan kata sandi...") }, singleLine = true, - shape = RoundedCornerShape(14.dp), + shape = RoundedCornerShape(8.dp), visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, @@ -622,7 +619,7 @@ private fun SpecializedInputsTabContent( 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") + Icon(icon, contentDescription = if (passwordVisible) "Sembunyikan password" else "Tampilkan password") } } ) @@ -642,10 +639,10 @@ private fun SpecializedInputsTabContent( modifier = Modifier .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, true) .fillMaxWidth(), - label = { Text("Searchable Dropdown (Filter Chips)") }, - placeholder = { Text("Type city name...") }, + label = { Text("Dropdown Pencarian") }, + placeholder = { Text("Ketik nama kota...") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded) }, - shape = RoundedCornerShape(14.dp), + shape = RoundedCornerShape(8.dp), colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors() ) @@ -684,7 +681,7 @@ private fun AutoTextTabContent( verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( - text = "Tap any AutoText snippet to insert it into the test sandbox:", + text = "Sentuh salah satu template teks untuk menyisipkannya ke sandbox:", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -693,12 +690,14 @@ private fun AutoTextTabContent( Card( modifier = Modifier .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) .clickable { onSnippetClicked(snippet) }, - shape = RoundedCornerShape(14.dp), + shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Row( modifier = Modifier 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 67dfd24..accb896 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 @@ -2,24 +2,31 @@ package com.frogobox.appkeyboard.ui.test import androidx.lifecycle.viewModelScope import com.frogobox.appkeyboard.common.base.BaseViewModel +import com.frogobox.appkeyboard.common.core.Resource import com.frogobox.appkeyboard.model.AutoTextEntity import com.frogobox.appkeyboard.model.AutoTextLabelType import com.frogobox.appkeyboard.repository.autotext.AutoTextRepository +import com.frogobox.appkeyboard.repository.data.DataApiRepository 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 java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone import javax.inject.Inject /** * Created by Faisal Amir on 24/10/22 - * Redesigned for TASK-010 by Tim Mobile + * Redesigned for TASK-010 and TASK-015 by Tim Mobile */ @HiltViewModel class TestViewModel @Inject constructor( - private val repository: AutoTextRepository + private val repository: AutoTextRepository, + private val dataApiRepository: DataApiRepository ) : BaseViewModel() { private val _sandboxText = MutableStateFlow("") @@ -34,6 +41,9 @@ class TestViewModel @Inject constructor( private val _autoTextList = MutableStateFlow>(emptyList()) val autoTextList: StateFlow> = _autoTextList.asStateFlow() + private val _remoteApiUiState = MutableStateFlow(DataApiUiState.Idle) + val remoteApiUiState: StateFlow = _remoteApiUiState.asStateFlow() + private var typingStartTimestamp: Long = 0L init { @@ -121,4 +131,37 @@ class TestViewModel @Inject constructor( fun onTabSelected(tabIndex: Int) { _activeTab.value = tabIndex } + + fun fetchRemoteData() { + viewModelScope.launch { + dataApiRepository.fetchDataStream().collect { resource -> + when (resource) { + is Resource.Loading -> { + _remoteApiUiState.value = DataApiUiState.Loading + } + is Resource.Success -> { + val items = resource.data.data ?: emptyList() + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).apply { + timeZone = TimeZone.getTimeZone("Asia/Jakarta") + } + val nowWib = "${sdf.format(Date())} WIB" + _remoteApiUiState.value = DataApiUiState.Success( + total = resource.data.total ?: items.size, + lastUpdatedWib = resource.data.lastUpdated ?: nowWib, + items = items + ) + } + is Resource.Error -> { + _remoteApiUiState.value = DataApiUiState.Error( + message = resource.message + ) + } + } + } + } + } + + fun resetRemoteData() { + _remoteApiUiState.value = DataApiUiState.Idle + } } \ 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 index b63daf8..3dc2ad6 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 @@ -14,7 +14,6 @@ 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 @@ -29,17 +28,14 @@ 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 @@ -49,9 +45,7 @@ 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 @@ -59,7 +53,6 @@ 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 @@ -82,6 +75,7 @@ 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.FrogoStatusSuccess import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar enum class ThemeCategory(val title: String) { @@ -267,10 +261,10 @@ fun ThemeScreen( previewedTheme = current }, colors = ButtonDefaults.buttonColors(containerColor = FrogoPrimary), - shape = RoundedCornerShape(10.dp) + shape = RoundedCornerShape(8.dp) ) { Text( - text = "Apply Now", + text = "Terapkan Sekarang", fontWeight = FontWeight.Bold ) } @@ -279,10 +273,10 @@ fun ThemeScreen( TextButton( onClick = { confirmApplyDialogTheme = null } ) { - Text("Cancel") + Text("Batal") } }, - shape = RoundedCornerShape(20.dp) + shape = RoundedCornerShape(16.dp) ) } } @@ -319,14 +313,14 @@ private fun ThemePreviewHero( ) { Card( modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 3.dp), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.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) + width = if (isActive) 1.5.dp else 1.dp, + color = if (isActive) FrogoPrimary else MaterialTheme.colorScheme.outlineVariant ) ) { Column( @@ -394,9 +388,9 @@ private fun ThemePreviewHero( if (isActive) { Surface( - shape = RoundedCornerShape(12.dp), - color = Color(0xFF41AF60).copy(alpha = 0.12f), - border = BorderStroke(1.dp, Color(0xFF41AF60).copy(alpha = 0.4f)) + shape = RoundedCornerShape(8.dp), + color = FrogoStatusSuccess.copy(alpha = 0.12f), + border = BorderStroke(1.dp, FrogoStatusSuccess.copy(alpha = 0.4f)) ) { Row( modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), @@ -404,16 +398,16 @@ private fun ThemePreviewHero( ) { Icon( imageVector = Icons.Default.CheckCircle, - contentDescription = "Active", - tint = Color(0xFF41AF60), + contentDescription = "Aktif", + tint = FrogoStatusSuccess, modifier = Modifier.size(16.dp) ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = "Active", + text = "Aktif", style = MaterialTheme.typography.labelMedium.copy( fontWeight = FontWeight.Bold, - color = Color(0xFF41AF60), + color = FrogoStatusSuccess, fontSize = 12.sp ) ) @@ -423,12 +417,12 @@ private fun ThemePreviewHero( Button( onClick = onApplyClick, colors = ButtonDefaults.buttonColors(containerColor = FrogoPrimary), - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(8.dp), contentPadding = PaddingValues(horizontal = 14.dp, vertical = 6.dp), modifier = Modifier.height(34.dp) ) { Text( - text = "Apply", + text = "Terapkan", style = MaterialTheme.typography.labelMedium.copy( fontWeight = FontWeight.Bold, fontSize = 12.sp @@ -612,7 +606,7 @@ private fun ThemeCategoryFilterRow( selectedLabelColor = FrogoPrimary, selectedLeadingIconColor = FrogoPrimary ), - shape = RoundedCornerShape(12.dp) + shape = RoundedCornerShape(8.dp) ) } } @@ -634,7 +628,7 @@ private fun ThemeGridCard( targetValue = when { isActive -> FrogoPrimary isPreviewed -> FrogoPrimary.copy(alpha = 0.6f) - else -> MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + else -> MaterialTheme.colorScheme.outlineVariant }, animationSpec = tween(durationMillis = 200), label = "cardBorderColor" @@ -643,15 +637,15 @@ private fun ThemeGridCard( Card( modifier = modifier .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(12.dp)) .clickable { onCardClick() }, - shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation(defaultElevation = if (isPreviewed || isActive) 3.dp else 1.dp), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), border = BorderStroke( - width = if (isActive || isPreviewed) 2.dp else 1.dp, + width = if (isActive || isPreviewed) 1.5.dp else 1.dp, color = borderColor ) ) { @@ -779,8 +773,8 @@ private fun ThemeGridCard( // Floating Active Badge (Top Right) if (isActive) { Surface( - shape = RoundedCornerShape(bottomStart = 10.dp), - color = Color(0xFF41AF60), + shape = RoundedCornerShape(bottomStart = 8.dp), + color = FrogoStatusSuccess, modifier = Modifier.align(Alignment.TopEnd) ) { Row( @@ -789,13 +783,13 @@ private fun ThemeGridCard( ) { Icon( imageVector = Icons.Default.Check, - contentDescription = "Active", + contentDescription = "Aktif", tint = Color.White, modifier = Modifier.size(12.dp) ) Spacer(modifier = Modifier.width(3.dp)) Text( - text = "Active", + text = "Aktif", style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Bold, fontSize = 10.sp, @@ -806,12 +800,12 @@ private fun ThemeGridCard( } } else if (isPreviewed) { Surface( - shape = RoundedCornerShape(bottomStart = 10.dp), + shape = RoundedCornerShape(bottomStart = 8.dp), color = FrogoPrimary, modifier = Modifier.align(Alignment.TopEnd) ) { Text( - text = "Previewing", + text = "Preview", style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Bold, fontSize = 9.sp, 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 index c50fb02..ea01d43 100644 --- 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 @@ -2,26 +2,45 @@ 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) +// Tactile & Modern Tooling Color Palette (Anti-Slop Design System) +// Precision Steel Blue as Primary, Deep Slate for Dark Base, Clean Slate-White for Light Base + +// Primary Brand +val FrogoPrimary = Color(0xFF2563EB) // Precision Steel Blue (Light) +val FrogoPrimaryDark = Color(0xFF3B82F6) // Precision Steel Blue (Dark) +val FrogoPrimaryVariant = Color(0xFF1D4ED8) +val FrogoPrimaryContainer = Color(0xFFEFF6FF) +val FrogoPrimaryContainerDark = Color(0xFF1E3A8A) + +// Secondary & Accent +val FrogoSecondary = Color(0xFF0284C7) +val FrogoSecondaryDark = Color(0xFF38BDF8) + +// Backgrounds & Surfaces +val FrogoBackground = Color(0xFFF8FAFC) +val FrogoBackgroundDark = Color(0xFF0F172A) +val FrogoSurface = Color(0xFFFFFFFF) +val FrogoSurfaceDark = Color(0xFF1E293B) +val FrogoSurfaceVariantLight = Color(0xFFF1F5F9) +val FrogoSurfaceVariantDark = Color(0xFF334155) + +// Typography & Content +val FrogoText = Color(0xFF0F172A) +val FrogoTextDark = Color(0xFFF8FAFC) +val FrogoTextSecondary = Color(0xFF475569) +val FrogoTextSecondaryDark = Color(0xFF94A3B8) +val FrogoGrey = Color(0xFF64748B) + +// Borders & Tactile Separators +val FrogoDivider = Color(0xFFE2E8F0) +val FrogoDividerDark = Color(0xFF334155) +val FrogoBorder = Color(0xFFCBD5E1) +val FrogoBorderDark = Color(0xFF475569) + +// Semantic Status Colors (WCAG AA Compliant contrast ratio >= 4.5:1) +val FrogoStatusSuccess = Color(0xFF15803D) +val FrogoStatusSuccessDark = Color(0xFF22C55E) +val FrogoStatusWarning = Color(0xFFB45309) +val FrogoStatusWarningDark = Color(0xFFF59E0B) +val FrogoStatusFailed = Color(0xFFB91C1C) +val FrogoStatusFailedDark = Color(0xFFEF4444) 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 index 1d6d5ce..58734c3 100644 --- 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 @@ -8,60 +8,86 @@ 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.material.icons.filled.Inbox +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults 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.graphics.vector.ImageVector 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. + * Reusable Empty State Composable for Frogo Keyboard app screens. + * Designed with Anti-Slop principles: human copy, purposeful icon, optional direct CTA. */ @Composable fun FrogoEmptyView( - title: String = "NO DATA FOUND", - subtitle: String = "Please insert data", - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + title: String = "Belum Ada Data Tersimpan", + subtitle: String = "Item yang Anda tambahkan akan tersimpan rapi dan dapat diakses kapan saja.", + icon: ImageVector = Icons.Default.Inbox, + actionButtonText: String? = null, + onActionClick: (() -> Unit)? = null ) { Column( modifier = modifier .fillMaxWidth() - .padding(32.dp), + .padding(horizontal = 24.dp, vertical = 40.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Icon( - imageVector = Icons.Default.Info, + imageVector = icon, contentDescription = null, - modifier = Modifier.size(72.dp), + modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.outline ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = title.uppercase(), + text = title, style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - fontSize = 18.sp + fontWeight = FontWeight.SemiBold, + fontSize = 17.sp ), color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(6.dp)) Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, + lineHeight = 20.sp ) + + if (actionButtonText != null && onActionClick != null) { + Spacer(modifier = Modifier.height(18.dp)) + + Button( + onClick = onActionClick, + shape = MaterialTheme.shapes.small, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Text( + text = actionButtonText, + style = MaterialTheme.typography.labelLarge + ) + } + } } } 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 index 805fb5b..d6af6bf 100644 --- 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 @@ -8,29 +8,41 @@ 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), + primary = FrogoPrimaryDark, + onPrimary = Color(0xFF0F172A), + primaryContainer = FrogoPrimaryContainerDark, + onPrimaryContainer = Color(0xFFDBEAFE), + secondary = FrogoSecondaryDark, + onSecondary = Color(0xFF0F172A), + tertiary = FrogoPrimaryDark, + onTertiary = Color(0xFF0F172A), + background = FrogoBackgroundDark, + onBackground = FrogoTextDark, + surface = FrogoSurfaceDark, + onSurface = FrogoTextDark, + surfaceVariant = FrogoSurfaceVariantDark, + onSurfaceVariant = FrogoTextSecondaryDark, + outline = FrogoBorderDark, + outlineVariant = FrogoDividerDark ) private val LightColorScheme = lightColorScheme( primary = FrogoPrimary, + onPrimary = Color.White, + primaryContainer = FrogoPrimaryContainer, + onPrimaryContainer = Color(0xFF1E3A8A), secondary = FrogoSecondary, + onSecondary = Color.White, tertiary = FrogoPrimaryVariant, - background = FrogoBackground, - surface = Color.White, - onPrimary = Color.White, - onSecondary = Color.Black, onTertiary = Color.White, + background = FrogoBackground, onBackground = FrogoText, + surface = FrogoSurface, onSurface = FrogoText, + surfaceVariant = FrogoSurfaceVariantLight, + onSurfaceVariant = FrogoTextSecondary, + outline = FrogoBorder, + outlineVariant = FrogoDivider ) @Composable diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt index f9de6e9..32e772a 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt @@ -242,7 +242,7 @@ private fun ToggleHeaderBanner( contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp) ) { Text( - text = "Enable All", + text = "Aktifkan Semua", style = MaterialTheme.typography.labelMedium.copy( fontWeight = FontWeight.SemiBold, fontSize = 12.sp @@ -258,7 +258,7 @@ private fun ToggleHeaderBanner( contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp) ) { Text( - text = "Disable All", + text = "Nonaktifkan Semua", style = MaterialTheme.typography.labelMedium.copy( fontWeight = FontWeight.Normal, fontSize = 12.sp @@ -288,19 +288,19 @@ private fun ToggleFilterRow( verticalAlignment = Alignment.CenterVertically ) { ToggleFilterChip( - text = "All ($totalCount)", + text = "Semua ($totalCount)", isSelected = selectedTab == ToggleFilterTab.ALL, onClick = { onTabSelected(ToggleFilterTab.ALL) } ) ToggleFilterChip( - text = "Active ($activeCount)", + text = "Aktif ($activeCount)", isSelected = selectedTab == ToggleFilterTab.ACTIVE, onClick = { onTabSelected(ToggleFilterTab.ACTIVE) } ) ToggleFilterChip( - text = "Disabled ($disabledCount)", + text = "Nonaktif ($disabledCount)", isSelected = selectedTab == ToggleFilterTab.DISABLED, onClick = { onTabSelected(ToggleFilterTab.DISABLED) } ) @@ -316,9 +316,9 @@ private fun ToggleFilterChip( ) { Surface( modifier = modifier - .clip(RoundedCornerShape(20.dp)) + .clip(RoundedCornerShape(8.dp)) .clickable(onClick = onClick), - shape = RoundedCornerShape(20.dp), + shape = RoundedCornerShape(8.dp), color = if (isSelected) { MaterialTheme.colorScheme.primary } else { @@ -329,7 +329,7 @@ private fun ToggleFilterChip( color = if (isSelected) { MaterialTheme.colorScheme.primary } else { - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + MaterialTheme.colorScheme.outlineVariant } ) ) { @@ -344,7 +344,7 @@ private fun ToggleFilterChip( } else { MaterialTheme.colorScheme.onSurfaceVariant }, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp) + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) ) } } @@ -363,21 +363,21 @@ private fun ToggleFeatureCard( Card( modifier = modifier .fillMaxWidth() - .clip(RoundedCornerShape(18.dp)) + .clip(RoundedCornerShape(12.dp)) .clickable { onCheckedChange(!isChecked) }, - shape = RoundedCornerShape(18.dp), + shape = RoundedCornerShape(12.dp), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), border = BorderStroke( - width = 1.dp, + width = if (isChecked) 1.5.dp else 1.dp, color = if (isChecked) { - primaryColor.copy(alpha = 0.45f) + primaryColor.copy(alpha = 0.5f) } else { - outlineVariant.copy(alpha = 0.35f) + outlineVariant } ), colors = CardDefaults.cardColors( containerColor = if (isChecked) { - primaryColor.copy(alpha = 0.08f) + primaryColor.copy(alpha = 0.06f) } else { surfaceColor } diff --git a/app/src/main/res/drawable/bg_border.xml b/app/src/main/res/drawable/bg_border.xml deleted file mode 100644 index a50cbc9..0000000 --- a/app/src/main/res/drawable/bg_border.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_card_16dp.xml b/app/src/main/res/drawable/bg_card_16dp.xml deleted file mode 100644 index 8591763..0000000 --- a/app/src/main/res/drawable/bg_card_16dp.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_card_8dp.xml b/app/src/main/res/drawable/bg_card_8dp.xml deleted file mode 100644 index f15208e..0000000 --- a/app/src/main/res/drawable/bg_card_8dp.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_suggestion_bar.xml b/app/src/main/res/drawable/bg_suggestion_bar.xml deleted file mode 100644 index 5736f68..0000000 --- a/app/src/main/res/drawable/bg_suggestion_bar.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/bg_suggestion_pill.xml b/app/src/main/res/drawable/bg_suggestion_pill.xml deleted file mode 100644 index 6af3416..0000000 --- a/app/src/main/res/drawable/bg_suggestion_pill.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml deleted file mode 100644 index 5e77081..0000000 --- a/app/src/main/res/drawable/ic_check.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/app/src/main/res/layout/activity_autotext.xml b/app/src/main/res/layout/activity_autotext.xml deleted file mode 100644 index cb17793..0000000 --- a/app/src/main/res/layout/activity_autotext.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_autotext_detail.xml b/app/src/main/res/layout/activity_autotext_detail.xml deleted file mode 100644 index beb0198..0000000 --- a/app/src/main/res/layout/activity_autotext_detail.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_autotext_editor.xml b/app/src/main/res/layout/activity_autotext_editor.xml deleted file mode 100644 index 215377c..0000000 --- a/app/src/main/res/layout/activity_autotext_editor.xml +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_keyboard_language.xml b/app/src/main/res/layout/activity_keyboard_language.xml deleted file mode 100644 index 5852945..0000000 --- a/app/src/main/res/layout/activity_keyboard_language.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 4a2c273..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,199 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_theme.xml b/app/src/main/res/layout/activity_theme.xml deleted file mode 100644 index b1bd4e4..0000000 --- a/app/src/main/res/layout/activity_theme.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_toggle.xml b/app/src/main/res/layout/activity_toggle.xml deleted file mode 100644 index d655ab4..0000000 --- a/app/src/main/res/layout/activity_toggle.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_autotext.xml b/app/src/main/res/layout/item_autotext.xml deleted file mode 100644 index 8e7fbac..0000000 --- a/app/src/main/res/layout/item_autotext.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_keyboard_autotext.xml b/app/src/main/res/layout/item_keyboard_autotext.xml deleted file mode 100644 index 025b3f5..0000000 --- a/app/src/main/res/layout/item_keyboard_autotext.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_keyboard_header.xml b/app/src/main/res/layout/item_keyboard_header.xml deleted file mode 100644 index 164282d..0000000 --- a/app/src/main/res/layout/item_keyboard_header.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_keyboard_movie.xml b/app/src/main/res/layout/item_keyboard_movie.xml deleted file mode 100644 index 0fe31b9..0000000 --- a/app/src/main/res/layout/item_keyboard_movie.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_keyboard_multi_form.xml b/app/src/main/res/layout/item_keyboard_multi_form.xml deleted file mode 100644 index f17daff..0000000 --- a/app/src/main/res/layout/item_keyboard_multi_form.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_keyboard_news.xml b/app/src/main/res/layout/item_keyboard_news.xml deleted file mode 100644 index 3da3016..0000000 --- a/app/src/main/res/layout/item_keyboard_news.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_language_keyboard.xml b/app/src/main/res/layout/item_language_keyboard.xml deleted file mode 100644 index 2e3e411..0000000 --- a/app/src/main/res/layout/item_language_keyboard.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_spinner.xml b/app/src/main/res/layout/item_spinner.xml deleted file mode 100644 index 1d042e2..0000000 --- a/app/src/main/res/layout/item_spinner.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_theme.xml b/app/src/main/res/layout/item_theme.xml deleted file mode 100644 index 420c4ed..0000000 --- a/app/src/main/res/layout/item_theme.xml +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_toggle.xml b/app/src/main/res/layout/item_toggle.xml deleted file mode 100644 index 39c1bf7..0000000 --- a/app/src/main/res/layout/item_toggle.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - \ 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 deleted file mode 100644 index c3b768a..0000000 --- a/app/src/main/res/layout/keyboard_autotext.xml +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/keyboard_form.xml b/app/src/main/res/layout/keyboard_form.xml deleted file mode 100644 index 80edbe0..0000000 --- a/app/src/main/res/layout/keyboard_form.xml +++ /dev/null @@ -1,126 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/keyboard_grid.xml b/app/src/main/res/layout/keyboard_grid.xml deleted file mode 100644 index 9225523..0000000 --- a/app/src/main/res/layout/keyboard_grid.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/keyboard_ime.xml b/app/src/main/res/layout/keyboard_ime.xml deleted file mode 100644 index 553303b..0000000 --- a/app/src/main/res/layout/keyboard_ime.xml +++ /dev/null @@ -1,130 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/keyboard_list.xml b/app/src/main/res/layout/keyboard_list.xml deleted file mode 100644 index 1002db6..0000000 --- a/app/src/main/res/layout/keyboard_list.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/keyboard_multi_form.xml b/app/src/main/res/layout/keyboard_multi_form.xml deleted file mode 100644 index 1006fc0..0000000 --- a/app/src/main/res/layout/keyboard_multi_form.xml +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/keyboard_webview.xml b/app/src/main/res/layout/keyboard_webview.xml deleted file mode 100644 index 0a74e0c..0000000 --- a/app/src/main/res/layout/keyboard_webview.xml +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/layout_keyboard_suggestion.xml b/app/src/main/res/layout/layout_keyboard_suggestion.xml deleted file mode 100644 index 4bb0f18..0000000 --- a/app/src/main/res/layout/layout_keyboard_suggestion.xml +++ /dev/null @@ -1,135 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/view_empty.xml b/app/src/main/res/layout/view_empty.xml deleted file mode 100644 index 98c219f..0000000 --- a/app/src/main/res/layout/view_empty.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-night/color.xml b/app/src/main/res/values-night/color.xml index d24e8ee..c3b87e0 100644 --- a/app/src/main/res/values-night/color.xml +++ b/app/src/main/res/values-night/color.xml @@ -12,12 +12,4 @@ #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 4c9034d..1746e8e 100644 --- a/app/src/main/res/values/color.xml +++ b/app/src/main/res/values/color.xml @@ -12,12 +12,4 @@ #C2185B #FBC02D - - #F4F6F8 - #546E7A - #0288D1 - #E1F5FE - #37474F - #E0E0E0 - \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..853c722 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + 192.168.100.6 + 10.0.2.2 + localhost + + + diff --git a/app/src/test/java/com/frogobox/appkeyboard/KeyboardFeaturesRedesignTest.kt b/app/src/test/java/com/frogobox/appkeyboard/KeyboardFeaturesRedesignTest.kt new file mode 100644 index 0000000..89cc2f6 --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/KeyboardFeaturesRedesignTest.kt @@ -0,0 +1,132 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.model.KeyboardFeatureType +import com.frogobox.appkeyboard.ui.keyboard.templatetext.TemplateCategoryItem +import com.frogobox.appkeyboard.ui.keyboard.templatetext.TemplateTextKeyboard +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.URLEncoder + +class KeyboardFeaturesRedesignTest { + + @Test + fun testTemplateCategoriesStaticInitialization() { + assertFalse(TemplateTextKeyboard.CATEGORIES.isEmpty()) + assertEquals(5, TemplateTextKeyboard.CATEGORIES.size) + assertEquals(KeyboardFeatureType.TEMPLATE_TEXT_GAME, TemplateTextKeyboard.CATEGORIES[0].first) + assertEquals("🎮", TemplateTextKeyboard.CATEGORIES[0].second.first) + assertEquals("Game", TemplateTextKeyboard.CATEGORIES[0].second.second) + } + + @Test + fun testTemplateCategoriesCoverage() { + val categories = listOf( + TemplateCategoryItem(KeyboardFeatureType.TEMPLATE_TEXT_GAME, "🎮", "Game", true), + TemplateCategoryItem(KeyboardFeatureType.TEMPLATE_TEXT_APP, "📱", "App", false), + TemplateCategoryItem(KeyboardFeatureType.TEMPLATE_TEXT_SALE, "💰", "Sale", false), + TemplateCategoryItem(KeyboardFeatureType.TEMPLATE_TEXT_GREETING, "👋", "Greeting", false), + TemplateCategoryItem(KeyboardFeatureType.TEMPLATE_TEXT_LOVE, "❤️", "Love", false) + ) + + assertEquals(5, categories.size) + assertTrue(categories.first().isSelected) + assertEquals("🎮", categories.first().icon) + assertEquals("Game", categories.first().title) + + // Test category switching simulation + val switched = categories.map { + it.copy(isSelected = it.type == KeyboardFeatureType.TEMPLATE_TEXT_SALE) + } + val selected = switched.first { it.isSelected } + assertEquals(KeyboardFeatureType.TEMPLATE_TEXT_SALE, selected.type) + assertEquals("Sale", selected.title) + } + + @Test + fun testFormKeyboardSnippetBuilder() { + val title = "Invoice #1024" + val details = "2x Mechanical Keyboard Switch Set" + val number = "IDR 350.000" + + val sb = StringBuilder() + if (title.isNotBlank()) sb.append("Subject: $title\n") + if (details.isNotBlank()) sb.append("Details: $details\n") + if (number.isNotBlank()) sb.append("Ref/No: $number\n") + + val expected = "Subject: Invoice #1024\nDetails: 2x Mechanical Keyboard Switch Set\nRef/No: IDR 350.000\n" + assertEquals(expected, sb.toString()) + } + + @Test + fun testFormKeyboardSnippetBuilderWithPartialFields() { + val title = "" + val details = "Quick reminder for the meeting at 3 PM" + val number = "" + + val sb = StringBuilder() + if (title.isNotBlank()) sb.append("Subject: $title\n") + if (details.isNotBlank()) sb.append("Details: $details\n") + if (number.isNotBlank()) sb.append("Ref/No: $number\n") + + val expected = "Details: Quick reminder for the meeting at 3 PM\n" + assertEquals(expected, sb.toString()) + } + + @Test + fun testMovieRatingFormatting() { + val vote1 = 8.423 + val vote2 = 7.0 + val vote3 = 9.99 + + assertEquals("8.4", String.format(java.util.Locale.US, "%.1f", vote1)) + assertEquals("7.0", String.format(java.util.Locale.US, "%.1f", vote2)) + assertEquals("10.0", String.format(java.util.Locale.US, "%.1f", vote3)) + } + + @Test + fun testNewsSourceFallbackLogic() { + val authorValid: String? = "BBC News" + val authorBlank: String? = " " + val authorNull: String? = null + + val source1 = if (!authorValid.isNullOrBlank()) authorValid else "News" + val source2 = if (!authorBlank.isNullOrBlank()) authorBlank else "News" + val source3 = if (!authorNull.isNullOrBlank()) authorNull else "News" + + assertEquals("BBC News", source1) + assertEquals("News", source2) + assertEquals("News", source3) + } + + @Test + fun testWebviewUrlSearchResolution() { + fun resolveUrl(query: String): String { + val trimmed = query.trim() + return when { + trimmed.startsWith("http://") || trimmed.startsWith("https://") -> trimmed + trimmed.contains(".") && !trimmed.contains(" ") -> "https://$trimmed" + trimmed.isNotEmpty() -> "https://www.google.com/search?q=" + URLEncoder.encode(trimmed, "UTF-8") + else -> "https://www.google.com" + } + } + + assertEquals("https://github.com", resolveUrl("https://github.com")) + assertEquals("http://example.com", resolveUrl("http://example.com")) + assertEquals("https://kotlinlang.org", resolveUrl("kotlinlang.org")) + assertEquals("https://www.google.com/search?q=android+keyboard+design", resolveUrl("android keyboard design")) + assertEquals("https://www.google.com", resolveUrl("")) + } + + @Test + fun testFeatureTypeFromIdMapping() { + assertEquals(KeyboardFeatureType.AUTO_TEXT, KeyboardFeatureType.from("menu_auto_text")) + assertEquals(KeyboardFeatureType.MOVIE, KeyboardFeatureType.from("menu_movie")) + assertEquals(KeyboardFeatureType.NEWS, KeyboardFeatureType.from("menu_news")) + assertEquals(KeyboardFeatureType.WEB, KeyboardFeatureType.from("menu_web")) + assertEquals(KeyboardFeatureType.FORM, KeyboardFeatureType.from("menu_form")) + assertEquals(KeyboardFeatureType.TEMPLATE_TEXT_GAME, KeyboardFeatureType.from("menu_play_store_game")) + assertEquals(KeyboardFeatureType.AUTO_TEXT, KeyboardFeatureType.from("unknown_invalid_id")) + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/KeyboardImeComposeUnitTest.kt b/app/src/test/java/com/frogobox/appkeyboard/KeyboardImeComposeUnitTest.kt new file mode 100644 index 0000000..aa4d423 --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/KeyboardImeComposeUnitTest.kt @@ -0,0 +1,181 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.model.KeyboardFeatureType +import com.frogobox.appkeyboard.suggestion.SuggestionResult +import com.frogobox.appkeyboard.ui.keyboard.root.CandidateType +import com.frogobox.appkeyboard.ui.keyboard.root.KeyboardPanelState +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 test suite validating TASK-020 Full Pure Compose architecture: + * - KeyboardPanelState state machine transitions & feature type mappings + * - CandidateType enum integrity + * - Suggestion result evaluation for AnimatedContent Top Bar transitions + */ +class KeyboardImeComposeUnitTest { + + @Test + fun testKeyboardPanelStateEnumValues() { + val expectedPanels = listOf( + KeyboardPanelState.MAIN, + KeyboardPanelState.EMOJI, + KeyboardPanelState.AUTO_TEXT, + KeyboardPanelState.TEMPLATE_TEXT_GAME, + KeyboardPanelState.TEMPLATE_TEXT_APP, + KeyboardPanelState.TEMPLATE_TEXT_SALE, + KeyboardPanelState.TEMPLATE_TEXT_LOVE, + KeyboardPanelState.TEMPLATE_TEXT_GREETING, + KeyboardPanelState.NEWS, + KeyboardPanelState.MOVIE, + KeyboardPanelState.WEBVIEW, + KeyboardPanelState.FORM + ) + + assertEquals(12, KeyboardPanelState.entries.size) + expectedPanels.forEach { panel -> + assertTrue(KeyboardPanelState.entries.contains(panel)) + } + } + + @Test + fun testKeyboardPanelStateFromFeatureMapping() { + assertEquals(KeyboardPanelState.AUTO_TEXT, KeyboardPanelState.fromFeature(KeyboardFeatureType.AUTO_TEXT)) + assertEquals(KeyboardPanelState.TEMPLATE_TEXT_GAME, KeyboardPanelState.fromFeature(KeyboardFeatureType.TEMPLATE_TEXT_GAME)) + assertEquals(KeyboardPanelState.TEMPLATE_TEXT_APP, KeyboardPanelState.fromFeature(KeyboardFeatureType.TEMPLATE_TEXT_APP)) + assertEquals(KeyboardPanelState.TEMPLATE_TEXT_SALE, KeyboardPanelState.fromFeature(KeyboardFeatureType.TEMPLATE_TEXT_SALE)) + assertEquals(KeyboardPanelState.TEMPLATE_TEXT_LOVE, KeyboardPanelState.fromFeature(KeyboardFeatureType.TEMPLATE_TEXT_LOVE)) + assertEquals(KeyboardPanelState.TEMPLATE_TEXT_GREETING, KeyboardPanelState.fromFeature(KeyboardFeatureType.TEMPLATE_TEXT_GREETING)) + assertEquals(KeyboardPanelState.NEWS, KeyboardPanelState.fromFeature(KeyboardFeatureType.NEWS)) + assertEquals(KeyboardPanelState.MOVIE, KeyboardPanelState.fromFeature(KeyboardFeatureType.MOVIE)) + assertEquals(KeyboardPanelState.WEBVIEW, KeyboardPanelState.fromFeature(KeyboardFeatureType.WEB)) + assertEquals(KeyboardPanelState.FORM, KeyboardPanelState.fromFeature(KeyboardFeatureType.FORM)) + + // Non-panel utility actions should map to null + assertNull(KeyboardPanelState.fromFeature(KeyboardFeatureType.SUGGESTION)) + assertNull(KeyboardPanelState.fromFeature(KeyboardFeatureType.CHANGE_KEYBOARD)) + assertNull(KeyboardPanelState.fromFeature(KeyboardFeatureType.SETTING)) + } + + @Test + fun testTemplateStateProperties() { + val templatePanels = listOf( + KeyboardPanelState.TEMPLATE_TEXT_GAME, + KeyboardPanelState.TEMPLATE_TEXT_APP, + KeyboardPanelState.TEMPLATE_TEXT_SALE, + KeyboardPanelState.TEMPLATE_TEXT_LOVE, + KeyboardPanelState.TEMPLATE_TEXT_GREETING + ) + + templatePanels.forEach { panel -> + assertTrue(panel.isTemplate) + assertNotNull(panel.templateFeatureType) + } + + assertFalse(KeyboardPanelState.MAIN.isTemplate) + assertFalse(KeyboardPanelState.EMOJI.isTemplate) + assertFalse(KeyboardPanelState.AUTO_TEXT.isTemplate) + assertFalse(KeyboardPanelState.NEWS.isTemplate) + assertFalse(KeyboardPanelState.MOVIE.isTemplate) + assertFalse(KeyboardPanelState.WEBVIEW.isTemplate) + assertFalse(KeyboardPanelState.FORM.isTemplate) + + assertNull(KeyboardPanelState.MAIN.templateFeatureType) + assertNull(KeyboardPanelState.EMOJI.templateFeatureType) + assertNull(KeyboardPanelState.AUTO_TEXT.templateFeatureType) + assertNull(KeyboardPanelState.NEWS.templateFeatureType) + } + + @Test + fun testCandidateTypeEnumIntegrity() { + assertEquals(3, CandidateType.entries.size) + assertEquals(CandidateType.USER_WORD, CandidateType.valueOf("USER_WORD")) + assertEquals(CandidateType.PREDICTED_WORD, CandidateType.valueOf("PREDICTED_WORD")) + assertEquals(CandidateType.AUTOCORRECT_WORD, CandidateType.valueOf("AUTOCORRECT_WORD")) + } + + @Test + fun testTopBarTransitionConditionLogic() { + // When suggestions are hidden or empty -> Header toolbar displayed + val emptyResult = SuggestionResult.EMPTY + val isSuggestionVisibleFalse = false + val condition1 = isSuggestionVisibleFalse && emptyResult.hasSuggestions() + assertFalse(condition1) + + // When suggestions are visible and candidates exist -> Candidate bar displayed + val populatedResult = SuggestionResult( + userWord = "rec", + predictedWord = "receive", + autoCorrectWord = "record" + ) + val isSuggestionVisibleTrue = true + val condition2 = isSuggestionVisibleTrue && populatedResult.hasSuggestions() + assertTrue(condition2) + + // When dismissed -> immediate return to Header + val dismissedResult = SuggestionResult.EMPTY + val condition3 = isSuggestionVisibleTrue && dismissedResult.hasSuggestions() + assertFalse(condition3) + } + + @Test + fun testWordBeforeCursorExtractionLogic() { + fun extractWord(text: String): String { + return text.takeLastWhile { it.isLetterOrDigit() || it == '\'' } + } + + assertEquals("hello", extractWord("say hello")) + assertEquals("world", extractWord("hello world")) + assertEquals("don't", extractWord("please don't")) + assertEquals("", extractWord("hello ")) + assertEquals("", extractWord("")) + assertEquals("123", extractWord("order 123")) + } + + @Test + fun testFeatureHeaderIconAndTextIntegrity() { + val features = KeyboardFeatureType.entries.map { it.mapToModel() } + features.forEach { feature -> + assertTrue("Feature text must not be empty", feature.text.isNotEmpty()) + assertTrue("Feature icon resource must be valid drawable", feature.icon != 0) + } + } + + @Test + fun testKeyboardTopBarDimensionsConsistency() { + val standardTopBarHeightDp = 56 + val featureItemMinHeightDp = 48 + val featureItemMinWidthDp = 72 + val featureIconSizeDp = 24 + + assertTrue("Feature item height must comfortably fit within Top Bar", featureItemMinHeightDp < standardTopBarHeightDp) + assertTrue("Icon size must fit within feature item", featureIconSizeDp < featureItemMinHeightDp) + assertTrue("Min width must be at least 72dp to avoid multi-line label clipping", featureItemMinWidthDp >= 72) + } + + @Test + fun testFeatureHeaderPositioningAndDistributionLogic() { + val standardScreenWidthDp = 360 + val minComfortableWidthDp = 70 + + // Case 1: 4 default items (Auto Text, Movie, Change Keyboard, Setting) + val fourItems = listOf("menu_auto_text", "menu_movie", "menu_change_keyboard", "menu_setting") + val canFitFourEvenly = fourItems.size <= 5 && (standardScreenWidthDp / fourItems.size) >= minComfortableWidthDp + assertTrue("4 items must be evenly distributed across full width", canFitFourEvenly) + val fourItemWidth = standardScreenWidthDp.toFloat() / fourItems.size + assertEquals(90.0f, fourItemWidth, 0.01f) + + // Case 2: Many items (e.g. 7 items) switch to scrollable with uniform slot width + val manyItems = KeyboardFeatureType.entries.map { it.id } + val canFitManyEvenly = manyItems.size <= 5 && (standardScreenWidthDp / manyItems.size) >= minComfortableWidthDp + assertFalse("Many items (>5) must use scrollable layout", canFitManyEvenly) + + val dynamicSlotWidth = (standardScreenWidthDp / 4.25f).coerceIn(72f, 92f) + assertTrue("Dynamic slot width must ensure at least 4 items visible with peeking 5th", dynamicSlotWidth in 72f..92f) + } + +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/MechanicalSoundAndProgrammerKeyboardTest.kt b/app/src/test/java/com/frogobox/appkeyboard/MechanicalSoundAndProgrammerKeyboardTest.kt index 03a606c..3466ebb 100644 --- a/app/src/test/java/com/frogobox/appkeyboard/MechanicalSoundAndProgrammerKeyboardTest.kt +++ b/app/src/test/java/com/frogobox/appkeyboard/MechanicalSoundAndProgrammerKeyboardTest.kt @@ -4,7 +4,6 @@ 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 diff --git a/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt b/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt index dcfbc7f..4a05d2c 100644 --- a/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt +++ b/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt @@ -5,7 +5,6 @@ import com.frogobox.appkeyboard.model.KeyboardThemeType import com.frogobox.appkeyboard.model.ThemeType import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test diff --git a/app/src/test/java/com/frogobox/appkeyboard/WordSuggestionEngineTest.kt b/app/src/test/java/com/frogobox/appkeyboard/WordSuggestionEngineTest.kt index eb9440f..2081575 100644 --- a/app/src/test/java/com/frogobox/appkeyboard/WordSuggestionEngineTest.kt +++ b/app/src/test/java/com/frogobox/appkeyboard/WordSuggestionEngineTest.kt @@ -2,7 +2,6 @@ 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 diff --git a/app/src/test/java/com/frogobox/appkeyboard/repository/DataApiRepositoryTest.kt b/app/src/test/java/com/frogobox/appkeyboard/repository/DataApiRepositoryTest.kt new file mode 100644 index 0000000..4674b49 --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/repository/DataApiRepositoryTest.kt @@ -0,0 +1,267 @@ +package com.frogobox.appkeyboard.repository + +import com.frogobox.appkeyboard.common.core.Resource +import com.frogobox.appkeyboard.data.remote.DataApiService +import com.frogobox.appkeyboard.data.remote.model.DataApiResponse +import com.frogobox.appkeyboard.data.remote.model.DataItemResponse +import com.frogobox.appkeyboard.repository.data.DataApiRepositoryImpl +import com.google.gson.Gson +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.Response +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +/** + * Modern Coroutine unit tests for DataApiRepository and remote data models. + * Strictly adheres to NO SUPPRESSION, ALWAYS MIGRATE policy using runTest and StandardTestDispatcher. + */ +class DataApiRepositoryTest { + + private class FakeDataApiService : DataApiService { + var responseToReturn: Response? = null + var exceptionToThrow: Throwable? = null + + override suspend fun getData(): Response { + exceptionToThrow?.let { throw it } + return responseToReturn + ?: Response.error(500, "Server Error".toResponseBody("text/plain".toMediaTypeOrNull())) + } + } + + private val sampleItems = listOf( + DataItemResponse( + id = 1, + rowIndex = 1, + title = "Tongsis Bluetooth", + caption = "Tongsis Bluetooth Wireless 3-in-1 Remote Shutter", + body = "Deskripsi produk tongsis tripod wireless bluetooth", + category = "Accessories", + fileSize = "3.26 MB", + isVideo = true, + uploaderName = "Celana Kulot Official", + uploadTimestamp = "2026-09-18 04:58:28 WIB", + driveLink = "https://drive.google.com/file/d/dummy123/view", + isActive = true + ), + DataItemResponse( + id = 2, + rowIndex = 2, + title = "Keyboard Mechanical RGB", + body = "Keyboard mechanical tactile switches RGB backlit", + category = "Hardware", + fileSize = "1.50 MB", + isVideo = false, + uploaderName = "Tech Store", + isActive = true + ) + ) + + private val sampleApiResponse = DataApiResponse( + code = 200, + status = "success", + message = "Data fetched successfully", + total = 2, + lastUpdated = "2026-09-19 08:00:00 WIB", + data = sampleItems + ) + + @Test + fun testFetchDataStream_success_emitsLoadingThenSuccess() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val fakeApiService = FakeDataApiService().apply { + responseToReturn = Response.success(sampleApiResponse) + } + val repository = DataApiRepositoryImpl(fakeApiService, testDispatcher) + + val emissions = repository.fetchDataStream().toList() + + assertEquals(2, emissions.size) + assertTrue(emissions[0] is Resource.Loading) + assertTrue(emissions[1] is Resource.Success) + + val successResult = emissions[1] as Resource.Success + assertEquals(200, successResult.data.code) + assertEquals("success", successResult.data.status) + assertEquals(2, successResult.data.total) + assertEquals(2, successResult.data.data?.size) + assertEquals("Tongsis Bluetooth Wireless 3-in-1 Remote Shutter", successResult.data.data?.get(0)?.caption) + } + + @Test + fun testFetchDataStream_connectException_emitsLoadingThenError() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val fakeApiService = FakeDataApiService().apply { + exceptionToThrow = ConnectException("Connection refused") + } + val repository = DataApiRepositoryImpl(fakeApiService, testDispatcher) + + val emissions = repository.fetchDataStream().toList() + + assertEquals(2, emissions.size) + assertTrue(emissions[0] is Resource.Loading) + assertTrue(emissions[1] is Resource.Error) + + val errorResult = emissions[1] as Resource.Error + assertTrue(errorResult.message.contains("Connection refused", ignoreCase = true)) + assertTrue(errorResult.cause is ConnectException) + } + + @Test + fun testFetchDataStream_socketTimeoutException_emitsLoadingThenError() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val fakeApiService = FakeDataApiService().apply { + exceptionToThrow = SocketTimeoutException("Read timed out") + } + val repository = DataApiRepositoryImpl(fakeApiService, testDispatcher) + + val emissions = repository.fetchDataStream().toList() + + assertEquals(2, emissions.size) + assertTrue(emissions[0] is Resource.Loading) + assertTrue(emissions[1] is Resource.Error) + + val errorResult = emissions[1] as Resource.Error + assertTrue(errorResult.message.contains("timed out", ignoreCase = true)) + assertTrue(errorResult.cause is SocketTimeoutException) + } + + @Test + fun testFetchDataStream_unknownHostException_emitsLoadingThenError() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val fakeApiService = FakeDataApiService().apply { + exceptionToThrow = UnknownHostException("192.168.100.6") + } + val repository = DataApiRepositoryImpl(fakeApiService, testDispatcher) + + val emissions = repository.fetchDataStream().toList() + + assertEquals(2, emissions.size) + assertTrue(emissions[0] is Resource.Loading) + assertTrue(emissions[1] is Resource.Error) + + val errorResult = emissions[1] as Resource.Error + assertTrue(errorResult.message.contains("Unable to resolve host", ignoreCase = true)) + } + + @Test + fun testFetchDataStream_http404_emitsLoadingThenError() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val fakeApiService = FakeDataApiService().apply { + responseToReturn = Response.error( + 404, + "Endpoint not found".toResponseBody("text/plain".toMediaTypeOrNull()) + ) + } + val repository = DataApiRepositoryImpl(fakeApiService, testDispatcher) + + val emissions = repository.fetchDataStream().toList() + + assertEquals(2, emissions.size) + assertTrue(emissions[0] is Resource.Loading) + assertTrue(emissions[1] is Resource.Error) + + val errorResult = emissions[1] as Resource.Error + assertEquals(404, errorResult.code) + assertTrue(errorResult.message.contains("404")) + } + + @Test + fun testFetchData_directSuspend_success() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val fakeApiService = FakeDataApiService().apply { + responseToReturn = Response.success(sampleApiResponse) + } + val repository = DataApiRepositoryImpl(fakeApiService, testDispatcher) + + val result = repository.fetchData() + + assertTrue(result is Resource.Success) + val successResult = result as Resource.Success + assertEquals(2, successResult.data.total) + } + + @Test + fun testGsonSerializationDeserialization_matchesContract() { + val gson = Gson() + val json = """ + { + "code": 200, + "status": "success", + "message": "OK", + "total": 1, + "lastUpdated": "2026-09-19 12:00:00 WIB", + "data": [ + { + "id": 10, + "rowIndex": 10, + "title": "Tripod Mini", + "caption": "Tripod Mini Flexible Stand", + "body": "Flexible spider tripod for mobile photography", + "category": "Camera", + "isVideo": true, + "fileSize": "2.40 MB", + "uploaderName": "PhotoStudio", + "uploadTimestamp": "2026-09-18 10:00:00 WIB", + "driveLink": "https://drive.google.com/file/d/test", + "thumbnailUrl": "https://example.com/thumb.jpg", + "isActive": true + } + ] + } + """.trimIndent() + + val parsed = gson.fromJson(json, DataApiResponse::class.java) + + assertNotNull(parsed) + assertEquals(200, parsed.code) + assertEquals("success", parsed.status) + assertEquals(1, parsed.total) + assertNotNull(parsed.data) + assertEquals(1, parsed.data?.size) + + val item = parsed.data?.first()!! + assertEquals(10, item.displayIndex) + assertEquals("Tripod Mini Flexible Stand", item.displayTitle) + assertEquals("PhotoStudio", item.displaySubtitle) + assertEquals("Flexible spider tripod for mobile photography", item.displayBody) + assertEquals("2026-09-18 10:00:00 WIB", item.displayTimestamp) + assertEquals(true, item.isVideo) + assertEquals("2.40 MB", item.fileSize) + assertEquals("https://drive.google.com/file/d/test", item.driveLink) + assertEquals("https://example.com/thumb.jpg", item.thumbnailUrl) + } + + @Test + fun testDataItemResponse_fallbackDisplayProperties() { + // Item with minimal fields + val minimalItem = DataItemResponse(id = 5, title = "Simple Title") + assertEquals(5, minimalItem.displayIndex) + assertEquals("Simple Title", minimalItem.displayTitle) + assertNull(minimalItem.displaySubtitle) + assertNull(minimalItem.displayBody) + assertNull(minimalItem.displayTimestamp) + + // Item with only rowIndex and uploaderName + val uploaderOnlyItem = DataItemResponse(rowIndex = 12, uploaderName = "Admin") + assertEquals(12, uploaderOnlyItem.displayIndex) + assertEquals("Admin", uploaderOnlyItem.displayTitle) + assertEquals("Admin", uploaderOnlyItem.displaySubtitle) + + // Completely empty item + val emptyItem = DataItemResponse() + assertEquals(0, emptyItem.displayIndex) + assertEquals("Item #0", emptyItem.displayTitle) + assertNull(emptyItem.displaySubtitle) + assertNull(emptyItem.displayBody) + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/ui/test/DataApiViewModelTest.kt b/app/src/test/java/com/frogobox/appkeyboard/ui/test/DataApiViewModelTest.kt new file mode 100644 index 0000000..e6b09ec --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/ui/test/DataApiViewModelTest.kt @@ -0,0 +1,209 @@ +package com.frogobox.appkeyboard.ui.test + +import com.frogobox.appkeyboard.common.callback.DataResponseCallback +import com.frogobox.appkeyboard.common.callback.StateResponseCallback +import com.frogobox.appkeyboard.common.core.Resource +import com.frogobox.appkeyboard.data.remote.model.DataApiResponse +import com.frogobox.appkeyboard.data.remote.model.DataItemResponse +import com.frogobox.appkeyboard.model.AutoTextEntity +import com.frogobox.appkeyboard.model.AutoTextLabelType +import com.frogobox.appkeyboard.repository.autotext.AutoTextRepository +import com.frogobox.appkeyboard.repository.data.DataApiRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Modern Coroutine unit tests for TestViewModel UI state transitions regarding remote data API. + * Adheres strictly to NO SUPPRESSION, ALWAYS MIGRATE policy using runTest and TestDispatchers. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DataApiViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private class FakeAutoTextRepository : AutoTextRepository { + override fun getAutoText(): Flow> = flowOf(emptyList()) + override fun getAutoTextByLabel(label: AutoTextLabelType): Flow> = flowOf(emptyList()) + override fun getAutoTextByTitle(title: String): Flow> = flowOf(emptyList()) + override fun getAutoTextByBody(body: String): Flow> = flowOf(emptyList()) + override fun getAutoTextByTitleOrBody(keyword: String): Flow> = flowOf(emptyList()) + override suspend fun insertAutoText(autoText: AutoTextEntity) {} + override suspend fun insertAutoText(autoTexts: List) {} + override suspend fun updateAutoText(autoText: AutoTextEntity) {} + override suspend fun deleteAutoText(autoText: AutoTextEntity) {} + override suspend fun deleteAutoText(idList: List) {} + override suspend fun nukeAutoText() {} + + override fun getAutoText(callback: DataResponseCallback>) {} + override fun getAutoTextByLabel(label: AutoTextLabelType, callback: DataResponseCallback>) {} + override fun getAutoTextByTitle(title: String, callback: DataResponseCallback>) {} + override fun getAutoTextByBody(body: String, callback: DataResponseCallback>) {} + override fun getAutoTextByTitleOrBody(keyword: String, callback: DataResponseCallback>) {} + override fun insertAutoText(autoText: AutoTextEntity, callback: StateResponseCallback) {} + override fun insertAutoText(autoTexts: List, callback: StateResponseCallback) {} + override fun updateAutoText(autoText: AutoTextEntity, callback: StateResponseCallback) {} + override fun deleteAutoText(autoText: AutoTextEntity, callback: StateResponseCallback) {} + override fun deleteAutoText(idList: List, callback: StateResponseCallback) {} + override fun nukeAutoText(callback: StateResponseCallback) {} + } + + private class FakeDataApiRepository : DataApiRepository { + var flowToReturn: Flow> = emptyFlow() + var directResultToReturn: Resource = Resource.Success(DataApiResponse(code = 200)) + + override fun fetchDataStream(): Flow> = flowToReturn + override suspend fun fetchData(): Resource = directResultToReturn + } + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testInitialState_isIdle() = runTest(testDispatcher) { + val fakeAutoTextRepo = FakeAutoTextRepository() + val fakeDataApiRepo = FakeDataApiRepository() + val viewModel = TestViewModel(fakeAutoTextRepo, fakeDataApiRepo) + + advanceUntilIdle() + + assertEquals(DataApiUiState.Idle, viewModel.remoteApiUiState.value) + } + + @Test + fun testFetchRemoteData_success_transitionsThroughLoadingToSuccess() = runTest(testDispatcher) { + val fakeAutoTextRepo = FakeAutoTextRepository() + val fakeDataApiRepo = FakeDataApiRepository() + + val sampleItems = listOf( + DataItemResponse(id = 1, title = "Wireless Mouse", body = "2.4GHz wireless mouse") + ) + val successResponse = DataApiResponse( + code = 200, + status = "success", + total = 1, + lastUpdated = "2026-09-19 15:00:00 WIB", + data = sampleItems + ) + + fakeDataApiRepo.flowToReturn = flow { + emit(Resource.Loading) + emit(Resource.Success(successResponse)) + } + + val viewModel = TestViewModel(fakeAutoTextRepo, fakeDataApiRepo) + val states = mutableListOf() + val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.remoteApiUiState.toList(states) + } + + viewModel.fetchRemoteData() + advanceUntilIdle() + + assertEquals(3, states.size) + assertTrue(states[0] is DataApiUiState.Idle) + assertTrue(states[1] is DataApiUiState.Loading) + assertTrue(states[2] is DataApiUiState.Success) + + val successState = states[2] as DataApiUiState.Success + assertEquals(1, successState.total) + assertEquals("2026-09-19 15:00:00 WIB", successState.lastUpdatedWib) + assertEquals(1, successState.items.size) + assertEquals("Wireless Mouse", successState.items[0].title) + + collectJob.cancel() + } + + @Test + fun testFetchRemoteData_error_transitionsThroughLoadingToError() = runTest(testDispatcher) { + val fakeAutoTextRepo = FakeAutoTextRepository() + val fakeDataApiRepo = FakeDataApiRepository() + val errorMsg = "Connection refused. Please ensure the local server is running at http://192.168.100.6:3000." + + fakeDataApiRepo.flowToReturn = flow { + emit(Resource.Loading) + emit(Resource.Error(message = errorMsg)) + } + + val viewModel = TestViewModel(fakeAutoTextRepo, fakeDataApiRepo) + val states = mutableListOf() + val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.remoteApiUiState.toList(states) + } + + viewModel.fetchRemoteData() + advanceUntilIdle() + + assertEquals(3, states.size) + assertTrue(states[0] is DataApiUiState.Idle) + assertTrue(states[1] is DataApiUiState.Loading) + assertTrue(states[2] is DataApiUiState.Error) + + val errorState = states[2] as DataApiUiState.Error + assertEquals(errorMsg, errorState.message) + + collectJob.cancel() + } + + @Test + fun testResetRemoteData_revertsStateToIdle() = runTest(testDispatcher) { + val fakeAutoTextRepo = FakeAutoTextRepository() + val fakeDataApiRepo = FakeDataApiRepository() + + fakeDataApiRepo.flowToReturn = flow { + emit(Resource.Loading) + } + + val viewModel = TestViewModel(fakeAutoTextRepo, fakeDataApiRepo) + advanceUntilIdle() + + viewModel.fetchRemoteData() + advanceUntilIdle() + assertTrue(viewModel.remoteApiUiState.value is DataApiUiState.Loading) + + viewModel.resetRemoteData() + assertEquals(DataApiUiState.Idle, viewModel.remoteApiUiState.value) + } + + @Test + fun testInsertText_handlesEmptyAndExistingContent() = runTest(testDispatcher) { + val fakeAutoTextRepo = FakeAutoTextRepository() + val fakeDataApiRepo = FakeDataApiRepository() + val viewModel = TestViewModel(fakeAutoTextRepo, fakeDataApiRepo) + advanceUntilIdle() + + assertEquals("", viewModel.sandboxText.value) + + viewModel.onInsertText("First line from API") + assertEquals("First line from API", viewModel.sandboxText.value) + + viewModel.onInsertText("Second line from API") + assertEquals("First line from API\nSecond line from API", viewModel.sandboxText.value) + + viewModel.onClearText() + assertEquals("", viewModel.sandboxText.value) + } +} diff --git a/buildSrc/src/main/kotlin/DependencyGradle.kt b/buildSrc/src/main/kotlin/DependencyGradle.kt index e0a6aad..efcddcc 100644 --- a/buildSrc/src/main/kotlin/DependencyGradle.kt +++ b/buildSrc/src/main/kotlin/DependencyGradle.kt @@ -1,5 +1,5 @@ object DependencyGradle { - const val MODULE_LIB_FROGO_KEYBOARD = ":frogo-keyboard" + const val MODULE_LIB_KEYBOARD = ":lib-keyboard" } \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/ProjectSetting.kt b/buildSrc/src/main/kotlin/ProjectSetting.kt index 5e783c2..981f3b7 100644 --- a/buildSrc/src/main/kotlin/ProjectSetting.kt +++ b/buildSrc/src/main/kotlin/ProjectSetting.kt @@ -36,14 +36,14 @@ object ProjectSetting { const val BASE_PACKAGE_NAME = "com.frogobox" const val MODULE_APP = "appkeyboard" - const val MODULE_LIB_FROGO_KEYBOARD = "libkeyboard" + const val MODULE_LIB_KEYBOARD = "libkeyboard" val APP_NAME = NAME_APP.lowercase().replace(" ", "") val PROJECT_APP_ID = "$APP_DOMAIN.$APP_PLAY_CONSOLE.$APP_NAME" const val PROJECT_NAME_SPACE_APP = "$BASE_PACKAGE_NAME.$MODULE_APP" - const val PROJECT_NAME_SPACE_LIB_FROGO_KEYBOARD = "$BASE_PACKAGE_NAME.$MODULE_LIB_FROGO_KEYBOARD" + const val PROJECT_NAME_SPACE_LIB_FROGO_KEYBOARD = "$BASE_PACKAGE_NAME.$MODULE_LIB_KEYBOARD" const val PROJECT_VERSION_CODE = (VERSION_MAJOR * 100) + (VERSION_MINOR * 10) + (VERSION_PATCH * 1) const val PROJECT_VERSION_NAME = "$VERSION_MAJOR.$VERSION_MINOR.$VERSION_PATCH" 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 deleted file mode 100644 index ed19597..0000000 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboard.kt +++ /dev/null @@ -1,216 +0,0 @@ -package com.frogobox.libkeyboard.ui.emoji - -import android.content.Context -import android.graphics.Paint -import android.graphics.Typeface -import android.os.Handler -import android.os.Looper -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.LinearLayout -import androidx.emoji2.text.EmojiCompat -import com.frogobox.libkeyboard.R -import com.frogobox.libkeyboard.common.core.BaseKeyboard -import com.frogobox.libkeyboard.databinding.ItemKeyboardEmojiBinding -import com.frogobox.libkeyboard.databinding.KeyboardEmojiBinding -import com.frogobox.libkeyboard.ui.main.OnKeyboardActionListener -import com.frogobox.recycler.core.FrogoRecyclerNotifyListener -import com.frogobox.recycler.core.IFrogoBindingAdapter -import com.frogobox.recycler.ext.injectorBinding - -/** - * Created by Faisal Amir on 11/12/22 - * ----------------------------------------- - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - * Copyright (C) Frogobox ID / amirisback - * All rights reserved - */ - -class EmojiKeyboard( - context: Context, - attrs: AttributeSet?, -) : BaseKeyboard(context, attrs) { - - 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 { - return KeyboardEmojiBinding.inflate(LayoutInflater.from(context), this, true) - } - - override fun onCreate() { - } - - fun openEmojiPalette() { - setupEmojis(EmojiCategoryType.GENERAL.path) - setupEmojiCategory() - } - - /** Clears the emoji cache. Call this if emoji assets are updated. */ - fun clearEmojiCache() { - emojiCache.clear() - } - - private fun setupEmojiCategory() { - val adapterCallback = - object : IFrogoBindingAdapter { - - override fun onItemClicked( - binding: ItemKeyboardEmojiBinding, - data: EmojiCategory, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) { - setupEmojis(data.path) - } - - override fun areContentsTheSame( - oldItem: EmojiCategory, - newItem: EmojiCategory - ): Boolean { - return oldItem == newItem - } - - override fun areItemsTheSame( - oldItem: EmojiCategory, - newItem: EmojiCategory - ): Boolean { - return oldItem.name == newItem.name - } - - override fun setViewBinding(parent: ViewGroup): ItemKeyboardEmojiBinding { - return ItemKeyboardEmojiBinding.inflate( - LayoutInflater.from(context), - parent, - false - ) - } - - override fun setupInitComponent( - binding: ItemKeyboardEmojiBinding, - data: EmojiCategory, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) { - val processed = EmojiCompat.get().process(data.icon) - binding.tvEmoji.text = processed - } - } - - binding.apply { - emojiCategoryList.injectorBinding() - .addData(getEmojiCategory()) - .addCallback(adapterCallback) - .createLayoutLinearHorizontal(false) - .build() - } - } - - 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 emojis = fullEmojiList.filter { emoji -> - systemFontPaint.hasGlyph(emoji) || (EmojiCompat.get().loadState == EmojiCompat.LOAD_STATE_SUCCEEDED - && EmojiCompat.get().getEmojiMatch( - emoji, - emojiCompatMetadataVersion - ) == EmojiCompat.EMOJI_SUPPORTED) - } - - // Store in cache - emojiCache[path] = emojis - - Handler(Looper.getMainLooper()).post { - setupEmojiAdapter(emojis) - } - } - } - - private fun setupEmojiAdapter(emojis: List) { - - val adapterCallback = object : IFrogoBindingAdapter { - override fun onItemClicked( - binding: ItemKeyboardEmojiBinding, - data: String, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) { - mOnKeyboardActionListener?.onText(data) - } - - override fun onItemLongClicked( - binding: ItemKeyboardEmojiBinding, - data: String, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) { - mOnKeyboardActionListener?.onText(data) - } - - override fun areContentsTheSame(oldItem: String, newItem: String): Boolean { - return oldItem == newItem - } - - override fun areItemsTheSame(oldItem: String, newItem: String): Boolean { - return oldItem == newItem - } - - override fun setViewBinding(parent: ViewGroup): ItemKeyboardEmojiBinding { - return ItemKeyboardEmojiBinding.inflate(LayoutInflater.from(context), parent, false) - } - - override fun setupInitComponent( - binding: ItemKeyboardEmojiBinding, - data: String, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) { - val processed = EmojiCompat.get().process(data) - binding.tvEmoji.text = processed - } - } - - val emojiItemWidth = context.resources.getDimensionPixelSize(R.dimen.emoji_item_size) - val mLayoutManager = AutoGridLayoutManager(context, emojiItemWidth) - - binding.apply { - emojiList.injectorBinding() - .addCallback(adapterCallback) - .addData(emojis) - .build() - - emojiList.layoutManager = mLayoutManager - } - } - - private fun ensureBackgroundThread(callback: () -> Unit) { - if (Looper.myLooper() == Looper.getMainLooper()) { - Thread { - callback() - }.start() - } else { - callback() - } - } - -} \ No newline at end of file diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/RecyclerScrollCallback.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/RecyclerScrollCallback.kt deleted file mode 100644 index fba5fec..0000000 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/RecyclerScrollCallback.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.frogobox.libkeyboard.ui.emoji - -interface RecyclerScrollCallback { - fun onScrolled(scrollY: Int) -} \ No newline at end of file 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 deleted file mode 100644 index 50613c2..0000000 --- a/frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_down.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - 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 deleted file mode 100644 index bd1add9..0000000 --- a/frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_up.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/frogo-keyboard/src/main/res/drawable/keypad_action.xml b/frogo-keyboard/src/main/res/drawable/keypad_action.xml deleted file mode 100644 index 4824432..0000000 --- a/frogo-keyboard/src/main/res/drawable/keypad_action.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - diff --git a/frogo-keyboard/src/main/res/drawable/keypad_emoji.xml b/frogo-keyboard/src/main/res/drawable/keypad_emoji.xml deleted file mode 100644 index 4264e74..0000000 --- a/frogo-keyboard/src/main/res/drawable/keypad_emoji.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - diff --git a/frogo-keyboard/src/main/res/layout/item_keyboard_emoji.xml b/frogo-keyboard/src/main/res/layout/item_keyboard_emoji.xml deleted file mode 100644 index 1803ba8..0000000 --- a/frogo-keyboard/src/main/res/layout/item_keyboard_emoji.xml +++ /dev/null @@ -1,11 +0,0 @@ - - diff --git a/frogo-keyboard/src/main/res/layout/item_keyboard_main.xml b/frogo-keyboard/src/main/res/layout/item_keyboard_main.xml deleted file mode 100644 index bb16d0c..0000000 --- a/frogo-keyboard/src/main/res/layout/item_keyboard_main.xml +++ /dev/null @@ -1,8 +0,0 @@ - diff --git a/frogo-keyboard/src/main/res/layout/keyboard_emoji.xml b/frogo-keyboard/src/main/res/layout/keyboard_emoji.xml deleted file mode 100644 index 7d6ddae..0000000 --- a/frogo-keyboard/src/main/res/layout/keyboard_emoji.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/frogo-keyboard/src/main/res/layout/keyboard_main_mini.xml b/frogo-keyboard/src/main/res/layout/keyboard_main_mini.xml deleted file mode 100644 index 001639b..0000000 --- a/frogo-keyboard/src/main/res/layout/keyboard_main_mini.xml +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/frogo-keyboard/src/main/res/values/dimens.xml b/frogo-keyboard/src/main/res/values/dimens.xml deleted file mode 100644 index 4974eff..0000000 --- a/frogo-keyboard/src/main/res/values/dimens.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - 52dp - 3dp - 8dp - 16dp - 12dp - 16dp - - 60dp - -10dp - - - 46dp - 32dp - 46dp - 42dp - 56dp - 12dp - - - 22sp - 26sp - 16sp - 28sp - - diff --git a/frogo-keyboard/src/main/res/xml/keys_letters_english_qwerty.xml b/frogo-keyboard/src/main/res/xml/keys_letters_english_qwerty.xml deleted file mode 100644 index 9fc0225..0000000 --- a/frogo-keyboard/src/main/res/xml/keys_letters_english_qwerty.xml +++ /dev/null @@ -1,178 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/gradle.properties b/gradle.properties index 5655886..f9138ec 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,11 +6,12 @@ # 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=-Xmx1280m -XX:+UseParallelGC -XX:MaxMetaspaceSize=320m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4096m -XX:+UseParallelGC -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 # Gradle Performance Flags org.gradle.daemon=true -org.gradle.parallel=false -org.gradle.workers.max=1 +org.gradle.daemon.idletimeout=180000 +org.gradle.parallel=true +org.gradle.workers.max=4 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 @@ -21,8 +22,8 @@ android.enableJetifier=false # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official android.nonTransitiveRClass=true -ksp.incremental=false -kotlin.incremental=false +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 1369c39..4dad54f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,6 @@ liveCycle = "2.11.0" roomKtx = "2.8.5" work = "2.11.2" -glideCompiler = "5.0.9" hilt = "2.60.1" emoji2 = "1.6.0" @@ -21,6 +20,7 @@ junit = "4.13.2" 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-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", 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" } @@ -40,7 +40,6 @@ google-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref google-hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } # -------------------------------------------------------------------------------------------------- -github-glide-compiler = { group = "com.github.bumptech.glide", name = "compiler", version.ref = "glideCompiler" } frogo-android = { group = "com.github.frogobox", name = "frogo-sdk", version.ref = "frogoAndroid" } frogo-consume-api = { group = "com.github.frogobox", name = "frogo-consume-api", version.ref = "frogoconsumeapi" } # -------------------------------------------------------------------------------------------------- diff --git a/frogo-keyboard/.gitignore b/lib-keyboard/.gitignore similarity index 100% rename from frogo-keyboard/.gitignore rename to lib-keyboard/.gitignore diff --git a/frogo-keyboard/build.gradle.kts b/lib-keyboard/build.gradle.kts similarity index 66% rename from frogo-keyboard/build.gradle.kts rename to lib-keyboard/build.gradle.kts index c8439dd..4425189 100644 --- a/frogo-keyboard/build.gradle.kts +++ b/lib-keyboard/build.gradle.kts @@ -2,6 +2,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.compose) `maven-publish` } @@ -32,6 +33,7 @@ android { buildFeatures { viewBinding = true buildConfig = true + compose = true } compileOptions { @@ -39,6 +41,12 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + publishing { singleVariant("release") { withSourcesJar() @@ -57,10 +65,30 @@ kotlin { dependencies { + val composeBom = platform(libs.androidx.compose.bom) + api(composeBom) + + api(libs.androidx.compose.ui) + api(libs.androidx.compose.ui.graphics) + api(libs.androidx.compose.ui.tooling.preview) + api(libs.androidx.compose.material3) + api(libs.androidx.compose.material.icons.extended) + + api(libs.androidx.activity.compose) + api(libs.androidx.lifecycle.runtime.ktx) + api(libs.androidx.lifecycle.viewmodel.compose) + api(libs.frogo.android) api(libs.frogo.consume.api) api(libs.androidx.emoji2) + androidTestImplementation(composeBom) + testImplementation(libs.junit) + testImplementation("org.robolectric:robolectric:4.16.1") + testImplementation("androidx.test:core:1.7.0") + debugImplementation(libs.androidx.compose.ui.tooling) + + } afterEvaluate { diff --git a/frogo-keyboard/consumer-rules.pro b/lib-keyboard/consumer-rules.pro similarity index 100% rename from frogo-keyboard/consumer-rules.pro rename to lib-keyboard/consumer-rules.pro diff --git a/frogo-keyboard/proguard-rules.pro b/lib-keyboard/proguard-rules.pro similarity index 100% rename from frogo-keyboard/proguard-rules.pro rename to lib-keyboard/proguard-rules.pro diff --git a/frogo-keyboard/src/main/AndroidManifest.xml b/lib-keyboard/src/main/AndroidManifest.xml similarity index 100% rename from frogo-keyboard/src/main/AndroidManifest.xml rename to lib-keyboard/src/main/AndroidManifest.xml diff --git a/frogo-keyboard/src/main/assets/media/_emoji_general.txt b/lib-keyboard/src/main/assets/media/_emoji_general.txt similarity index 100% rename from frogo-keyboard/src/main/assets/media/_emoji_general.txt rename to lib-keyboard/src/main/assets/media/_emoji_general.txt diff --git a/frogo-keyboard/src/main/assets/media/emoji_activities.txt b/lib-keyboard/src/main/assets/media/emoji_activities.txt similarity index 100% rename from frogo-keyboard/src/main/assets/media/emoji_activities.txt rename to lib-keyboard/src/main/assets/media/emoji_activities.txt diff --git a/frogo-keyboard/src/main/assets/media/emoji_animal_nature.txt b/lib-keyboard/src/main/assets/media/emoji_animal_nature.txt similarity index 100% rename from frogo-keyboard/src/main/assets/media/emoji_animal_nature.txt rename to lib-keyboard/src/main/assets/media/emoji_animal_nature.txt diff --git a/frogo-keyboard/src/main/assets/media/emoji_flag.txt b/lib-keyboard/src/main/assets/media/emoji_flag.txt similarity index 100% rename from frogo-keyboard/src/main/assets/media/emoji_flag.txt rename to lib-keyboard/src/main/assets/media/emoji_flag.txt diff --git a/frogo-keyboard/src/main/assets/media/emoji_food_drink.txt b/lib-keyboard/src/main/assets/media/emoji_food_drink.txt similarity index 100% rename from frogo-keyboard/src/main/assets/media/emoji_food_drink.txt rename to lib-keyboard/src/main/assets/media/emoji_food_drink.txt diff --git a/frogo-keyboard/src/main/assets/media/emoji_objects.txt b/lib-keyboard/src/main/assets/media/emoji_objects.txt similarity index 100% rename from frogo-keyboard/src/main/assets/media/emoji_objects.txt rename to lib-keyboard/src/main/assets/media/emoji_objects.txt diff --git a/frogo-keyboard/src/main/assets/media/emoji_people_body.txt b/lib-keyboard/src/main/assets/media/emoji_people_body.txt similarity index 100% rename from frogo-keyboard/src/main/assets/media/emoji_people_body.txt rename to lib-keyboard/src/main/assets/media/emoji_people_body.txt diff --git a/frogo-keyboard/src/main/assets/media/emoji_smileys_emotion.txt b/lib-keyboard/src/main/assets/media/emoji_smileys_emotion.txt similarity index 100% rename from frogo-keyboard/src/main/assets/media/emoji_smileys_emotion.txt rename to lib-keyboard/src/main/assets/media/emoji_smileys_emotion.txt diff --git a/frogo-keyboard/src/main/assets/media/emoji_symbols.txt b/lib-keyboard/src/main/assets/media/emoji_symbols.txt similarity index 100% rename from frogo-keyboard/src/main/assets/media/emoji_symbols.txt rename to lib-keyboard/src/main/assets/media/emoji_symbols.txt diff --git a/frogo-keyboard/src/main/assets/media/emoji_travel_places.txt b/lib-keyboard/src/main/assets/media/emoji_travel_places.txt similarity index 100% rename from frogo-keyboard/src/main/assets/media/emoji_travel_places.txt rename to lib-keyboard/src/main/assets/media/emoji_travel_places.txt diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboard.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboard.kt similarity index 88% rename from frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboard.kt rename to lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboard.kt index 0074930..026622d 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboard.kt +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboard.kt @@ -36,7 +36,12 @@ abstract class BaseKeyboard @JvmOverloads constructor( // Backing property for view binding private var _binding: VB? = null val binding: VB - get() = _binding ?: throw IllegalStateException("Binding is accessed before setup or after onDestroy()") + get() { + if (_binding == null) { + _binding = setupViewBinding(LayoutInflater.from(context), this) + } + return _binding!! + } // Current input connection to communicate with the EditText / InputMethod var currentInputConnection: InputConnection? = null @@ -72,8 +77,10 @@ abstract class BaseKeyboard @JvmOverloads constructor( } private fun initialize() { - _binding = setupViewBinding(LayoutInflater.from(context), this) - onCreate() + if (_binding == null) { + _binding = setupViewBinding(LayoutInflater.from(context), this) + onCreate() + } } /** @@ -87,7 +94,6 @@ abstract class BaseKeyboard @JvmOverloads constructor( override fun onDetachedFromWindow() { super.onDetachedFromWindow() onDestroy() - _binding = null currentInputConnection = null } } diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt similarity index 72% rename from frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt rename to lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt index 16aea89..a1e9c8c 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt @@ -25,11 +25,12 @@ import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.SHIFT_ON_PERM 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 { +abstract class BaseKeyboardIME : InputMethodService(), OnKeyboardActionListener, IKeyboardIME { companion object { // How quickly do we have to doubletap shift to enable permanent caps lock const val SHIFT_PERM_TOGGLE_SPEED = 500L + const val DOUBLE_SPACE_PERIOD_TIMEOUT = 350L const val KEYBOARD_LETTERS = 0 const val KEYBOARD_SYMBOLS = 1 @@ -40,14 +41,15 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo var keyboard: ItemMainKeyboard? = null var lastShiftPressTS = 0L + var lastSpacePressTS = 0L var keyboardMode = KEYBOARD_LETTERS var inputTypeClass = InputType.TYPE_CLASS_TEXT var enterKeyType = IME_ACTION_NONE var switchToLetters = false - var binding: VB? = null - - abstract fun setupViewBinding() : VB + open fun setupInputView(): View { + return View(this) + } override fun onCreate() { setTheme(R.style.Theme_Research) @@ -71,11 +73,11 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo } override fun onCreateInputView(): View { - binding = setupViewBinding() + val view = setupInputView() setupBinding() initCurrentInputConnection() initView() - return binding!!.root + return view } override fun onPress(primaryCode: Int) { @@ -211,6 +213,7 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo when (code) { ItemMainKeyboard.KEYCODE_DELETE -> { + lastSpacePressTS = 0L if (kb.mShiftState == SHIFT_ON_ONE_CHAR) { kb.mShiftState = SHIFT_OFF } @@ -227,13 +230,10 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo inputConnection.commitText("", 1) } - if (inputConnection != currentInputConnection) { - inputConnection.deleteSurroundingText(1, 0) - } - invalidateAllKeys() } ItemMainKeyboard.KEYCODE_SHIFT -> { + lastSpacePressTS = 0L if (keyboardMode == KEYBOARD_LETTERS) { when { kb.mShiftState == SHIFT_ON_PERMANENT -> kb.mShiftState = SHIFT_OFF @@ -257,6 +257,7 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo invalidateAllKeys() } ItemMainKeyboard.KEYCODE_ENTER -> { + lastSpacePressTS = 0L val imeOptionsActionId = getImeOptionsActionId() if (imeOptionsActionId != IME_ACTION_NONE) { inputConnection.performEditorAction(imeOptionsActionId) @@ -268,12 +269,9 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER) ) } - - if (inputConnection != currentInputConnection) { - inputConnection.commitText("\n", 1) - } } ItemMainKeyboard.KEYCODE_MODE_CHANGE -> { + lastSpacePressTS = 0L val keyboardXml = if (keyboardMode == KEYBOARD_LETTERS) { keyboardMode = KEYBOARD_SYMBOLS R.xml.keys_symbols @@ -285,9 +283,11 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo initialSetupKeyboard() } ItemMainKeyboard.KEYCODE_EMOJI -> { + lastSpacePressTS = 0L runEmojiBoard() } ItemMainKeyboard.KEYCODE_TAB -> { + lastSpacePressTS = 0L val isMultiline = (currentInputEditorInfo?.inputType ?: 0) and InputType.TYPE_TEXT_FLAG_MULTI_LINE != 0 if (isMultiline) { inputConnection.commitText("\t", 1) @@ -301,12 +301,15 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo } } ItemMainKeyboard.KEYCODE_ARROW_LEFT -> { + lastSpacePressTS = 0L moveCursor(false) } ItemMainKeyboard.KEYCODE_ARROW_RIGHT -> { + lastSpacePressTS = 0L moveCursor(true) } ItemMainKeyboard.KEYCODE_ARROW_UP -> { + lastSpacePressTS = 0L inputConnection.sendKeyEvent( KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP) ) @@ -315,6 +318,7 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo ) } ItemMainKeyboard.KEYCODE_ARROW_DOWN -> { + lastSpacePressTS = 0L inputConnection.sendKeyEvent( KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN) ) @@ -329,16 +333,35 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo codeChar = Character.toUpperCase(codeChar) } - // If the keyboard is set to symbols and the user presses space, we usually should switch back to the letters keyboard. - // However, avoid doing that in cases when the EditText for example requires numbers as the input. - // We can detect that by the text not changing on pressing Space. - if (keyboardMode != KEYBOARD_LETTERS && code == ItemMainKeyboard.KEYCODE_SPACE) { - val originalText = - inputConnection.getExtractedText(ExtractedTextRequest(), 0)?.text ?: return - inputConnection.commitText(codeChar.toString(), 1) - val newText = inputConnection.getExtractedText(ExtractedTextRequest(), 0)?.text - switchToLetters = newText != null && originalText != newText + if (code == ItemMainKeyboard.KEYCODE_SPACE) { + val now = System.currentTimeMillis() + // Gboard behavior: Double tap space -> insert ". " and auto-shift + if (keyboardMode == KEYBOARD_LETTERS && (now - lastSpacePressTS) < DOUBLE_SPACE_PERIOD_TIMEOUT) { + val textBefore = inputConnection.getTextBeforeCursor(2, 0) + if (textBefore != null && textBefore.length >= 1 && textBefore.endsWith(" ") && !textBefore.endsWith(". ")) { + inputConnection.deleteSurroundingText(1, 0) + inputConnection.commitText(". ", 1) + lastSpacePressTS = 0L + if (kb.mShiftState == SHIFT_OFF) { + kb.mShiftState = SHIFT_ON_ONE_CHAR + invalidateAllKeys() + } + updateShiftKeyState() + return + } + } + lastSpacePressTS = now + + inputConnection.commitText(" ", 1) + + if (keyboardMode != KEYBOARD_LETTERS && + inputTypeClass != TYPE_CLASS_NUMBER && + inputTypeClass != TYPE_CLASS_PHONE && + inputTypeClass != TYPE_CLASS_DATETIME) { + switchToLetters = true + } } else { + lastSpacePressTS = 0L inputConnection.commitText(codeChar.toString(), 1) } @@ -355,24 +378,63 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo } override fun moveCursor(moveRight: Boolean) { - val extractedText = - currentInputConnection?.getExtractedText(ExtractedTextRequest(), 0) ?: return - var newCursorPosition = extractedText.selectionStart - val textLength = extractedText.text?.length ?: return - newCursorPosition = if (moveRight) { - (newCursorPosition + 1).coerceAtMost(textLength) + val ic = currentInputConnection ?: return + val extractedText = ic.getExtractedText(ExtractedTextRequest(), 0) + if (extractedText != null && extractedText.text != null) { + var newCursorPosition = extractedText.selectionStart + val textLength = extractedText.text.length + newCursorPosition = if (moveRight) { + (newCursorPosition + 1).coerceAtMost(textLength) + } else { + (newCursorPosition - 1).coerceAtLeast(0) + } + ic.setSelection(newCursorPosition, newCursorPosition) } else { - (newCursorPosition - 1).coerceAtLeast(0) + // Fallback for custom editors or WebViews that don't support ExtractedText + val keyCode = if (moveRight) KeyEvent.KEYCODE_DPAD_RIGHT else KeyEvent.KEYCODE_DPAD_LEFT + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode)) } + } - currentInputConnection?.setSelection(newCursorPosition, newCursorPosition) + override fun deleteWordsBeforeCursor(count: Int) { + val ic = currentInputConnection ?: return + if (count <= 0) return + val textBefore = ic.getTextBeforeCursor(120, 0)?.toString() ?: return + if (textBefore.isEmpty()) return + + var remainingWords = count + var deleteLen = 0 + var inWord = false + + for (i in textBefore.length - 1 downTo 0) { + val ch = textBefore[i] + if (ch.isWhitespace() || !ch.isLetterOrDigit()) { + if (inWord) { + remainingWords-- + if (remainingWords <= 0) { + deleteLen++ + break + } + inWord = false + } + } else { + inWord = true + } + deleteLen++ + } + + if (deleteLen > 0) { + ic.deleteSurroundingText(deleteLen, 0) + } } override fun getImeOptionsActionId(): Int { - return if (currentInputEditorInfo.imeOptions and IME_FLAG_NO_ENTER_ACTION != 0) { + val editorInfo = currentInputEditorInfo ?: return IME_ACTION_NONE + return if (editorInfo.imeOptions and IME_FLAG_NO_ENTER_ACTION != 0) { IME_ACTION_NONE } else { - currentInputEditorInfo.imeOptions and IME_MASK_ACTION + editorInfo.imeOptions and IME_MASK_ACTION } } @@ -380,4 +442,21 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo return R.xml.keys_letters_qwerty } +} + +/** + * Legacy ViewBinding adapter base class for consumers that require [ViewBinding]. + */ +abstract class BaseViewBindingKeyboardIME : BaseKeyboardIME() { + + var binding: VB? = null + + abstract fun setupViewBinding(): VB + + override fun setupInputView(): View { + val vb = setupViewBinding() + binding = vb + return vb.root + } + } \ No newline at end of file diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/IKeyboardIME.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/IKeyboardIME.kt similarity index 96% rename from frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/IKeyboardIME.kt rename to lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/IKeyboardIME.kt index 46f1951..67baac1 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/IKeyboardIME.kt +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/IKeyboardIME.kt @@ -54,6 +54,8 @@ interface IKeyboardIME { fun moveCursor(moveRight: Boolean) + fun deleteWordsBeforeCursor(count: Int) + fun getImeOptionsActionId(): Int fun getKeyboardLayoutXML(): Int diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt similarity index 100% rename from frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt rename to lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/IntExt.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/IntExt.kt similarity index 100% rename from frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/IntExt.kt rename to lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/IntExt.kt diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/sound/MechanicalSoundManager.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/sound/MechanicalSoundManager.kt similarity index 100% rename from frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/sound/MechanicalSoundManager.kt rename to lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/sound/MechanicalSoundManager.kt diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/AutoGridLayoutManager.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/AutoGridLayoutManager.kt similarity index 86% rename from frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/AutoGridLayoutManager.kt rename to lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/AutoGridLayoutManager.kt index 0cfbbe9..19d3281 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/AutoGridLayoutManager.kt +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/AutoGridLayoutManager.kt @@ -31,7 +31,10 @@ class AutoGridLayoutManager( } else { height - paddingTop - paddingBottom } - spanCount = max(1, totalSpace / itemWidth) + val calculatedSpan = max(1, totalSpace / itemWidth) + if (spanCount != calculatedSpan) { + spanCount = calculatedSpan + } } super.onLayoutChildren(recycler, state) } diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiCategory.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiCategory.kt similarity index 100% rename from frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiCategory.kt rename to lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiCategory.kt diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiCategoryType.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiCategoryType.kt similarity index 100% rename from frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiCategoryType.kt rename to lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiCategoryType.kt diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiHelper.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiHelper.kt similarity index 100% rename from frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiHelper.kt rename to lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiHelper.kt diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboard.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboard.kt new file mode 100644 index 0000000..7de1814 --- /dev/null +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboard.kt @@ -0,0 +1,181 @@ +package com.frogobox.libkeyboard.ui.emoji + +import android.content.Context +import android.graphics.Paint +import android.graphics.Typeface +import android.util.AttributeSet +import android.view.inputmethod.InputConnection +import android.widget.FrameLayout +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.emoji2.text.EmojiCompat +import com.frogobox.libkeyboard.ui.main.OnKeyboardActionListener +import com.frogobox.libkeyboard.ui.theme.FrogoLibKeyboardTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Modern Jetpack Compose-based Emoji Keyboard panel. + * + * Hosts [EmojiKeyboardScreen] within a ComposeView, supporting + * responsive emoji grids, category tabs, and input connection commits. + */ +class EmojiKeyboard @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private val job = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.Main + job) + + private val emojiCache = mutableMapOf>() + private val systemFontPaint = Paint().apply { + typeface = Typeface.DEFAULT + } + private var emojiCompatMetadataVersion = 0 + + private val emojisState = MutableStateFlow>(emptyList()) + private val selectedCategoryState = MutableStateFlow(EmojiCategoryType.GENERAL) + private val isLoadingState = MutableStateFlow(false) + private var resetScrollTrigger by mutableIntStateOf(0) + + var currentInputConnection: InputConnection? = null + var mOnKeyboardActionListener: OnKeyboardActionListener? = null + var onBackClick: (() -> Unit)? = null + + private val composeView = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool) + setContent { + FrogoLibKeyboardTheme { + val emojis by emojisState.collectAsState() + val selectedCategory by selectedCategoryState.collectAsState() + val isLoading by isLoadingState.collectAsState() + val gridState = rememberLazyGridState() + + LaunchedEffect(resetScrollTrigger) { + if (resetScrollTrigger > 0) { + gridState.scrollToItem(0) + } + } + + EmojiKeyboardScreen( + emojis = emojis, + selectedCategory = selectedCategory, + categories = getEmojiCategory(), + isLoading = isLoading, + lazyGridState = gridState, + onCategorySelected = { category -> + selectedCategoryState.value = category + loadEmojis(category.path) + resetScroll() + }, + onEmojiClicked = { emoji -> + currentInputConnection?.commitText(emoji, 1) + mOnKeyboardActionListener?.onText(emoji) + mOnKeyboardActionListener?.onActionUp() + }, + onBackClicked = { + onBackClick?.invoke() + } + ) + } + } + } + + init { + addView(composeView) + openEmojiPalette() + } + + /** + * Assigns the active [InputConnection] for committing typed emojis. + */ + fun setInputConnection(inputConnection: InputConnection?) { + currentInputConnection = inputConnection + } + + /** + * Registers a listener for back navigation clicks from the emoji header. + */ + fun setOnBackClickListener(listener: () -> Unit) { + onBackClick = listener + } + + /** + * Opens the emoji palette at the default category. + */ + fun openEmojiPalette() { + selectedCategoryState.value = EmojiCategoryType.GENERAL + loadEmojis(EmojiCategoryType.GENERAL.path) + } + + /** + * Clears cached emoji lists from memory. + */ + fun clearEmojiCache() { + emojiCache.clear() + } + + /** + * Smoothly or instantly scrolls the emoji grid back to item 0. + */ + fun resetScroll() { + resetScrollTrigger++ + } + + private fun loadEmojis(path: String) { + val cached = emojiCache[path] + if (cached != null) { + emojisState.value = cached + return + } + + isLoadingState.value = true + scope.launch(Dispatchers.IO) { + val fullEmojiList = parseRawEmojiSpecsFile(context, path) + + val isEmojiCompatReady = EmojiCompat.isConfigured() && + EmojiCompat.get().loadState == EmojiCompat.LOAD_STATE_SUCCEEDED + + val emojis = fullEmojiList.filter { emoji -> + systemFontPaint.hasGlyph(emoji) || (isEmojiCompatReady && + EmojiCompat.get().getEmojiMatch( + emoji, + emojiCompatMetadataVersion + ) == EmojiCompat.EMOJI_SUPPORTED) + } + + emojiCache[path] = emojis + + withContext(Dispatchers.Main) { + emojisState.value = emojis + isLoadingState.value = false + } + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + job.cancelChildren() + currentInputConnection = null + } + + fun onDestroy() { + job.cancelChildren() + currentInputConnection = null + clearEmojiCache() + } + +} \ No newline at end of file diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboardScreen.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboardScreen.kt new file mode 100644 index 0000000..2448c8f --- /dev/null +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboardScreen.kt @@ -0,0 +1,310 @@ +package com.frogobox.libkeyboard.ui.emoji + +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.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.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.emoji2.text.EmojiCompat +import com.frogobox.libkeyboard.R +import com.frogobox.libkeyboard.ui.theme.FrogoLibKeyboardTheme + +val EmojiCategoryType.displayName: String + get() = when (this) { + EmojiCategoryType.GENERAL -> "General" + EmojiCategoryType.ACTIVITIES -> "Activities" + EmojiCategoryType.ANIMAL_NATURE -> "Animals & Nature" + EmojiCategoryType.FLAG -> "Flags" + EmojiCategoryType.FOOD_DRINK -> "Food & Drink" + EmojiCategoryType.OBJECTS -> "Objects" + EmojiCategoryType.PEOPLE_BODY -> "People & Body" + EmojiCategoryType.SMILEYS_EMOTION -> "Smileys & Emotion" + EmojiCategoryType.SYMBOLS -> "Symbols" + EmojiCategoryType.TRAVEL_PLACES -> "Travel & Places" + } + +/** + * Modern Jetpack Compose Screen for browsing and selecting emojis. + */ +@Composable +fun EmojiKeyboardScreen( + emojis: List, + selectedCategory: EmojiCategoryType, + categories: List, + onCategorySelected: (EmojiCategoryType) -> Unit, + onEmojiClicked: (String) -> Unit, + onBackClicked: () -> Unit, + modifier: Modifier = Modifier, + lazyGridState: LazyGridState = rememberLazyGridState(), + isLoading: Boolean = false +) { + Surface( + modifier = modifier + .fillMaxWidth() + .height(260.dp), + color = MaterialTheme.colorScheme.surface + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Header Toolbar + EmojiHeaderToolbar( + selectedCategory = selectedCategory, + onBackClicked = onBackClicked + ) + + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + + // Emoji Grid or Loading State + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(36.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp + ) + } else if (emojis.isEmpty()) { + Text( + text = "No emojis found", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 42.dp), + state = lazyGridState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + items(emojis, key = { it }) { emoji -> + EmojiGridItem( + emoji = emoji, + onClick = { onEmojiClicked(emoji) } + ) + } + } + } + } + + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + + // Category Selector Bar + EmojiCategoryBar( + selectedCategory = selectedCategory, + categories = categories, + onCategorySelected = onCategorySelected + ) + } + } +} + +@Composable +fun EmojiHeaderToolbar( + selectedCategory: EmojiCategoryType, + onBackClicked: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(48.dp) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onBackClicked, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.back), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 4.dp) + ) { + Text( + text = stringResource(id = R.string.emojis), + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = selectedCategory.displayName, + fontSize = 11.sp, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +fun EmojiGridItem( + emoji: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val displayEmoji = remember(emoji) { + if (EmojiCompat.isConfigured()) { + try { + EmojiCompat.get().process(emoji).toString() + } catch (e: Exception) { + emoji + } + } else { + emoji + } + } + + Box( + modifier = modifier + .size(42.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Text( + text = displayEmoji, + fontSize = 24.sp, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +fun EmojiCategoryBar( + selectedCategory: EmojiCategoryType, + categories: List, + onCategorySelected: (EmojiCategoryType) -> Unit, + modifier: Modifier = Modifier +) { + LazyRow( + modifier = modifier + .fillMaxWidth() + .height(44.dp) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), + horizontalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + items(categories) { category -> + val type = EmojiCategoryType.entries.firstOrNull { it.name == category.name } ?: EmojiCategoryType.GENERAL + val isSelected = type == selectedCategory + + Surface( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { onCategorySelected(type) }, + shape = RoundedCornerShape(8.dp), + color = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + Color.Transparent + }, + border = if (isSelected) { + BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)) + } else null + ) { + Box( + modifier = Modifier + .padding(horizontal = 10.dp, vertical = 6.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = category.icon, + fontSize = 18.sp + ) + } + } + } + } +} + +@Preview(name = "Light Mode", showBackground = true) +@Composable +private fun EmojiKeyboardScreenPreviewLight() { + FrogoLibKeyboardTheme(darkTheme = false) { + EmojiKeyboardScreen( + emojis = listOf("😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "😊", "😇", "🙂", "🙃", "😉", "😍"), + selectedCategory = EmojiCategoryType.SMILEYS_EMOTION, + categories = getEmojiCategory(), + onCategorySelected = {}, + onEmojiClicked = {}, + onBackClicked = {} + ) + } +} + +@Preview(name = "Dark Mode", showBackground = true) +@Composable +private fun EmojiKeyboardScreenPreviewDark() { + FrogoLibKeyboardTheme(darkTheme = true) { + EmojiKeyboardScreen( + emojis = listOf("😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "😊", "😇", "🙂", "🙃", "😉", "😍"), + selectedCategory = EmojiCategoryType.SMILEYS_EMOTION, + categories = getEmojiCategory(), + onCategorySelected = {}, + onEmojiClicked = {}, + onBackClicked = {} + ) + } +} diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt similarity index 100% rename from frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt rename to lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/KeyPreviewBubble.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/KeyPreviewBubble.kt new file mode 100644 index 0000000..a27bac8 --- /dev/null +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/KeyPreviewBubble.kt @@ -0,0 +1,100 @@ +package com.frogobox.libkeyboard.ui.main + +import android.graphics.drawable.Drawable +import android.widget.ImageView +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import com.frogobox.libkeyboard.ui.theme.FrogoLibKeyboardTheme +import com.frogobox.libkeyboard.ui.theme.KeypadDark +import com.frogobox.libkeyboard.ui.theme.KeypadLight + +/** + * Modern Jetpack Compose Key Preview Bubble component replacing legacy item_keyboard_main.xml. + * Renders a floating elevated tactile preview above the pressed key. + */ +@Composable +fun KeyPreviewBubble( + label: String, + modifier: Modifier = Modifier, + icon: Drawable? = null, + isDarkTheme: Boolean = false +) { + val backgroundColor = if (isDarkTheme) KeypadDark else KeypadLight + val contentColor = if (isDarkTheme) Color.White else Color(0xFF1F1F1F) + + Surface( + modifier = modifier + .wrapContentSize() + .defaultMinSize(minWidth = 48.dp, minHeight = 56.dp), + shape = RoundedCornerShape(10.dp), + color = backgroundColor, + shadowElevation = 6.dp, + tonalElevation = 2.dp + ) { + Box( + modifier = Modifier + .wrapContentSize() + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + if (icon != null) { + AndroidView( + modifier = Modifier.size(28.dp), + factory = { context -> + ImageView(context).apply { + setImageDrawable(icon) + scaleType = ImageView.ScaleType.FIT_CENTER + } + }, + update = { imageView -> + imageView.setImageDrawable(icon) + } + ) + } else { + Text( + text = label, + fontSize = if (label.length > 2) 16.sp else 24.sp, + fontWeight = FontWeight.Bold, + color = contentColor, + textAlign = TextAlign.Center + ) + } + } + } +} + +@Preview(name = "Key Preview Light", showBackground = true) +@Composable +private fun KeyPreviewBubbleLightPreview() { + FrogoLibKeyboardTheme(darkTheme = false) { + Box(modifier = Modifier.padding(16.dp)) { + KeyPreviewBubble(label = "A", isDarkTheme = false) + } + } +} + +@Preview(name = "Key Preview Dark", showBackground = true, backgroundColor = 0xFF121212) +@Composable +private fun KeyPreviewBubbleDarkPreview() { + FrogoLibKeyboardTheme(darkTheme = true) { + Box(modifier = Modifier.padding(16.dp)) { + KeyPreviewBubble(label = "K", isDarkTheme = true) + } + } +} diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt similarity index 74% rename from frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt rename to lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt index f265cfc..97641cc 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt @@ -19,11 +19,10 @@ 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 import android.view.ViewConfiguration +import android.view.ViewGroup import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityManager import android.widget.PopupWindow @@ -55,6 +54,8 @@ import java.util.Locale import kotlin.math.floor import kotlin.math.max import kotlin.math.min +import androidx.core.graphics.createBitmap +import androidx.core.graphics.withSave @SuppressLint("UseCompatLoadingForDrawables", "ClickableViewAccessibility") class MainKeyboard @JvmOverloads constructor( @@ -115,7 +116,6 @@ class MainKeyboard @JvmOverloads constructor( private var mPopupX = 0 private var mPopupY = 0 private var mRepeatKeyIndex = NOT_A_KEY - private var mPopupLayout = 0 private var mAbortKey = false private var mIsLongPressingSpace = false private var mLastSpaceMoveX = 0 @@ -124,8 +124,22 @@ class MainKeyboard @JvmOverloads constructor( private var mTopSmallNumberMarginWidth = 0f private var mTopSmallNumberMarginHeight = 0f private val mSpaceMoveThreshold: Int + private var mDeleteSwipeThreshold = 0 private var ignoreTouches = false + // Instant Gboard gestures + private var mSpaceStartX = 0 + private var mIsSlidingSpace = false + private var mDeleteStartX = 0 + private var mIsSlidingDelete = false + + // Cached drawables for zero per-frame allocation + private var mKeypadDefaultDrawable: android.graphics.drawable.Drawable? = null + private var mKeypadActionDrawable: android.graphics.drawable.Drawable? = null + private var mShiftOffDrawable: android.graphics.drawable.Drawable? = null + private var mShiftOneCharDrawable: android.graphics.drawable.Drawable? = null + private var mShiftPermDrawable: android.graphics.drawable.Drawable? = null + /** Reusable Paint for top small numbers — avoids per-frame allocation */ private val mSmallLetterPaint = Paint() @@ -181,20 +195,19 @@ class MainKeyboard @JvmOverloads constructor( private val LONGPRESS_TIMEOUT = ViewConfiguration.getLongPressTimeout() } - // handle system default theme (Material You) specially as the color is taken from the system, not hardcoded by us - init { + val defaultKeyTextSize = resources.getDimensionPixelSize(R.dimen.keyboard_text_size) + mKeyTextSize = defaultKeyTextSize + val attributes = context.obtainStyledAttributes(attrs, R.styleable.FrogoKeyboardView, 0, defStyleRes) - val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - val keyTextSize = 0 val indexCnt = attributes.indexCount try { for (i in 0 until indexCnt) { when (val attr = attributes.getIndex(i)) { R.styleable.FrogoKeyboardView_keyTextSize -> mKeyTextSize = - attributes.getDimensionPixelSize(attr, 18) + attributes.getDimensionPixelSize(attr, defaultKeyTextSize) } } } finally { @@ -204,13 +217,17 @@ class MainKeyboard @JvmOverloads constructor( mTextColor = context.getColorExt(R.color.keypad_text) mBackgroundColor = context.getColorExt(R.color.keyboard_board) - mPopupLayout = R.layout.keyboard_main_mini + if (background == null) { + setBackgroundColor(mBackgroundColor) + } + mVerticalCorrection = resources.getDimension(R.dimen.vertical_correction).toInt() mLabelTextSize = resources.getDimension(R.dimen.label_text_size).toInt() mPreviewHeight = resources.getDimension(R.dimen.key_height).toInt() mSpaceMoveThreshold = resources.getDimension(com.frogobox.ui.R.dimen.frogo_dimen_8dp).toInt() + mDeleteSwipeThreshold = (resources.getDimension(R.dimen.key_height) * 0.6f).toInt() - mPreviewText = inflater.inflate(resources.getLayout(R.layout.item_keyboard_main), null) as TextView + mPreviewText = createPreviewTextView() mPreviewTextSizeLarge = resources.getDimension(R.dimen.preview_text_size).toInt() mPreviewPopup = PopupWindow(context) @@ -224,7 +241,7 @@ class MainKeyboard @JvmOverloads constructor( mPaint = Paint().apply { isAntiAlias = true - textSize = keyTextSize.toFloat() + textSize = mKeyTextSize.toFloat() textAlign = Align.CENTER alpha = 255 } @@ -237,6 +254,48 @@ class MainKeyboard @JvmOverloads constructor( mTopSmallNumberSize = resources.getDimension(com.frogobox.ui.R.dimen.frogo_dimen_font_10sp) mTopSmallNumberMarginWidth = resources.getDimension(R.dimen.top_small_number_margin_width) mTopSmallNumberMarginHeight = resources.getDimension(R.dimen.top_small_number_margin_height) + + initCachedDrawables() + } + + fun getKeyTextSize(): Int = mKeyTextSize + + fun setKeyTextSize(size: Int) { + mKeyTextSize = size + mPaint.textSize = size.toFloat() + } + + private fun createPreviewTextView(): TextView { + val padding = resources.getDimensionPixelSize(R.dimen.key_margin) + return TextView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + setBackgroundResource(R.drawable.keypad_pop_up) + gravity = Gravity.CENTER + includeFontPadding = false + setPadding(padding, padding, padding, padding) + setTextColor(context.getColorExt(R.color.keypad_text)) + typeface = Typeface.DEFAULT_BOLD + setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.preview_text_size)) + } + } + + private fun createMiniKeyboard(): MainKeyboard { + return MainKeyboard(context, null).apply { + id = R.id.mini_keyboard_view + setBackgroundResource(R.drawable.keypad_pop_up) + setKeyTextSize(resources.getDimensionPixelSize(R.dimen.preview_text_size)) + } + } + + fun initCachedDrawables() { + mKeypadDefaultDrawable = resources.getDrawable(R.drawable.keypad_default, context.theme) + mKeypadActionDrawable = resources.getDrawable(R.drawable.keypad_action, context.theme) + mShiftOffDrawable = ResourcesCompat.getDrawable(resources, R.drawable.ic_keyboard_caps_outline, null) + mShiftOneCharDrawable = ResourcesCompat.getDrawable(resources, R.drawable.ic_keyboard_caps, null) + mShiftPermDrawable = ResourcesCompat.getDrawable(resources, R.drawable.ic_keyboard_caps_underlined, null) } override fun onAttachedToWindow() { @@ -259,25 +318,33 @@ class MainKeyboard @JvmOverloads constructor( } - override fun onVisibilityChanged(changedView: View, visibility: Int) { + public override fun onVisibilityChanged(changedView: View, visibility: Int) { super.onVisibilityChanged(changedView, visibility) if (visibility == VISIBLE) { mTextColor = context.getColorExt(R.color.keypad_text) mBackgroundColor = context.getColorExt(R.color.keyboard_board) + initCachedDrawables() val strokeColor = context.getColorExt(R.color.keypad_mini_stroke) val miniKeyboardBackgroundColor = context.getColorExt(R.color.keypad) if (changedView == findViewById(R.id.mini_keyboard_view)) { - val previewBackground = background as LayerDrawable - previewBackground.findDrawableByLayerId(R.id.button_background_shape) - .applyColorFilter(miniKeyboardBackgroundColor) - previewBackground.findDrawableByLayerId(R.id.button_background_stroke) - .applyColorFilter(strokeColor) - background = previewBackground + val previewBackground = background as? LayerDrawable + previewBackground?.findDrawableByLayerId(R.id.button_background_shape) + ?.applyColorFilter(miniKeyboardBackgroundColor) + previewBackground?.findDrawableByLayerId(R.id.button_background_stroke) + ?.applyColorFilter(strokeColor) + if (previewBackground != null) { + background = previewBackground + } } else { - background.applyColorFilter(mBackgroundColor) + val currentBg = background + if (currentBg != null) { + currentBg.applyColorFilter(mBackgroundColor) + } else { + setBackgroundColor(mBackgroundColor) + } } } @@ -408,11 +475,11 @@ class MainKeyboard @JvmOverloads constructor( @SuppressLint("UseCompatLoadingForDrawables") private fun onBufferDraw() { if (mBuffer == null || mKeyboardChanged) { - if (mBuffer == null || mKeyboardChanged && (mBuffer!!.width != width || mBuffer!!.height != height)) { + if (mBuffer == null || (mBuffer!!.width != width || mBuffer!!.height != height)) { // Make sure our bitmap is at least 1x1 val width = max(1, width) val height = max(1, height) - mBuffer = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + mBuffer = createBitmap(width, height) mCanvas = Canvas(mBuffer!!) } invalidateAllKeys() @@ -423,125 +490,133 @@ class MainKeyboard @JvmOverloads constructor( return } - mCanvas!!.save() - val canvas = mCanvas - canvas!!.clipRect(mDirtyRect) - val paint = mPaint - val keys = mKeys - paint.color = mTextColor - // 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 + mCanvas!!.withSave { + val canvas = mCanvas + canvas!!.clipRect(mDirtyRect) + val paint = mPaint + val keys = mKeys + paint.color = mTextColor + // 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) + canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) - val keyCount = keys.size - for (i in 0 until keyCount) { - val key = keys[i] - val code = key.code - var keyBackground = resources.getDrawable(R.drawable.keypad_default, context.theme) - when (code) { - - KEYCODE_SHIFT, - KEYCODE_DELETE, - KEYCODE_ENTER, - 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) - } - - } + val keyCount = keys.size + val kb = mKeyboard + val defaultBg = mKeypadDefaultDrawable ?: resources.getDrawable( + R.drawable.keypad_default, + context.theme + ) + val actionBg = mKeypadActionDrawable ?: resources.getDrawable( + R.drawable.keypad_action, + context.theme + ) - // Switch the character to uppercase if shift is pressed - val label = adjustCase(key.label)?.toString() - val bounds = keyBackground!!.bounds - if (key.width != bounds.right || key.height != bounds.bottom) { - keyBackground.setBounds(0, 0, key.width, key.height) - } + for (i in 0 until keyCount) { + val key = keys[i] + val code = key.code + val keyBackground = when (code) { + KEYCODE_SHIFT, + KEYCODE_DELETE, + KEYCODE_ENTER, + KEYCODE_MODE_CHANGE, + KEYCODE_TAB, + KEYCODE_ARROW_LEFT, + KEYCODE_ARROW_RIGHT, + KEYCODE_ARROW_UP, + KEYCODE_ARROW_DOWN, + -> actionBg + + else -> defaultBg + } - keyBackground.state = when { - key.pressed -> intArrayOf(android.R.attr.state_pressed) - key.focused -> intArrayOf(android.R.attr.state_focused) - else -> intArrayOf() - } + // Switch the character to uppercase if shift is pressed + val label = adjustCase(key.label)?.toString() + val bounds = keyBackground.bounds + if (key.width != bounds.right || key.height != bounds.bottom) { + keyBackground.setBounds(0, 0, key.width, key.height) + } - canvas.translate(key.x.toFloat(), key.y.toFloat()) - keyBackground.draw(canvas) - if (label?.isNotEmpty() == true) { - // For characters, use large font. For labels like "Done", use small font. - if (label.length > 1) { - paint.textSize = mLabelTextSize.toFloat() - paint.typeface = Typeface.DEFAULT_BOLD - } else { - paint.textSize = mKeyTextSize.toFloat() - paint.typeface = Typeface.DEFAULT + keyBackground.state = when { + key.pressed -> intArrayOf(android.R.attr.state_pressed) + key.focused -> intArrayOf(android.R.attr.state_focused) + else -> intArrayOf() } - paint.color = mTextColor + canvas.translate(key.x.toFloat(), key.y.toFloat()) + keyBackground.draw(canvas) + if (label?.isNotEmpty() == true) { + // For characters, use large font. For labels like "Done", use small font. + if (label.length > 1) { + paint.textSize = mLabelTextSize.toFloat() + paint.typeface = Typeface.DEFAULT_BOLD + } else { + paint.textSize = mKeyTextSize.toFloat() + paint.typeface = Typeface.DEFAULT + } - canvas.drawText( - label, - (key.width / 2).toFloat(), - key.height / 2 + (paint.textSize - paint.descent()) / 2, - paint - ) + paint.color = mTextColor - if (key.topSmallNumber.isNotEmpty()) { canvas.drawText( - key.topSmallNumber, - key.width - mTopSmallNumberMarginWidth, - mTopSmallNumberMarginHeight, - smallLetterPaint + label, + (key.width / 2).toFloat(), + key.height / 2 + (paint.textSize - paint.descent()) / 2, + paint ) - } - // 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 (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 + if (key.topSmallNumber.isNotEmpty()) { + canvas.drawText( + key.topSmallNumber, + key.width - mTopSmallNumberMarginWidth, + mTopSmallNumberMarginHeight, + smallLetterPaint + ) } - key.icon = ResourcesCompat.getDrawable(resources, drawableId, null) - } - key.icon?.let { icon -> - 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) + // Turn off drop shadow + paint.setShadowLayer(0f, 0f, 0f, 0) + } else if (key.icon != null && kb != null) { + if (code == KEYCODE_SHIFT) { + key.icon = when (kb.mShiftState) { + SHIFT_OFF -> mShiftOffDrawable + SHIFT_ON_ONE_CHAR -> mShiftOneCharDrawable + else -> mShiftPermDrawable + } } - 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()) + key.icon?.let { icon -> + 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) + } + + 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()) } - canvas.translate(-key.x.toFloat(), -key.y.toFloat()) - } - // Overlay a dark rectangle to dim the keyboard - if (mMiniKeyboardOnScreen) { - paint.color = Color.BLACK.adjustAlpha(0.3f) - canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint) - } + // Overlay a dark rectangle to dim the keyboard + if (mMiniKeyboardOnScreen) { + paint.color = Color.BLACK.adjustAlpha(0.3f) + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint) + } - mCanvas!!.restore() + mCanvas!! + } mDrawPending = false mDirtyRect.setEmpty() } @@ -644,7 +719,7 @@ class MainKeyboard @JvmOverloads constructor( mPreviewText!!.setCompoundDrawables(null, null, null, null) try { mPreviewText!!.text = adjustCase(key.label) - } catch (ignored: Exception) { + } catch (_: Exception) { } } @@ -763,11 +838,6 @@ class MainKeyboard @JvmOverloads constructor( private fun openPopupIfRequired(me: MotionEvent): Boolean { return try { - // Check if we have a popup layout specified first. - if (mPopupLayout == 0) { - return false - } - if (mCurrentKey < 0 || mCurrentKey >= mKeys.size) { return false } @@ -783,8 +853,6 @@ class MainKeyboard @JvmOverloads constructor( e.printStackTrace() false } - - } /** @@ -799,11 +867,9 @@ class MainKeyboard @JvmOverloads constructor( if (popupKeyboardId != 0) { mMiniKeyboardContainer = mMiniKeyboardCache[popupKey] if (mMiniKeyboardContainer == null) { - val inflater = - context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - mMiniKeyboardContainer = inflater.inflate(mPopupLayout, null) - mMiniKeyboard = - mMiniKeyboardContainer!!.findViewById(R.id.mini_keyboard_view) as MainKeyboard + val miniKeyboard = createMiniKeyboard() + mMiniKeyboardContainer = miniKeyboard + mMiniKeyboard = miniKeyboard mMiniKeyboard!!.mOnKeyboardActionListener = object : OnKeyboardActionListener { override fun onKey(code: Int) { @@ -851,8 +917,8 @@ class MainKeyboard @JvmOverloads constructor( ) mMiniKeyboardCache[popupKey] = mMiniKeyboardContainer } else { - mMiniKeyboard = - mMiniKeyboardContainer!!.findViewById(R.id.mini_keyboard_view) as MainKeyboard + mMiniKeyboard = mMiniKeyboardContainer as? MainKeyboard + ?: (mMiniKeyboardContainer!!.findViewById(R.id.mini_keyboard_view) as MainKeyboard) } getLocationInWindow(mCoordinates) @@ -1037,6 +1103,19 @@ class MainKeyboard @JvmOverloads constructor( mDownTime = me.eventTime mLastMoveTime = mDownTime + val isSpaceKey = mCurrentKey >= 0 && mKeys.getOrNull(mCurrentKey)?.code == KEYCODE_SPACE + val isDeleteKey = mCurrentKey >= 0 && mKeys.getOrNull(mCurrentKey)?.code == KEYCODE_DELETE + + if (isSpaceKey) { + mSpaceStartX = touchX + mLastSpaceMoveX = touchX + mIsSlidingSpace = false + } + if (isDeleteKey) { + mDeleteStartX = touchX + mIsSlidingDelete = false + } + val onPressKey = if (keyIndex != NOT_A_KEY) { mKeys[keyIndex].code } else { @@ -1051,9 +1130,8 @@ class MainKeyboard @JvmOverloads constructor( val msg = mHandler!!.obtainMessage(MSG_REPEAT) mHandler!!.sendMessageDelayed(msg, REPEAT_START_DELAY.toLong()) - // if the user long presses Space, move the cursor after swipine left/right if (mKeys[mCurrentKey].code == KEYCODE_SPACE) { - mLastSpaceMoveX = -1 + mLastSpaceMoveX = touchX } else { repeatKey(true) } @@ -1096,19 +1174,57 @@ class MainKeyboard @JvmOverloads constructor( } } - if (mIsLongPressingSpace) { + // Instant Gboard spacebar swipe cursor control (without 400ms delay) + if (mCurrentKey >= 0 && mKeys.getOrNull(mCurrentKey)?.code == KEYCODE_SPACE) { + val totalDiffX = touchX - mSpaceStartX + if (kotlin.math.abs(totalDiffX) > mSpaceMoveThreshold) { + if (!mIsSlidingSpace) { + mIsSlidingSpace = true + mHandler?.removeMessages(MSG_REPEAT) + mHandler?.removeMessages(MSG_LONGPRESS) + showPreview(NOT_A_KEY) + } + + val diff = touchX - mLastSpaceMoveX + if (diff < -mSpaceMoveThreshold) { + val steps = (-diff) / mSpaceMoveThreshold + repeat((0 until steps).count()) { + mOnKeyboardActionListener?.moveCursorLeft() + vibrateIfNeeded() + } + mLastSpaceMoveX -= steps * mSpaceMoveThreshold + } else if (diff > mSpaceMoveThreshold) { + val steps = diff / mSpaceMoveThreshold + repeat((0 until steps).count()) { + mOnKeyboardActionListener?.moveCursorRight() + vibrateIfNeeded() + } + mLastSpaceMoveX += steps * mSpaceMoveThreshold + } + } + } else if (mCurrentKey >= 0 && mKeys.getOrNull(mCurrentKey)?.code == KEYCODE_DELETE) { + val swipeLeftDist = mDeleteStartX - touchX + if (swipeLeftDist > mDeleteSwipeThreshold) { + if (!mIsSlidingDelete) { + mIsSlidingDelete = true + mHandler?.removeMessages(MSG_REPEAT) + mHandler?.removeMessages(MSG_LONGPRESS) + showPreview(NOT_A_KEY) + } + } + } else if (mIsLongPressingSpace) { if (mLastSpaceMoveX == -1) { mLastSpaceMoveX = mLastX } val diff = mLastX - mLastSpaceMoveX if (diff < -mSpaceMoveThreshold) { - for (i in diff / mSpaceMoveThreshold until 0) { + repeat((diff / mSpaceMoveThreshold until 0).count()) { mOnKeyboardActionListener?.moveCursorLeft() } mLastSpaceMoveX = mLastX } else if (diff > mSpaceMoveThreshold) { - for (i in 0 until diff / mSpaceMoveThreshold) { + repeat((0 until diff / mSpaceMoveThreshold).count()) { mOnKeyboardActionListener?.moveCursorRight() } mLastSpaceMoveX = mLastX @@ -1148,12 +1264,28 @@ class MainKeyboard @JvmOverloads constructor( } showPreview(NOT_A_KEY) Arrays.fill(mKeyIndices, NOT_A_KEY) - // 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) - } 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) + + val wasSlidingSpace = mIsSlidingSpace + val wasSlidingDelete = mIsSlidingDelete + mIsSlidingSpace = false + mIsSlidingDelete = false + + if (wasSlidingSpace) { + // Sliding space gesture consumed - do not send space character + } else if (wasSlidingDelete) { + // Sliding delete gesture consumed - delete words + val swipeLeftDist = mDeleteStartX - touchX + val wordsToDelete = max(1, swipeLeftDist / (mDeleteSwipeThreshold * 2)) + mOnKeyboardActionListener?.deleteWordsBeforeCursor(wordsToDelete) + vibrateIfNeeded() + } else { + // 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) + } 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) + } } invalidateKey(keyIndex) @@ -1164,6 +1296,8 @@ class MainKeyboard @JvmOverloads constructor( MotionEvent.ACTION_CANCEL -> { mIsLongPressingSpace = false + mIsSlidingSpace = false + mIsSlidingDelete = false mLastSpaceMoveX = 0 removeMessages() dismissPopupKeyboard() diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboardScreen.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboardScreen.kt new file mode 100644 index 0000000..e64d449 --- /dev/null +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboardScreen.kt @@ -0,0 +1,224 @@ +package com.frogobox.libkeyboard.ui.main + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.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.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Popup +import com.frogobox.libkeyboard.R +import com.frogobox.libkeyboard.ui.theme.KeypadActionDark +import com.frogobox.libkeyboard.ui.theme.KeypadActionLight +import com.frogobox.libkeyboard.ui.theme.KeypadDark +import com.frogobox.libkeyboard.ui.theme.KeypadLight +import com.frogobox.sdk.ext.getColorExt + +/** + * Jetpack Compose interoperability wrapper for hosting [MainKeyboard] within Compose UI trees. + */ +@Composable +fun MainKeyboardView( + keyboard: ItemMainKeyboard?, + onActionListener: OnKeyboardActionListener?, + modifier: Modifier = Modifier, + onInit: (MainKeyboard) -> Unit = {} +) { + AndroidView( + modifier = modifier, + factory = { context -> + MainKeyboard(context, null).apply { + if (background == null) { + setBackgroundColor(context.getColorExt(R.color.keyboard_board)) + } + this.mOnKeyboardActionListener = onActionListener + keyboard?.let { setKeyboard(it) } + onInit(this) + } + }, + update = { view -> + view.mOnKeyboardActionListener = onActionListener + if (keyboard != null) { + view.setKeyboard(keyboard) + } + } + ) +} + +/** + * Declarative Jetpack Compose component rendering keyboard keys based on [ItemMainKeyboard]. + */ +@Composable +fun MainKeyboardComposable( + keyboard: ItemMainKeyboard, + onKeyPress: (Int) -> Unit, + modifier: Modifier = Modifier, + onKeyActionUp: () -> Unit = {}, + isDarkTheme: Boolean = false +) { + val defaultKeyColor = if (isDarkTheme) KeypadDark else KeypadLight + val actionKeyColor = if (isDarkTheme) KeypadActionDark else KeypadActionLight + + var activePopupKey by remember { mutableStateOf(null) } + + Box(modifier = modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = 4.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Group keys by their approximate row position + val keys = keyboard.mKeys + if (keys.isNotEmpty()) { + val rowYPositions = keys.map { it.y }.distinct().sorted() + + for (rowY in rowYPositions) { + val rowKeys = keys.filter { it.y == rowY }.sortedBy { it.x } + val totalRowWidth = rowKeys.sumOf { it.width }.coerceAtLeast(1) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(3.dp), + verticalAlignment = Alignment.CenterVertically + ) { + for (key in rowKeys) { + val weight = (key.width.toFloat() / totalRowWidth.toFloat()).coerceAtLeast(0.01f) + val isActionKey = when (key.code) { + ItemMainKeyboard.KEYCODE_SHIFT, + ItemMainKeyboard.KEYCODE_DELETE, + ItemMainKeyboard.KEYCODE_ENTER, + ItemMainKeyboard.KEYCODE_MODE_CHANGE, + ItemMainKeyboard.KEYCODE_TAB -> true + else -> false + } + + val keyBackground = if (isActionKey) actionKeyColor else defaultKeyColor + + KeyItemView( + label = key.label.toString(), + code = key.code, + backgroundColor = keyBackground, + isActionKey = isActionKey, + onClick = { + onKeyPress(key.code) + onKeyActionUp() + }, + onLongClick = if (key.popupCharacters != null && key.popupCharacters!!.isNotEmpty()) { + { activePopupKey = key } + } else null, + modifier = Modifier.weight(weight) + ) + } + } + } + } + } + + // Render Compose MiniKeyboardPopup overlay on long-press + activePopupKey?.let { popupKey -> + val chars = popupKey.popupCharacters?.map { it.toString() } ?: emptyList() + if (chars.isNotEmpty()) { + Popup( + alignment = Alignment.TopCenter, + onDismissRequest = { activePopupKey = null } + ) { + Box(modifier = Modifier.padding(top = 8.dp)) { + MiniKeyboardPopup( + characters = chars, + onKeySelected = { selectedChar -> + val code = selectedChar.firstOrNull()?.code ?: 0 + if (code != 0) { + onKeyPress(code) + onKeyActionUp() + } + activePopupKey = null + }, + isDarkTheme = isDarkTheme + ) + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun KeyItemView( + label: String, + code: Int, + backgroundColor: Color, + isActionKey: Boolean, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + + Surface( + modifier = modifier + .height(46.dp) + .clip(RoundedCornerShape(6.dp)) + .combinedClickable( + interactionSource = interactionSource, + indication = ripple(bounded = true), + onClick = onClick, + onLongClick = onLongClick + ), + shape = RoundedCornerShape(6.dp), + color = backgroundColor, + border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + shadowElevation = if (isActionKey) 1.dp else 1.5.dp + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + val displayLabel = when (code) { + ItemMainKeyboard.KEYCODE_SHIFT -> "⇧" + ItemMainKeyboard.KEYCODE_DELETE -> "⌫" + ItemMainKeyboard.KEYCODE_ENTER -> "↵" + ItemMainKeyboard.KEYCODE_SPACE -> " " + ItemMainKeyboard.KEYCODE_MODE_CHANGE -> "?123" + ItemMainKeyboard.KEYCODE_EMOJI -> "🙂" + ItemMainKeyboard.KEYCODE_TAB -> "⇥" + else -> label + } + + Text( + text = displayLabel, + fontSize = if (displayLabel.length > 2) 13.sp else 18.sp, + fontWeight = if (displayLabel.length > 2 || isActionKey) FontWeight.SemiBold else FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MiniKeyboardPopup.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MiniKeyboardPopup.kt new file mode 100644 index 0000000..5d566c0 --- /dev/null +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MiniKeyboardPopup.kt @@ -0,0 +1,133 @@ +package com.frogobox.libkeyboard.ui.main + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.libkeyboard.ui.theme.FrogoLibKeyboardTheme + +/** + * Modern Jetpack Compose Mini Keyboard Popup component replacing legacy keyboard_main_mini.xml. + * Renders a horizontal capsule strip containing alternate characters on key long-press. + */ +@Composable +fun MiniKeyboardPopup( + characters: List, + onKeySelected: (String) -> Unit, + modifier: Modifier = Modifier, + selectedIndex: Int = -1, + isDarkTheme: Boolean = false +) { + val containerColor = if (isDarkTheme) Color(0xFF2B2B2C) else Color(0xFFECEFF1) + val selectedItemColor = MaterialTheme.colorScheme.primaryContainer + val defaultItemColor = Color.Transparent + val defaultTextColor = if (isDarkTheme) Color.White else Color(0xFF1E1E1E) + val selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer + + Surface( + modifier = modifier + .wrapContentWidth() + .height(52.dp), + shape = RoundedCornerShape(12.dp), + color = containerColor, + shadowElevation = 8.dp, + tonalElevation = 4.dp + ) { + Row( + modifier = Modifier + .wrapContentWidth() + .padding(horizontal = 4.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + characters.forEachIndexed { index, char -> + val isSelected = index == selectedIndex + val backgroundColor by animateColorAsState( + targetValue = if (isSelected) selectedItemColor else defaultItemColor, + animationSpec = tween(durationMillis = 120), + label = "MiniKeyHighlight" + ) + val textColor = if (isSelected) selectedTextColor else defaultTextColor + val interactionSource = remember { MutableInteractionSource() } + + Box( + modifier = Modifier + .defaultMinSize(minWidth = 38.dp) + .height(44.dp) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .clickable( + interactionSource = interactionSource, + indication = ripple(bounded = true), + onClick = { onKeySelected(char) } + ), + contentAlignment = Alignment.Center + ) { + Text( + text = char, + fontSize = 18.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + color = textColor, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 6.dp) + ) + } + } + } + } +} + +@Preview(name = "Mini Keyboard Light", showBackground = true) +@Composable +private fun MiniKeyboardPopupLightPreview() { + FrogoLibKeyboardTheme(darkTheme = false) { + Box(modifier = Modifier.padding(16.dp)) { + MiniKeyboardPopup( + characters = listOf("á", "à", "â", "ä", "ã", "å"), + selectedIndex = 2, + onKeySelected = {}, + isDarkTheme = false + ) + } + } +} + +@Preview(name = "Mini Keyboard Dark", showBackground = true, backgroundColor = 0xFF121212) +@Composable +private fun MiniKeyboardPopupDarkPreview() { + FrogoLibKeyboardTheme(darkTheme = true) { + Box(modifier = Modifier.padding(16.dp)) { + MiniKeyboardPopup( + characters = listOf("1", "!", "@", "#", "$"), + selectedIndex = 0, + onKeySelected = {}, + isDarkTheme = true + ) + } + } +} diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/OnKeyboardActionListener.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/OnKeyboardActionListener.kt similarity index 90% rename from frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/OnKeyboardActionListener.kt rename to lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/OnKeyboardActionListener.kt index 6bbcb5a..48746d7 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/OnKeyboardActionListener.kt +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/OnKeyboardActionListener.kt @@ -43,4 +43,9 @@ interface OnKeyboardActionListener { * @param text the string to be displayed. */ fun onText(text: String) + + /** + * Called when the user swipes left on Backspace to delete words + */ + fun deleteWordsBeforeCursor(count: Int) {} } \ No newline at end of file diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/theme/FrogoLibKeyboardTheme.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/theme/FrogoLibKeyboardTheme.kt new file mode 100644 index 0000000..1163131 --- /dev/null +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/theme/FrogoLibKeyboardTheme.kt @@ -0,0 +1,78 @@ +package com.frogobox.libkeyboard.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +val KeyboardTealPrimary = Color(0xFF2563EB) +val KeyboardTealPrimaryDark = Color(0xFF3B82F6) +val KeyboardTealPrimaryContainer = Color(0xFFDBEAFE) +val KeyboardTealPrimaryContainerDark = Color(0xFF1E3A8A) + +val KeyboardSurfaceLight = Color(0xFFFFFFFF) +val KeyboardSurfaceDark = Color(0xFF0F172A) +val KeyboardBackgroundLight = Color(0xFFF8FAFC) +val KeyboardBackgroundDark = Color(0xFF020617) + +val KeypadLight = Color(0xFFF1F5F9) +val KeypadDark = Color(0xFF1E293B) +val KeypadActionLight = Color(0xFFE2E8F0) +val KeypadActionDark = Color(0xFF334155) + +private val DarkColorScheme = darkColorScheme( + primary = KeyboardTealPrimaryDark, + onPrimary = Color(0xFF020617), + primaryContainer = KeyboardTealPrimaryContainerDark, + onPrimaryContainer = Color(0xFFDBEAFE), + surface = KeyboardSurfaceDark, + onSurface = Color(0xFFF8FAFC), + surfaceVariant = Color(0xFF1E293B), + onSurfaceVariant = Color(0xFF94A3B8), + background = KeyboardBackgroundDark, + onBackground = Color(0xFFF8FAFC), + outline = Color(0xFF334155), + outlineVariant = Color(0xFF1E293B) +) + +private val LightColorScheme = lightColorScheme( + primary = KeyboardTealPrimary, + onPrimary = Color.White, + primaryContainer = KeyboardTealPrimaryContainer, + onPrimaryContainer = Color(0xFF1E3A8A), + surface = KeyboardSurfaceLight, + onSurface = Color(0xFF0F172A), + surfaceVariant = Color(0xFFF1F5F9), + onSurfaceVariant = Color(0xFF64748B), + background = KeyboardBackgroundLight, + onBackground = Color(0xFF0F172A), + outline = Color(0xFFCBD5E1), + outlineVariant = Color(0xFFE2E8F0) +) + +@Composable +fun FrogoLibKeyboardTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + content = content + ) +} diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_left.xml b/lib-keyboard/src/main/res/drawable/ic_keyboard_arrow_left.xml similarity index 100% rename from frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_left.xml rename to lib-keyboard/src/main/res/drawable/ic_keyboard_arrow_left.xml diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_right.xml b/lib-keyboard/src/main/res/drawable/ic_keyboard_arrow_right.xml similarity index 100% rename from frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_right.xml rename to lib-keyboard/src/main/res/drawable/ic_keyboard_arrow_right.xml diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_backspace.xml b/lib-keyboard/src/main/res/drawable/ic_keyboard_backspace.xml similarity index 100% rename from frogo-keyboard/src/main/res/drawable/ic_keyboard_backspace.xml rename to lib-keyboard/src/main/res/drawable/ic_keyboard_backspace.xml diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_caps.xml b/lib-keyboard/src/main/res/drawable/ic_keyboard_caps.xml similarity index 100% rename from frogo-keyboard/src/main/res/drawable/ic_keyboard_caps.xml rename to lib-keyboard/src/main/res/drawable/ic_keyboard_caps.xml diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_caps_outline.xml b/lib-keyboard/src/main/res/drawable/ic_keyboard_caps_outline.xml similarity index 100% rename from frogo-keyboard/src/main/res/drawable/ic_keyboard_caps_outline.xml rename to lib-keyboard/src/main/res/drawable/ic_keyboard_caps_outline.xml diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_caps_underlined.xml b/lib-keyboard/src/main/res/drawable/ic_keyboard_caps_underlined.xml similarity index 100% rename from frogo-keyboard/src/main/res/drawable/ic_keyboard_caps_underlined.xml rename to lib-keyboard/src/main/res/drawable/ic_keyboard_caps_underlined.xml diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_emoji_emotions.xml b/lib-keyboard/src/main/res/drawable/ic_keyboard_emoji_emotions.xml similarity index 100% rename from frogo-keyboard/src/main/res/drawable/ic_keyboard_emoji_emotions.xml rename to lib-keyboard/src/main/res/drawable/ic_keyboard_emoji_emotions.xml diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_enter.xml b/lib-keyboard/src/main/res/drawable/ic_keyboard_enter.xml similarity index 100% rename from frogo-keyboard/src/main/res/drawable/ic_keyboard_enter.xml rename to lib-keyboard/src/main/res/drawable/ic_keyboard_enter.xml diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_search.xml b/lib-keyboard/src/main/res/drawable/ic_keyboard_search.xml similarity index 100% rename from frogo-keyboard/src/main/res/drawable/ic_keyboard_search.xml rename to lib-keyboard/src/main/res/drawable/ic_keyboard_search.xml diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_send.xml b/lib-keyboard/src/main/res/drawable/ic_keyboard_send.xml similarity index 100% rename from frogo-keyboard/src/main/res/drawable/ic_keyboard_send.xml rename to lib-keyboard/src/main/res/drawable/ic_keyboard_send.xml diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_tab.xml b/lib-keyboard/src/main/res/drawable/ic_keyboard_tab.xml similarity index 100% rename from frogo-keyboard/src/main/res/drawable/ic_keyboard_tab.xml rename to lib-keyboard/src/main/res/drawable/ic_keyboard_tab.xml diff --git a/lib-keyboard/src/main/res/drawable/keypad_action.xml b/lib-keyboard/src/main/res/drawable/keypad_action.xml new file mode 100644 index 0000000..4b88a25 --- /dev/null +++ b/lib-keyboard/src/main/res/drawable/keypad_action.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frogo-keyboard/src/main/res/drawable/keypad_default.xml b/lib-keyboard/src/main/res/drawable/keypad_default.xml similarity index 81% rename from frogo-keyboard/src/main/res/drawable/keypad_default.xml rename to lib-keyboard/src/main/res/drawable/keypad_default.xml index cdc762a..05f520f 100644 --- a/frogo-keyboard/src/main/res/drawable/keypad_default.xml +++ b/lib-keyboard/src/main/res/drawable/keypad_default.xml @@ -6,8 +6,7 @@ android:bottom="@dimen/key_margin" android:left="@dimen/key_margin" android:right="@dimen/key_margin" - android:top="@dimen/key_margin" - android:state_pressed="true"> + android:top="@dimen/key_margin"> @@ -21,10 +20,10 @@ android:bottom="@dimen/key_margin" android:left="@dimen/key_margin" android:right="@dimen/key_margin" - android:top="@dimen/key_margin" - android:state_pressed="true"> + android:top="@dimen/key_margin"> - diff --git a/frogo-keyboard/src/main/res/drawable/keypad_pop_up.xml b/lib-keyboard/src/main/res/drawable/keypad_pop_up.xml similarity index 82% rename from frogo-keyboard/src/main/res/drawable/keypad_pop_up.xml rename to lib-keyboard/src/main/res/drawable/keypad_pop_up.xml index aa6c2bc..922f557 100644 --- a/frogo-keyboard/src/main/res/drawable/keypad_pop_up.xml +++ b/lib-keyboard/src/main/res/drawable/keypad_pop_up.xml @@ -3,7 +3,7 @@ - + @@ -13,7 +13,7 @@ android:width="@dimen/frogo_dimen_1dp" android:color="@color/keypad_mini_stroke" /> - + diff --git a/frogo-keyboard/src/main/res/raw/mechanical_cherry_blue.wav b/lib-keyboard/src/main/res/raw/mechanical_cherry_blue.wav similarity index 100% rename from frogo-keyboard/src/main/res/raw/mechanical_cherry_blue.wav rename to lib-keyboard/src/main/res/raw/mechanical_cherry_blue.wav diff --git a/frogo-keyboard/src/main/res/raw/mechanical_cherry_brown.wav b/lib-keyboard/src/main/res/raw/mechanical_cherry_brown.wav similarity index 100% rename from frogo-keyboard/src/main/res/raw/mechanical_cherry_brown.wav rename to lib-keyboard/src/main/res/raw/mechanical_cherry_brown.wav diff --git a/frogo-keyboard/src/main/res/raw/mechanical_cherry_red.wav b/lib-keyboard/src/main/res/raw/mechanical_cherry_red.wav similarity index 100% rename from frogo-keyboard/src/main/res/raw/mechanical_cherry_red.wav rename to lib-keyboard/src/main/res/raw/mechanical_cherry_red.wav diff --git a/frogo-keyboard/src/main/res/raw/mechanical_typewriter.wav b/lib-keyboard/src/main/res/raw/mechanical_typewriter.wav similarity index 100% rename from frogo-keyboard/src/main/res/raw/mechanical_typewriter.wav rename to lib-keyboard/src/main/res/raw/mechanical_typewriter.wav diff --git a/frogo-keyboard/src/main/res/values-night/colors.xml b/lib-keyboard/src/main/res/values-night/colors.xml similarity index 84% rename from frogo-keyboard/src/main/res/values-night/colors.xml rename to lib-keyboard/src/main/res/values-night/colors.xml index 45d4fcd..4c8fa4b 100644 --- a/frogo-keyboard/src/main/res/values-night/colors.xml +++ b/lib-keyboard/src/main/res/values-night/colors.xml @@ -2,8 +2,6 @@ #FFFFFF - #000000 - #FFFFFF @@ -13,10 +11,12 @@ #F9FCFF #80232427 + #3F4347 #232427 #80232427 #181921 #80B1BEFF + #808595D6 \ No newline at end of file diff --git a/frogo-keyboard/src/main/res/values-night/themes.xml b/lib-keyboard/src/main/res/values-night/themes.xml similarity index 100% rename from frogo-keyboard/src/main/res/values-night/themes.xml rename to lib-keyboard/src/main/res/values-night/themes.xml diff --git a/frogo-keyboard/src/main/res/values/attrs.xml b/lib-keyboard/src/main/res/values/attrs.xml similarity index 100% rename from frogo-keyboard/src/main/res/values/attrs.xml rename to lib-keyboard/src/main/res/values/attrs.xml diff --git a/frogo-keyboard/src/main/res/values/colors.xml b/lib-keyboard/src/main/res/values/colors.xml similarity index 71% rename from frogo-keyboard/src/main/res/values/colors.xml rename to lib-keyboard/src/main/res/values/colors.xml index b7d9199..be36eb1 100644 --- a/frogo-keyboard/src/main/res/values/colors.xml +++ b/lib-keyboard/src/main/res/values/colors.xml @@ -11,15 +11,8 @@ #FF000000 #FFFFFFFF - #424242 - - #C53741 - #41AF60 - #FFF57C00 #000000 - #FFFFFF - #000000 #FCFCFF @@ -29,12 +22,13 @@ #001E2C #80FFFFFF #80EFEFEF - #757575 + #D1E4FF #FFFFFF #8076D1FF + #8038A4E0 - #757575 + #B0BEC5 #FFFFFF diff --git a/lib-keyboard/src/main/res/values/dimens.xml b/lib-keyboard/src/main/res/values/dimens.xml new file mode 100644 index 0000000..218ca6c --- /dev/null +++ b/lib-keyboard/src/main/res/values/dimens.xml @@ -0,0 +1,19 @@ + + + + 52dp + 3dp + 10dp + 16dp + 10dp + 14dp + + 60dp + -10dp + + + 22sp + 26sp + 16sp + + diff --git a/lib-keyboard/src/main/res/values/ids.xml b/lib-keyboard/src/main/res/values/ids.xml new file mode 100644 index 0000000..c9097dc --- /dev/null +++ b/lib-keyboard/src/main/res/values/ids.xml @@ -0,0 +1,4 @@ + + + + diff --git a/frogo-keyboard/src/main/res/values/strings.xml b/lib-keyboard/src/main/res/values/strings.xml similarity index 68% rename from frogo-keyboard/src/main/res/values/strings.xml rename to lib-keyboard/src/main/res/values/strings.xml index 5f03446..c916a70 100644 --- a/frogo-keyboard/src/main/res/values/strings.xml +++ b/lib-keyboard/src/main/res/values/strings.xml @@ -1,17 +1,12 @@ - Change Keyboard - Delete Change keyboard type Shift Enter + Back Emojis - Welcome to Frogo Keyboard - For using this Frogo Keyboard you need to go to your settings and enable the Frogo Keyboard. After that, when you want to use this Frogo Keyboard, you need to set it for default keyboard. - After that, go to any chat application and share content :) - Bengali Bulgarian English diff --git a/frogo-keyboard/src/main/res/values/styles.xml b/lib-keyboard/src/main/res/values/styles.xml similarity index 100% rename from frogo-keyboard/src/main/res/values/styles.xml rename to lib-keyboard/src/main/res/values/styles.xml diff --git a/frogo-keyboard/src/main/res/values/themes.xml b/lib-keyboard/src/main/res/values/themes.xml similarity index 100% rename from frogo-keyboard/src/main/res/values/themes.xml rename to lib-keyboard/src/main/res/values/themes.xml diff --git a/frogo-keyboard/src/main/res/xml/keys_letter_persian.xml b/lib-keyboard/src/main/res/xml/keys_letter_persian.xml similarity index 100% rename from frogo-keyboard/src/main/res/xml/keys_letter_persian.xml rename to lib-keyboard/src/main/res/xml/keys_letter_persian.xml diff --git a/frogo-keyboard/src/main/res/xml/keys_letters_bengali.xml b/lib-keyboard/src/main/res/xml/keys_letters_bengali.xml similarity index 100% rename from frogo-keyboard/src/main/res/xml/keys_letters_bengali.xml rename to lib-keyboard/src/main/res/xml/keys_letters_bengali.xml diff --git a/frogo-keyboard/src/main/res/xml/keys_letters_bulgarian.xml b/lib-keyboard/src/main/res/xml/keys_letters_bulgarian.xml similarity index 100% rename from frogo-keyboard/src/main/res/xml/keys_letters_bulgarian.xml rename to lib-keyboard/src/main/res/xml/keys_letters_bulgarian.xml diff --git a/frogo-keyboard/src/main/res/xml/keys_letters_english_dvorak.xml b/lib-keyboard/src/main/res/xml/keys_letters_english_dvorak.xml similarity index 100% rename from frogo-keyboard/src/main/res/xml/keys_letters_english_dvorak.xml rename to lib-keyboard/src/main/res/xml/keys_letters_english_dvorak.xml diff --git a/frogo-keyboard/src/main/res/xml/keys_letters_english_qwertz.xml b/lib-keyboard/src/main/res/xml/keys_letters_english_qwertz.xml similarity index 100% rename from frogo-keyboard/src/main/res/xml/keys_letters_english_qwertz.xml rename to lib-keyboard/src/main/res/xml/keys_letters_english_qwertz.xml diff --git a/frogo-keyboard/src/main/res/xml/keys_letters_french.xml b/lib-keyboard/src/main/res/xml/keys_letters_french.xml similarity index 100% rename from frogo-keyboard/src/main/res/xml/keys_letters_french.xml rename to lib-keyboard/src/main/res/xml/keys_letters_french.xml diff --git a/frogo-keyboard/src/main/res/xml/keys_letters_german.xml b/lib-keyboard/src/main/res/xml/keys_letters_german.xml similarity index 100% rename from frogo-keyboard/src/main/res/xml/keys_letters_german.xml rename to lib-keyboard/src/main/res/xml/keys_letters_german.xml diff --git a/frogo-keyboard/src/main/res/xml/keys_letters_greek.xml b/lib-keyboard/src/main/res/xml/keys_letters_greek.xml similarity index 100% rename from frogo-keyboard/src/main/res/xml/keys_letters_greek.xml rename to lib-keyboard/src/main/res/xml/keys_letters_greek.xml diff --git a/frogo-keyboard/src/main/res/xml/keys_letters_lithuanian.xml b/lib-keyboard/src/main/res/xml/keys_letters_lithuanian.xml similarity index 100% rename from frogo-keyboard/src/main/res/xml/keys_letters_lithuanian.xml rename to lib-keyboard/src/main/res/xml/keys_letters_lithuanian.xml diff --git a/frogo-keyboard/src/main/res/xml/keys_letters_programmer.xml b/lib-keyboard/src/main/res/xml/keys_letters_programmer.xml similarity index 100% rename from frogo-keyboard/src/main/res/xml/keys_letters_programmer.xml rename to lib-keyboard/src/main/res/xml/keys_letters_programmer.xml diff --git a/frogo-keyboard/src/main/res/xml/keys_letters_qwerty.xml b/lib-keyboard/src/main/res/xml/keys_letters_qwerty.xml similarity index 53% rename from frogo-keyboard/src/main/res/xml/keys_letters_qwerty.xml rename to lib-keyboard/src/main/res/xml/keys_letters_qwerty.xml index 7303258..e2aba2f 100644 --- a/frogo-keyboard/src/main/res/xml/keys_letters_qwerty.xml +++ b/lib-keyboard/src/main/res/xml/keys_letters_qwerty.xml @@ -14,12 +14,12 @@ app:topSmallNumber="2" /> + app:popupCharacters="@áàâãäåāæą" + app:popupKeyboard="@xml/keys_popup_template" + app:topSmallNumber="@" /> + app:popupCharacters="#śßš$" + app:popupKeyboard="@xml/keys_popup_template" + app:topSmallNumber="#" /> + app:popupCharacters="$ďđ" + app:popupKeyboard="@xml/keys_popup_template" + app:topSmallNumber="$" /> - - - - + app:popupCharacters="%₣" + app:popupKeyboard="@xml/keys_popup_template" + app:topSmallNumber="%" /> + + + + + app:popupCharacters=")ĺľł" + app:popupKeyboard="@xml/keys_popup_template" + app:topSmallNumber=")" /> - + app:popupCharacters="*źžż" + app:popupKeyboard="@xml/keys_popup_template" + app:topSmallNumber="*" /> + - - + app:popupCharacters="'çčć¢" + app:popupKeyboard="@xml/keys_popup_template" + app:topSmallNumber="'" /> + + - + app:popupCharacters="!ňńñ" + app:popupKeyboard="@xml/keys_popup_template" + app:topSmallNumber="!" /> + SHIFT_ON_ONE_CHAR + shiftState = when (shiftState) { + ItemMainKeyboard.SHIFT_OFF -> ItemMainKeyboard.SHIFT_ON_ONE_CHAR + ItemMainKeyboard.SHIFT_ON_ONE_CHAR -> ItemMainKeyboard.SHIFT_OFF + else -> ItemMainKeyboard.SHIFT_OFF + } + assertEquals(ItemMainKeyboard.SHIFT_ON_ONE_CHAR, shiftState) + + // 2nd press (rapid tap) -> SHIFT_ON_PERMANENT (Caps Lock) + val isDoubleTap = true + if (isDoubleTap) { + shiftState = ItemMainKeyboard.SHIFT_ON_PERMANENT + } + assertEquals(ItemMainKeyboard.SHIFT_ON_PERMANENT, shiftState) + + // 3rd press -> SHIFT_OFF + shiftState = when (shiftState) { + ItemMainKeyboard.SHIFT_ON_PERMANENT -> ItemMainKeyboard.SHIFT_OFF + else -> ItemMainKeyboard.SHIFT_ON_ONE_CHAR + } + assertEquals(ItemMainKeyboard.SHIFT_OFF, shiftState) + } + + @Test + fun testDoubleSpaceToPeriodCondition() { + val textBefore1 = "hello " + val canInsertPeriod1 = textBefore1.endsWith(" ") && !textBefore1.endsWith(". ") + assertTrue(canInsertPeriod1) + + val textBefore2 = "hello. " + val canInsertPeriod2 = textBefore2.endsWith(" ") && !textBefore2.endsWith(". ") + assertFalse(canInsertPeriod2) + + val textBefore3 = " " + val canInsertPeriod3 = textBefore3.endsWith(" ") && !textBefore3.endsWith(". ") + assertTrue(canInsertPeriod3) + } + + @Test + fun testDeleteWordsBeforeCursorLogic() { + val sampleText = "The quick brown fox jumps" + val wordsToDelete = 2 // should delete "fox jumps" and the preceding space + + var remainingWords = wordsToDelete + var deleteLen = 0 + var inWord = false + + for (i in sampleText.length - 1 downTo 0) { + val ch = sampleText[i] + if (ch.isWhitespace() || !ch.isLetterOrDigit()) { + if (inWord) { + remainingWords-- + if (remainingWords <= 0) { + deleteLen++ + break + } + inWord = false + } + } else { + inWord = true + } + deleteLen++ + } + + val remainingText = sampleText.dropLast(deleteLen) + assertEquals("The quick brown", remainingText) + } + + @Test + fun testHslHsvConversion() { + val originalHsl = floatArrayOf(200f, 0.5f, 0.5f) + val hsv = com.frogobox.libkeyboard.common.ext.hsl2hsv(originalHsl) + val convertedHsl = com.frogobox.libkeyboard.common.ext.hsv2hsl(hsv) + assertEquals(originalHsl[0], convertedHsl[0], 0.02f) + assertEquals(originalHsl[1], convertedHsl[1], 0.02f) + assertEquals(originalHsl[2], convertedHsl[2], 0.02f) + } + + @Test + fun testConstantsIntegrity() { + assertEquals(0, BaseKeyboardIME.KEYBOARD_LETTERS) + assertEquals(1, BaseKeyboardIME.KEYBOARD_SYMBOLS) + assertEquals(2, BaseKeyboardIME.KEYBOARD_SYMBOLS_SHIFT) + assertEquals(3, BaseKeyboardIME.KEYBOARD_NUMBER) + assertEquals(350L, BaseKeyboardIME.DOUBLE_SPACE_PERIOD_TIMEOUT) + } +} diff --git a/lib-keyboard/src/test/java/com/frogobox/libkeyboard/EmojiKeyboardComposeTest.kt b/lib-keyboard/src/test/java/com/frogobox/libkeyboard/EmojiKeyboardComposeTest.kt new file mode 100644 index 0000000..10562ec --- /dev/null +++ b/lib-keyboard/src/test/java/com/frogobox/libkeyboard/EmojiKeyboardComposeTest.kt @@ -0,0 +1,78 @@ +package com.frogobox.libkeyboard + +import com.frogobox.libkeyboard.ui.emoji.EmojiCategoryType +import com.frogobox.libkeyboard.ui.emoji.displayName +import com.frogobox.libkeyboard.ui.emoji.getEmojiCategory +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 + +class EmojiKeyboardComposeTest { + + @Test + fun testEmojiCategoryTypeCountAndPaths() { + val categories = EmojiCategoryType.entries + assertEquals(10, categories.size) + + assertTrue(categories.any { it == EmojiCategoryType.GENERAL && it.path == "media/_emoji_general.txt" }) + assertTrue(categories.any { it == EmojiCategoryType.ACTIVITIES && it.path == "media/emoji_activities.txt" }) + assertTrue(categories.any { it == EmojiCategoryType.ANIMAL_NATURE && it.path == "media/emoji_animal_nature.txt" }) + assertTrue(categories.any { it == EmojiCategoryType.FLAG && it.path == "media/emoji_flag.txt" }) + assertTrue(categories.any { it == EmojiCategoryType.FOOD_DRINK && it.path == "media/emoji_food_drink.txt" }) + assertTrue(categories.any { it == EmojiCategoryType.OBJECTS && it.path == "media/emoji_objects.txt" }) + assertTrue(categories.any { it == EmojiCategoryType.PEOPLE_BODY && it.path == "media/emoji_people_body.txt" }) + assertTrue(categories.any { it == EmojiCategoryType.SMILEYS_EMOTION && it.path == "media/emoji_smileys_emotion.txt" }) + assertTrue(categories.any { it == EmojiCategoryType.SYMBOLS && it.path == "media/emoji_symbols.txt" }) + assertTrue(categories.any { it == EmojiCategoryType.TRAVEL_PLACES && it.path == "media/emoji_travel_places.txt" }) + } + + @Test + fun testEmojiCategoryTypeDisplayNames() { + assertEquals("General", EmojiCategoryType.GENERAL.displayName) + assertEquals("Activities", EmojiCategoryType.ACTIVITIES.displayName) + assertEquals("Animals & Nature", EmojiCategoryType.ANIMAL_NATURE.displayName) + assertEquals("Flags", EmojiCategoryType.FLAG.displayName) + assertEquals("Food & Drink", EmojiCategoryType.FOOD_DRINK.displayName) + assertEquals("Objects", EmojiCategoryType.OBJECTS.displayName) + assertEquals("People & Body", EmojiCategoryType.PEOPLE_BODY.displayName) + assertEquals("Smileys & Emotion", EmojiCategoryType.SMILEYS_EMOTION.displayName) + assertEquals("Symbols", EmojiCategoryType.SYMBOLS.displayName) + assertEquals("Travel & Places", EmojiCategoryType.TRAVEL_PLACES.displayName) + } + + @Test + fun testGetEmojiCategoryListMapping() { + val categoryList = getEmojiCategory() + assertEquals(10, categoryList.size) + + val first = categoryList[0] + assertEquals("GENERAL", first.name) + assertNotNull(first.icon) + assertEquals("media/_emoji_general.txt", first.path) + } + + @Test + fun testKeycodeMappingsForComposeKeyboard() { + assertEquals(-1, ItemMainKeyboard.KEYCODE_SHIFT) + assertEquals(-2, ItemMainKeyboard.KEYCODE_MODE_CHANGE) + assertEquals(-4, ItemMainKeyboard.KEYCODE_ENTER) + assertEquals(-5, ItemMainKeyboard.KEYCODE_DELETE) + assertEquals(32, ItemMainKeyboard.KEYCODE_SPACE) + assertEquals(-6, ItemMainKeyboard.KEYCODE_EMOJI) + assertEquals(-7, ItemMainKeyboard.KEYCODE_TAB) + } + + @Test + fun testScrollTriggerIncrementLogic() { + var resetTrigger = 0 + assertEquals(0, resetTrigger) + + resetTrigger++ + assertEquals(1, resetTrigger) + + resetTrigger++ + assertEquals(2, resetTrigger) + } +} diff --git a/lib-keyboard/src/test/java/com/frogobox/libkeyboard/KeyboardLayoutLogicTest.kt b/lib-keyboard/src/test/java/com/frogobox/libkeyboard/KeyboardLayoutLogicTest.kt new file mode 100644 index 0000000..e3ab8d1 --- /dev/null +++ b/lib-keyboard/src/test/java/com/frogobox/libkeyboard/KeyboardLayoutLogicTest.kt @@ -0,0 +1,52 @@ +package com.frogobox.libkeyboard + +import com.frogobox.libkeyboard.common.sound.MechanicalSoundType +import com.frogobox.libkeyboard.ui.emoji.EmojiCategoryType +import com.frogobox.libkeyboard.ui.emoji.getEmojiCategory +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 + +class KeyboardLayoutLogicTest { + + @Test + fun testMechanicalSoundTypeMapping() { + 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")) + // Fallback on unknown id + assertEquals(MechanicalSoundType.CHERRY_MX_BLUE, MechanicalSoundType.fromId("unknown_switch")) + } + + @Test + fun testEmojiCategoriesMapping() { + val categories = getEmojiCategory() + assertNotNull(categories) + assertEquals(EmojiCategoryType.entries.size, categories.size) + assertTrue(categories.any { it.name == EmojiCategoryType.SMILEYS_EMOTION.name }) + assertTrue(categories.any { it.name == EmojiCategoryType.PEOPLE_BODY.name }) + } + + @Test + fun testKeycodeConstants() { + assertEquals(-1, ItemMainKeyboard.KEYCODE_SHIFT) + assertEquals(-2, ItemMainKeyboard.KEYCODE_MODE_CHANGE) + assertEquals(-4, ItemMainKeyboard.KEYCODE_ENTER) + assertEquals(-5, ItemMainKeyboard.KEYCODE_DELETE) + assertEquals(32, ItemMainKeyboard.KEYCODE_SPACE) + assertEquals(-6, ItemMainKeyboard.KEYCODE_EMOJI) + assertEquals(-7, ItemMainKeyboard.KEYCODE_TAB) + } + + @Test + fun testAdjustCaseLogic() { + val label = "a" + val upper = label.uppercase() + assertEquals("A", upper) + } +} diff --git a/lib-keyboard/src/test/java/com/frogobox/libkeyboard/KeyboardPopupsComposeTest.kt b/lib-keyboard/src/test/java/com/frogobox/libkeyboard/KeyboardPopupsComposeTest.kt new file mode 100644 index 0000000..8d0902d --- /dev/null +++ b/lib-keyboard/src/test/java/com/frogobox/libkeyboard/KeyboardPopupsComposeTest.kt @@ -0,0 +1,77 @@ +package com.frogobox.libkeyboard + +import android.content.Context +import android.view.View +import androidx.test.core.app.ApplicationProvider +import com.frogobox.libkeyboard.ui.main.MainKeyboard +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit & Robolectric test suite for TASK-024: + * Verifies that MainKeyboard initializes key preview and mini keyboard programmatically + * without relying on legacy XML layouts (item_keyboard_main.xml & keyboard_main_mini.xml). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class KeyboardPopupsComposeTest { + + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun testMainKeyboardInit_withoutXmlLayouts_initializesSuccessfully() { + // Given programmatic instantiation without item_keyboard_main.xml or keyboard_main_mini.xml + val keyboard = MainKeyboard(context, null) + + // Then view must initialize properly + assertNotNull("Keyboard instance must not be null", keyboard) + assertTrue("Key text size must be greater than zero", keyboard.getKeyTextSize() > 0) + assertNotNull("Background must be non-null", keyboard.background) + } + + @Test + fun testSetKeyTextSize_updatesPropertyCorrectly() { + val keyboard = MainKeyboard(context, null) + val newSize = 36 + + keyboard.setKeyTextSize(newSize) + assertEquals("getKeyTextSize must return the updated size", newSize, keyboard.getKeyTextSize()) + } + + @Test + fun testMiniKeyboard_backwardCompatibilityId_remainsDefined() { + // Verify R.id.mini_keyboard_view remains accessible and defined via ids.xml + val resId = R.id.mini_keyboard_view + assertTrue("mini_keyboard_view resource ID must be defined and non-zero", resId != 0) + + // Verify simulated mini_keyboard_view behaves properly in visibility check + val keyboard = MainKeyboard(context, null) + val miniView = View(context).apply { id = R.id.mini_keyboard_view } + + keyboard.onVisibilityChanged(miniView, View.VISIBLE) + assertNotNull("Keyboard background must remain valid after visibility change", keyboard.background) + } + + @Test + fun testMiniKeyboardCharacters_parsingAndSelectionLogic() { + val testChars = listOf("á", "à", "â", "ä", "ã", "å") + var selectedChar: String? = null + + val onSelect: (String) -> Unit = { selectedChar = it } + onSelect(testChars[2]) + + assertEquals("Selected character should match callback input", "â", selectedChar) + assertEquals("Character count matches input list", 6, testChars.size) + } +} diff --git a/lib-keyboard/src/test/java/com/frogobox/libkeyboard/MainKeyboardBackgroundTest.kt b/lib-keyboard/src/test/java/com/frogobox/libkeyboard/MainKeyboardBackgroundTest.kt new file mode 100644 index 0000000..af494d2 --- /dev/null +++ b/lib-keyboard/src/test/java/com/frogobox/libkeyboard/MainKeyboardBackgroundTest.kt @@ -0,0 +1,119 @@ +package com.frogobox.libkeyboard + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.LayerDrawable +import android.view.View +import androidx.test.core.app.ApplicationProvider +import com.frogobox.libkeyboard.ui.main.MainKeyboard +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit and Robolectric regression test suite for TASK-021 & TASK-022: + * Validates defensive null-safety architecture and valid keyTextSize initialization + * during programmatic instantiation and window attachment / visibility changes. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class MainKeyboardBackgroundTest { + + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun testProgrammaticInstantiation_initializesValidKeyTextSize() { + // Given programmatic instantiation with null AttributeSet (Compose interop pattern) + val keyboard = MainKeyboard(context, null) + + // Then keyTextSize must be initialized to keyboard_text_size dimension instead of 0 + val expectedSize = context.resources.getDimensionPixelSize(R.dimen.keyboard_text_size) + assertTrue("Key text size must be greater than 0", keyboard.getKeyTextSize() > 0) + assertEquals("Key text size should match R.dimen.keyboard_text_size", expectedSize, keyboard.getKeyTextSize()) + } + + @Test + fun testProgrammaticInstantiation_initializesNonNullBackground() { + // Given programmatic instantiation with null AttributeSet (Compose interop pattern) + val keyboard = MainKeyboard(context, null) + + // Then background must be non-null immediately upon init (Defense Layer 1) + assertNotNull("Background must be non-null after programmatic instantiation", keyboard.background) + assertTrue("Background should be an instance of ColorDrawable", keyboard.background is ColorDrawable) + } + + @Test + fun testOnVisibilityChanged_whenBackgroundNull_fallbacksGracefullyWithoutNPE() { + // Given programmatic instantiation + val keyboard = MainKeyboard(context, null) + + // Simulate edge-case where background was cleared or set to null at runtime + keyboard.background = null + assertNull(keyboard.background) + + // When onVisibilityChanged is triggered with VISIBLE (as in dispatchAttachedToWindow) + keyboard.onVisibilityChanged(keyboard, View.VISIBLE) + + // Then no NPE must be thrown, and background must be defensively restored (Defense Layer 2) + assertNotNull("Background must be safely restored upon becoming visible", keyboard.background) + assertTrue("Restored background should be a ColorDrawable", keyboard.background is ColorDrawable) + } + + @Test + fun testOnVisibilityChanged_whenBackgroundNonNull_appliesColorFilterSafely() { + // Given programmatic instantiation + val keyboard = MainKeyboard(context, null) + assertNotNull(keyboard.background) + + // When onVisibilityChanged is dispatched with VISIBLE + keyboard.onVisibilityChanged(keyboard, View.VISIBLE) + + // Then background remains valid and non-null without exception + assertNotNull("Background must remain non-null after visibility change", keyboard.background) + } + + @Test + fun testOnVisibilityChanged_miniKeyboardView_safeCastWithoutClassCastException() { + // Given programmatic instantiation + val keyboard = MainKeyboard(context, null) + + // Set background to a non-LayerDrawable (e.g., ColorDrawable) + keyboard.background = ColorDrawable(Color.DKGRAY) + + // Create a simulated mini_keyboard_view + val miniView = View(context).apply { + id = R.id.mini_keyboard_view + } + + // When onVisibilityChanged is triggered targeting mini_keyboard_view + // with a non-LayerDrawable background + keyboard.onVisibilityChanged(miniView, View.VISIBLE) + + // Then no ClassCastException or NPE must occur (safe-cast as? LayerDrawable succeeds) + assertNotNull("Background remains safe and non-null", keyboard.background) + } + + @Test + fun testOnVisibilityChanged_whenVisibilityNotVisible_doesNotThrow() { + val keyboard = MainKeyboard(context, null) + keyboard.background = null + + // GONE or INVISIBLE should bypass visibility logic cleanly + keyboard.onVisibilityChanged(keyboard, View.GONE) + keyboard.onVisibilityChanged(keyboard, View.INVISIBLE) + + assertNull("Background remains null when view is not visible", keyboard.background) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index d3e65ed..5d88077 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,4 +16,4 @@ dependencyResolutionManagement { } } include(":app") -include(":frogo-keyboard") +include(":lib-keyboard")