diff --git a/.gitignore b/.gitignore index 06b4d63..9365c95 100644 --- a/.gitignore +++ b/.gitignore @@ -50,14 +50,14 @@ app.*.map.json /android/app/release # Miscellaneous -build application files +# build application files *.apk *.ap_ *.aab build/ -Local configuration file (sdk path, etc) +# Local configuration file (sdk path, etc) app/version.properties # Flutter Modules @@ -79,10 +79,13 @@ build /captures .externalNativeBuild -generated files +# generated files bin/ gen/ .externalNativeBuild/ # SonarQube .sonar/ +buildSrc/.kotlin +.kotlin +workflow \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2e5c938..89e6f12 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,10 +1,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import java.text.SimpleDateFormat -import java.util.Date plugins { alias(libs.plugins.android.application) - alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.jetbrains.kotlin.compose) alias(libs.plugins.hilt) alias(libs.plugins.ksp) id("kotlin-parcelize") @@ -15,15 +13,14 @@ ksp { } base { - // Naming APK // AAB - val timestamp = SimpleDateFormat("dd-MM-yyyy_hh-mm").format(Date()) - archivesName = "${ProjectSetting.NAME_APK}-${ProjectSetting.PROJECT_VERSION_CODE}-[${ProjectSetting.PROJECT_VERSION_NAME}]-$timestamp" + // Naming APK // AAB (stable name avoids invalidating incremental tasks every minute) + archivesName = "${ProjectSetting.NAME_APK}-${ProjectSetting.PROJECT_VERSION_CODE}-[${ProjectSetting.PROJECT_VERSION_NAME}]" } android { namespace = ProjectSetting.PROJECT_NAME_SPACE_APP - compileSdk = ProjectSetting.PROJECT_TARGET_SDK + compileSdk = ProjectSetting.PROJECT_COMPILE_SDK defaultConfig { applicationId = ProjectSetting.PROJECT_APP_ID @@ -97,6 +94,8 @@ android { buildFeatures { buildConfig = true viewBinding = true + compose = true + resValues = true } compileOptions { @@ -115,14 +114,32 @@ 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(libs.androidx.work.ktx) implementation(libs.google.hilt) + // Room & Coroutines + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.github.glide.compiler) ksp(libs.google.hilt.compiler) ksp(libs.androidx.room.compiler) ksp(libs.androidx.lifecycle.compiler) + testImplementation(libs.junit) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 625d548..30cf068 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,7 +14,7 @@ android:label="@string/app_name" android:roundIcon="@drawable/ic_frogobox" android:supportsRtl="true" - android:theme="@style/Theme.Research" + android:theme="@style/Theme.ResearchNoActionBar" tools:targetApi="tiramisu"> + + diff --git a/app/src/main/assets/text/dictionary_en.txt b/app/src/main/assets/text/dictionary_en.txt new file mode 100644 index 0000000..c97d0d0 --- /dev/null +++ b/app/src/main/assets/text/dictionary_en.txt @@ -0,0 +1,895 @@ +the +be +to +of +and +a +in +that +have +i +it +for +not +on +with +he +as +you +do +at +this +but +his +by +from +they +we +say +her +she +or +an +will +my +one +all +would +there +their +what +so +up +out +if +about +who +get +which +go +me +when +make +can +like +time +no +just +him +know +take +people +into +year +your +good +some +could +them +see +other +than +then +now +look +only +come +its +over +think +also +back +after +use +two +how +our +work +first +well +way +even +new +want +because +any +these +give +day +most +us +is +are +was +were +been +has +had +did +doing +done +having +does +said +says +saying +went +gone +going +goes +got +getting +made +making +makes +took +taking +takes +knew +knowing +knows +came +coming +comes +looked +looking +looks +thought +thinking +thinks +saw +seen +seeing +sees +wanted +wanting +wants +gave +given +giving +gives +found +finding +finds +find +tell +told +telling +tells +asked +asking +asks +ask +worked +working +works +called +calling +calls +call +felt +feeling +feels +feel +tried +trying +tries +try +left +leaving +leaves +leave +helped +helping +helps +help +talked +talking +talks +talk +turned +turning +turns +turn +started +starting +starts +start +showed +showing +shows +show +heard +hearing +hears +hear +played +playing +plays +play +ran +running +runs +run +moved +moving +moves +move +liked +liking +likes +lived +living +lives +live +believed +believing +believes +believe +held +holding +holds +hold +brought +bringing +brings +bring +happened +happening +happens +happen +wrote +written +writing +writes +write +provided +providing +provides +provide +sat +sitting +sits +sit +stood +standing +stands +stand +lost +losing +loses +lose +paid +paying +pays +pay +met +meeting +meets +meet +included +including +includes +include +continued +continuing +continues +continue +set +setting +sets +learned +learning +learns +learn +changed +changing +changes +change +led +leading +leads +lead +understood +understanding +understands +understand +watched +watching +watches +watch +followed +following +follows +follow +stopped +stopping +stops +stop +created +creating +creates +create +spoke +spoken +speaking +speaks +speak +read +reading +reads +allowed +allowing +allows +allow +added +adding +adds +add +spent +spending +spends +spend +grew +growing +grows +grow +opened +opening +opens +open +walked +walking +walks +walk +won +winning +wins +win +offered +offering +offers +offer +remembered +remembering +remembers +remember +loved +loving +loves +love +considered +considering +considers +consider +appeared +appearing +appears +appear +bought +buying +buys +buy +waited +waiting +waits +wait +served +serving +serves +serve +died +dying +dies +die +sent +sending +sends +send +expected +expecting +expects +expect +built +building +builds +build +stayed +staying +stays +stay +fell +fallen +falling +falls +fall +cut +cutting +cuts +reached +reaching +reaches +reach +killed +killing +kills +kill +remained +remaining +remains +remain +suggest +suggested +suggesting +suggests +suggestion +suggestions +predict +predicted +predicting +predicts +prediction +predictions +correct +corrected +correcting +corrects +correction +autocorrect +keyboard +keyboards +keypad +typing +type +typed +types +android +google +kotlin +compose +developer +development +device +screen +layout +interface +system +mobile +application +software +hardware +computer +phone +message +messages +text +texts +notification +settings +toggle +feature +features +theme +color +background +button +input +connection +word +words +string +char +character +dictionary +search +query +result +results +candidate +candidates +hello +hi +hey +welcome +thanks +thank +please +sorry +excuse +morning +afternoon +evening +night +today +tomorrow +yesterday +weekend +monday +tuesday +wednesday +thursday +friday +saturday +sunday +january +february +march +april +may +june +july +august +september +october +november +december +home +office +school +university +college +student +teacher +friend +friends +family +brother +sister +father +mother +parent +child +children +person +people +group +team +company +business +market +product +service +money +price +order +cart +customer +client +support +account +user +password +login +signup +register +profile +setting +privacy +security +policy +terms +about +contact +info +information +help +faq +feedback +review +rating +star +stars +share +download +install +update +version +release +build +test +testing +tested +tester +bug +issue +fix +fixed +fixing +error +warning +exception +crash +failed +passed +success +successful +status +check +active +enable +disabled +enabled +disable +open +close +closed +cancel +confirm +accept +delete +remove +edit +editor +save +saved +saving +copy +paste +cut +select +all +none +clear +done +next +previous +back +forward +up +down +left +right +center +middle +top +bottom +start +end +first +last +min +max +small +medium +large +huge +fast +slow +easy +hard +simple +complex +clean +clear +true +false +yes +no +ok +okay +fine +great +awesome +cool +nice +super +wonderful +amazing +excellent +perfect +best +better +worse +worst +high +higher +highest +low +lower +lowest +long +longer +longest +short +shorter +shortest +big +bigger +biggest +little +few +many +much +more +less +most +least +both +either +neither +each +every +another +such +same +different +important +real +possible +able +available +public +private +personal +general +special +main +major +common +current +recent +popular +hot +new +old +young +free +busy +ready +sure +certain +safe +secure +strong +weak +rich +poor +happy +glad +sad +angry +excited +bored +tired +hungry +fresh +warm +hot +cold +cool +bright +dark +light +heavy +blue +green +red +yellow +orange +purple +pink +black +white +gray +brown +gold +silver +apple +orange +banana +fruit +coffee +tea +water +food +dinner +lunch +breakfast +restaurant +store +shop +game +movie +music +song +video +photo +picture +image +camera +audio +sound +news +web +website +internet +network +online +offline +link +page +post +comment +like +follow +view +play +pause +stop +record +player +podcast +book +read +write +note +notes +document +file +folder +data +database +table +list +grid +item +items +header +footer +dialog +sheet +tab +tabs +box +card +panel +window +view +viewmodel +activity +fragment +adapter +service +receiver +provider +broadcast +intent +permission +location +map +navigation +route +distance +speed +time +hour +minute +second +clock +alarm +calendar +event +date +weather +cloud +rain +sun +wind +snow +travel +trip +flight +hotel +car +bus +train +plane +ticket +city +country +place +world +earth +space +star +moon diff --git a/app/src/main/java/com/frogobox/appkeyboard/MainApp.kt b/app/src/main/java/com/frogobox/appkeyboard/MainApp.kt index cc39066..68cfb5e 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/MainApp.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/MainApp.kt @@ -1,12 +1,13 @@ package com.frogobox.appkeyboard import android.content.Context -import android.os.Build +import androidx.core.os.ConfigurationCompat import androidx.emoji2.bundled.BundledEmojiCompatConfig import androidx.emoji2.text.EmojiCompat import com.frogobox.sdk.FrogoApplication import dagger.hilt.android.HiltAndroidApp import java.util.Locale +import java.util.concurrent.Executors /** * Created by Faisal Amir on 24/10/22 @@ -28,11 +29,7 @@ class MainApp : FrogoApplication() { fun getContext(): Context = instance.applicationContext fun getCurrentLocale(): Locale? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - instance.resources.configuration.locales[0] - } else { - instance.resources.configuration.locale - } + return ConfigurationCompat.getLocales(instance.resources.configuration)[0] } } @@ -44,7 +41,7 @@ class MainApp : FrogoApplication() { } private fun setupEmojiCompat() { - val config = BundledEmojiCompatConfig(this) + val config = BundledEmojiCompatConfig(this, Executors.newSingleThreadExecutor()) EmojiCompat.init(config) } diff --git a/app/src/main/java/com/frogobox/appkeyboard/common/base/BaseComposeActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/common/base/BaseComposeActivity.kt new file mode 100644 index 0000000..f93185e --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/common/base/BaseComposeActivity.kt @@ -0,0 +1,31 @@ +package com.frogobox.appkeyboard.common.base + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import com.frogobox.appkeyboard.BuildConfig +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.sdk.view.FrogoActivity + +/** + * BaseComposeActivity provides a clean, unified base for Jetpack Compose screens + * while maintaining compatibility with Frogo SDK's toolbar & debug setup. + */ +abstract class BaseComposeActivity : FrogoActivity() { + + override fun setupDebugMode(): Boolean { + return BuildConfig.DEBUG + } + + override fun onCreateExt(savedInstanceState: Bundle?) { + super.onCreateExt(savedInstanceState) + setContent { + FrogoKeyboardTheme { + Content() + } + } + } + + @Composable + abstract fun Content() +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/common/base/BaseComposeMainActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/common/base/BaseComposeMainActivity.kt new file mode 100644 index 0000000..40cafca --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/common/base/BaseComposeMainActivity.kt @@ -0,0 +1,31 @@ +package com.frogobox.appkeyboard.common.base + +import com.frogobox.sdk.delegate.piracy.PiracyCallback +import com.frogobox.sdk.delegate.piracy.PiracyDelegates +import com.frogobox.sdk.delegate.piracy.PiracyDelegatesImpl +import com.frogobox.sdk.delegate.piracy.PiracyMessage +import com.frogobox.sdk.ext.openPlayStore + +/** + * BaseComposeMainActivity provides Compose baseline combined with Frogo SDK PiracyDelegates. + */ +abstract class BaseComposeMainActivity : BaseComposeActivity(), + PiracyDelegates by PiracyDelegatesImpl() { + + override fun setupDelegates() { + super.setupDelegates() + setupPiracyDelegate(this, this) + } + + override fun setupPiracyMode() { + connectPiracyChecker(object : PiracyCallback { + override fun doOnPirated(message: PiracyMessage) { + showPiracedDialog(message, object : PiracyCallback { + override fun doOnPirated(message: PiracyMessage) { + openPlayStore(packageName) + } + }) + } + }) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/common/ext/DateExt.kt b/app/src/main/java/com/frogobox/appkeyboard/common/ext/DateExt.kt index 7fad923..c4521a7 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/common/ext/DateExt.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/common/ext/DateExt.kt @@ -66,9 +66,10 @@ const val DAY_WITH_DATE_TIME_LOCALE_FULL = "EEEE, dd MMMM yyyy HH:mm" // Senin, if (date != null) { try { - timestamp = df.parse(date).time / 1000 + timestamp = df.parse(date)?.time?.div(1000) ?: (Date().time / 1000) } catch (e: ParseException) { e.printStackTrace() + timestamp = Date().time / 1000 } } else { @@ -98,25 +99,39 @@ const val DAY_WITH_DATE_TIME_LOCALE_FULL = "EEEE, dd MMMM yyyy HH:mm" // Senin, } fun convertDateNewFormat(string: String?): String { - val formatter = SimpleDateFormat(DATE_ENGLISH_YYYY_MM_DD, Locale.getDefault()) - val date = formatter.parse(string) as Date - val newFormat = SimpleDateFormat(DATE_DD_MM_YYYY, Locale("EN")) - return newFormat.format(date) + if (string == null) return "" + return try { + val formatter = SimpleDateFormat(DATE_ENGLISH_YYYY_MM_DD, Locale.getDefault()) + val date = formatter.parse(string) ?: return "" + val newFormat = SimpleDateFormat(DATE_DD_MM_YYYY, Locale.ENGLISH) + newFormat.format(date) + } catch (e: Exception) { + "" + } } fun convertLongDateNewFormat(string: String?): String { - val formatter = SimpleDateFormat(DATE_TIME_STANDARD, Locale.getDefault()) - val date = formatter.parse(string) as Date - val newFormat = SimpleDateFormat("dd-MM-yy HH:mm:ss", Locale("EN")) - return newFormat.format(date) + if (string == null) return "" + return try { + val formatter = SimpleDateFormat(DATE_TIME_STANDARD, Locale.getDefault()) + val date = formatter.parse(string) ?: return "" + val newFormat = SimpleDateFormat("dd-MM-yy HH:mm:ss", Locale.ENGLISH) + newFormat.format(date) + } catch (e: Exception) { + "" + } } fun revertFromLongDateNewFormat(string: String?): String { - val formatter = SimpleDateFormat("dd-MM-yy HH:mm:ss", Locale("EN")) - val date = formatter.parse(string) as Date - val newFormat = SimpleDateFormat(DATE_TIME_STANDARD, Locale.getDefault()) - val finalString = newFormat.format(date) - return finalString + if (string == null) return "" + return try { + val formatter = SimpleDateFormat("dd-MM-yy HH:mm:ss", Locale.ENGLISH) + val date = formatter.parse(string) ?: return "" + val newFormat = SimpleDateFormat(DATE_TIME_STANDARD, Locale.getDefault()) + newFormat.format(date) + } catch (e: Exception) { + "" + } } fun convertTargetDate(string: String?): String { diff --git a/app/src/main/java/com/frogobox/appkeyboard/data/local/autotext/AutoTextDao.kt b/app/src/main/java/com/frogobox/appkeyboard/data/local/autotext/AutoTextDao.kt index 1fa19d7..af93df6 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/data/local/autotext/AutoTextDao.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/data/local/autotext/AutoTextDao.kt @@ -8,50 +8,50 @@ package com.frogobox.appkeyboard.data.local.autotext import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert +import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import com.frogobox.appkeyboard.model.AutoTextEntity import com.frogobox.appkeyboard.model.AutoTextLabelType -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.flow.Flow @Dao interface AutoTextDao { - @Insert - fun insert(autoText: AutoTextEntity) : Completable + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(autoText: AutoTextEntity) - @Insert - fun insert(autoTexts: List) : Completable + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(autoTexts: List) @Update - fun update(autoText: AutoTextEntity) : Completable + suspend fun update(autoText: AutoTextEntity) @Delete - fun delete(autoText: AutoTextEntity) : Completable + suspend fun delete(autoText: AutoTextEntity) - @Query("DELETE FROM auto_text WHERE id in (:idList)") - fun delete(idList: List): Completable + @Query("DELETE FROM auto_text WHERE id IN (:idList)") + suspend fun delete(idList: List) @Query("DELETE FROM auto_text") - fun nukeData(): Completable + suspend fun nukeData() @Query("SELECT * FROM auto_text ORDER BY id ASC") - fun getAll() : Single> + fun getAll(): Flow> @Query("SELECT * FROM auto_text WHERE id = :id") - fun getById(id: Int) : Single> + fun getById(id: Int): Flow @Query("SELECT * FROM auto_text WHERE title LIKE '%' || :search || '%' ORDER BY id ASC") - fun getByTitle(search: String): Single> + fun getByTitle(search: String): Flow> - @Query("SELECT * FROM auto_text WHERE label = :search ORDER BY id ASC") - fun getByLabel(search: AutoTextLabelType) : Single> + @Query("SELECT * FROM auto_text WHERE label = :label ORDER BY id ASC") + fun getByLabel(label: AutoTextLabelType): Flow> @Query("SELECT * FROM auto_text WHERE body LIKE '%' || :search || '%' ORDER BY id ASC") - fun getByBody(search: String) : Single> + fun getByBody(search: String): Flow> @Query("SELECT * FROM auto_text WHERE (title LIKE '%' || :search || '%' OR body LIKE '%' || :search || '%') ORDER BY id ASC") - fun getByTitleOrBody(search: String): Single> + fun getByTitleOrBody(search: String): Flow> } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/data/local/db/AppDatabase.kt b/app/src/main/java/com/frogobox/appkeyboard/data/local/db/AppDatabase.kt index 89ff9c9..2b8e0c5 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/data/local/db/AppDatabase.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/data/local/db/AppDatabase.kt @@ -40,7 +40,7 @@ abstract class AppDatabase : RoomDatabase() { private fun buildDatabase(context: Context): AppDatabase { return if (BuildConfig.DEBUG) { Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, DATABASE_NAME) - .fallbackToDestructiveMigration() // FOR DEVELOPMENT ONLY !!!! + .fallbackToDestructiveMigration(dropAllTables = true) // FOR DEVELOPMENT ONLY !!!! .build() } else { Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, DATABASE_NAME) diff --git a/app/src/main/java/com/frogobox/appkeyboard/data/remote/ApiService.kt b/app/src/main/java/com/frogobox/appkeyboard/data/remote/ApiService.kt index 54b8348..5f92e83 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/data/remote/ApiService.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/data/remote/ApiService.kt @@ -7,7 +7,6 @@ import com.chuckerteam.chucker.api.RetentionManager import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit -import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit @@ -54,7 +53,6 @@ object ApiService { return Retrofit.Builder() .baseUrl(baseUrl) .addConverterFactory(GsonConverterFactory.create()) - .addCallAdapterFactory(RxJava3CallAdapterFactory.create()) .client(client) .build() .create(T::class.java) diff --git a/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardFeatureType.kt b/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardFeatureType.kt index a727add..0cba63f 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardFeatureType.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardFeatureType.kt @@ -20,6 +20,7 @@ enum class KeyboardFeatureType(val id: String, val text: String, val icon: Int) TEMPLATE_TEXT_SALE("menu_template_text_sale", "Sale Admin", R.drawable.ic_menu_ps_sale), TEMPLATE_TEXT_LOVE("menu_template_text_love", "Love Emoji", R.drawable.ic_menu_ps_love), TEMPLATE_TEXT_GREETING("menu_template_text_greeting", "Greeting", R.drawable.ic_menu_ps_greeting), + SUGGESTION("menu_suggestion", "Word Suggestion", R.drawable.ic_menu_suggestion), CHANGE_KEYBOARD("menu_change_keyboard", "Change Keyboard", R.drawable.ic_menu_keyboard), SETTING("menu_setting", "Setting", R.drawable.ic_menu_setting); diff --git a/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardThemeType.kt b/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardThemeType.kt index c65373c..8143679 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardThemeType.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardThemeType.kt @@ -21,12 +21,17 @@ enum class KeyboardThemeType( private val themeType: ThemeType, private val background: Int, ) { - DEFAULT("Default", "Default Color", ThemeType.COLOR, R.color.color_bg_keyboard_default), - RED("Red", "Red Color", ThemeType.COLOR, R.color.color_bg_keyboard_red), - GREEN("Green", "Green Color", ThemeType.COLOR, R.color.color_bg_keyboard_green), - YELLOW("Yellow", "Yellow Color", ThemeType.COLOR, R.color.color_bg_keyboard_yellow), - BLUE("Blue", "Blue Color", ThemeType.COLOR, R.color.color_bg_keyboard_blue), - IMAGE_BG_DARK("Image", "Sample Wallpaper", ThemeType.IMAGE, R.drawable.ic_wallpaper_dummy); + DEFAULT("Default", "Classic Adaptive", ThemeType.COLOR, R.color.color_bg_keyboard_default), + PURPLE("Frogo Purple", "Signature Brand", ThemeType.COLOR, R.color.color_bg_keyboard_purple), + DARK("Midnight AMOLED", "Deep OLED Black", ThemeType.COLOR, R.color.color_bg_keyboard_dark), + BLUE("Ocean Blue", "Calm & Focused", ThemeType.COLOR, R.color.color_bg_keyboard_blue), + GREEN("Forest Emerald", "Natural Harmony", ThemeType.COLOR, R.color.color_bg_keyboard_green), + RED("Crimson Sunset", "Vibrant Warmth", ThemeType.COLOR, R.color.color_bg_keyboard_red), + ORANGE("Sunset Orange", "Energetic Twilight", ThemeType.COLOR, R.color.color_bg_keyboard_orange), + CYAN("Nordic Cyan", "Fresh & Clean", ThemeType.COLOR, R.color.color_bg_keyboard_cyan), + PINK("Sakura Pink", "Aesthetic Pastel", ThemeType.COLOR, R.color.color_bg_keyboard_pink), + YELLOW("Amber Gold", "Golden Accent", ThemeType.COLOR, R.color.color_bg_keyboard_yellow), + IMAGE_BG_DARK("Wallpaper", "Sample Artwork", ThemeType.IMAGE, R.drawable.ic_wallpaper_dummy); fun mapToModel(): KeyboardThemeModel { return KeyboardThemeModel( diff --git a/app/src/main/java/com/frogobox/appkeyboard/repository/autotext/AutoTextRepository.kt b/app/src/main/java/com/frogobox/appkeyboard/repository/autotext/AutoTextRepository.kt index 526afe4..7097785 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/repository/autotext/AutoTextRepository.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/repository/autotext/AutoTextRepository.kt @@ -4,7 +4,7 @@ import com.frogobox.appkeyboard.common.callback.DataResponseCallback import com.frogobox.appkeyboard.common.callback.StateResponseCallback import com.frogobox.appkeyboard.model.AutoTextEntity import com.frogobox.appkeyboard.model.AutoTextLabelType - +import kotlinx.coroutines.flow.Flow /** * Created by Faisal Amir on 25/11/22 @@ -13,6 +13,28 @@ import com.frogobox.appkeyboard.model.AutoTextLabelType interface AutoTextRepository { + fun getAutoText(): Flow> + + fun getAutoTextByLabel(label: AutoTextLabelType): Flow> + + fun getAutoTextByTitle(title: String): Flow> + + fun getAutoTextByBody(body: String): Flow> + + fun getAutoTextByTitleOrBody(keyword: String): Flow> + + suspend fun insertAutoText(autoText: AutoTextEntity) + + suspend fun insertAutoText(autoTexts: List) + + suspend fun updateAutoText(autoText: AutoTextEntity) + + suspend fun deleteAutoText(autoText: AutoTextEntity) + + suspend fun deleteAutoText(idList: List) + + suspend fun nukeAutoText() + fun getAutoText(callback: DataResponseCallback>) fun getAutoTextByLabel(label: AutoTextLabelType, callback: DataResponseCallback>) diff --git a/app/src/main/java/com/frogobox/appkeyboard/repository/autotext/AutoTextRepositoryImpl.kt b/app/src/main/java/com/frogobox/appkeyboard/repository/autotext/AutoTextRepositoryImpl.kt index 0c23b0a..5386eb8 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/repository/autotext/AutoTextRepositoryImpl.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/repository/autotext/AutoTextRepositoryImpl.kt @@ -5,10 +5,12 @@ import com.frogobox.appkeyboard.common.callback.StateResponseCallback import com.frogobox.appkeyboard.data.local.autotext.AutoTextDao import com.frogobox.appkeyboard.model.AutoTextEntity import com.frogobox.appkeyboard.model.AutoTextLabelType -import com.frogobox.sdk.ext.executeRoomDB -import com.frogobox.sdk.ext.fetchRoomDB -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.disposables.Disposable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject /** @@ -16,24 +18,84 @@ import javax.inject.Inject * https://github.com/amirisback */ - class AutoTextRepositoryImpl @Inject constructor( private val dao: AutoTextDao, ) : AutoTextRepository { - private val compositeDisposable: CompositeDisposable by lazy { CompositeDisposable() } + override fun getAutoText(): Flow> { + return dao.getAll().flowOn(Dispatchers.IO) + } + + override fun getAutoTextByLabel(label: AutoTextLabelType): Flow> { + return dao.getByLabel(label).flowOn(Dispatchers.IO) + } + + override fun getAutoTextByTitle(title: String): Flow> { + return dao.getByTitle(title).flowOn(Dispatchers.IO) + } + + override fun getAutoTextByBody(body: String): Flow> { + return dao.getByBody(body).flowOn(Dispatchers.IO) + } + + override fun getAutoTextByTitleOrBody(keyword: String): Flow> { + return dao.getByTitleOrBody(keyword).flowOn(Dispatchers.IO) + } + + override suspend fun insertAutoText(autoText: AutoTextEntity) { + withContext(Dispatchers.IO) { + dao.insert(autoText) + } + } + + override suspend fun insertAutoText(autoTexts: List) { + withContext(Dispatchers.IO) { + dao.insert(autoTexts) + } + } + + override suspend fun updateAutoText(autoText: AutoTextEntity) { + withContext(Dispatchers.IO) { + dao.update(autoText) + } + } - fun onClearDisposables() { - compositeDisposable.clear() + override suspend fun deleteAutoText(autoText: AutoTextEntity) { + withContext(Dispatchers.IO) { + dao.delete(autoText) + } } - fun addSubscribe(disposable: Disposable) { - compositeDisposable.add(disposable) + override suspend fun deleteAutoText(idList: List) { + withContext(Dispatchers.IO) { + dao.delete(idList) + } + } + + override suspend fun nukeAutoText() { + withContext(Dispatchers.IO) { + dao.nukeData() + } } override fun getAutoText(callback: DataResponseCallback>) { - dao.getAll().fetchRoomDB(callback) { - addSubscribe(it) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.getAll().collect { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess(it) + callback.onFinish() + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } } } @@ -41,8 +103,23 @@ class AutoTextRepositoryImpl @Inject constructor( label: AutoTextLabelType, callback: DataResponseCallback>, ) { - dao.getByLabel(label).fetchRoomDB(callback) { - addSubscribe(it) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.getByLabel(label).collect { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess(it) + callback.onFinish() + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } } } @@ -50,8 +127,23 @@ class AutoTextRepositoryImpl @Inject constructor( title: String, callback: DataResponseCallback>, ) { - dao.getByTitle(title).fetchRoomDB(callback) { - addSubscribe(it) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.getByTitle(title).collect { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess(it) + callback.onFinish() + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } } } @@ -59,8 +151,23 @@ class AutoTextRepositoryImpl @Inject constructor( body: String, callback: DataResponseCallback>, ) { - dao.getByBody(body).fetchRoomDB(callback) { - addSubscribe(it) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.getByBody(body).collect { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess(it) + callback.onFinish() + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } } } @@ -68,33 +175,144 @@ class AutoTextRepositoryImpl @Inject constructor( keyword: String, callback: DataResponseCallback>, ) { - dao.getByTitleOrBody(keyword).fetchRoomDB(callback) { - addSubscribe(it) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.getByTitleOrBody(keyword).collect { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess(it) + callback.onFinish() + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } } } override fun insertAutoText(autoText: AutoTextEntity, callback: StateResponseCallback) { - dao.insert(autoText).executeRoomDB(callback) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.insert(autoText) + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess() + callback.onFinish() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } + } } override fun insertAutoText(autoTexts: List, callback: StateResponseCallback) { - dao.insert(autoTexts).executeRoomDB(callback) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.insert(autoTexts) + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess() + callback.onFinish() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } + } } override fun updateAutoText(autoText: AutoTextEntity, callback: StateResponseCallback) { - dao.update(autoText).executeRoomDB(callback) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.update(autoText) + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess() + callback.onFinish() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } + } } override fun deleteAutoText(autoText: AutoTextEntity, callback: StateResponseCallback) { - dao.delete(autoText).executeRoomDB(callback) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.delete(autoText) + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess() + callback.onFinish() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } + } } override fun deleteAutoText(idList: List, callback: StateResponseCallback) { - dao.delete(idList).executeRoomDB(callback) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.delete(idList) + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess() + callback.onFinish() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } + } } override fun nukeAutoText(callback: StateResponseCallback) { - dao.nukeData().executeRoomDB(callback) + callback.onShowProgress() + CoroutineScope(Dispatchers.IO).launch { + try { + dao.nukeData() + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onSuccess() + callback.onFinish() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback.onHideProgress() + callback.onFailed(-1, e.localizedMessage ?: "Unknown Error") + callback.onFinish() + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt index 74ccc37..5276f7a 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt @@ -5,6 +5,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager import android.widget.EditText import com.frogobox.appkeyboard.R @@ -15,6 +16,9 @@ import com.frogobox.appkeyboard.model.KeyboardFeatureType import com.frogobox.appkeyboard.model.ThemeType import com.frogobox.appkeyboard.ui.main.MainActivity import com.frogobox.libkeyboard.common.core.BaseKeyboardIME +import com.frogobox.libkeyboard.common.sound.MechanicalSoundManager +import com.frogobox.libkeyboard.common.sound.MechanicalSoundType +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard import com.frogobox.recycler.core.FrogoRecyclerNotifyListener import com.frogobox.recycler.core.IFrogoBindingAdapter import com.frogobox.recycler.ext.injectorBinding @@ -22,6 +26,7 @@ import com.frogobox.sdk.delegate.preference.PreferenceDelegates import com.frogobox.sdk.ext.getColorExt import com.frogobox.sdk.ext.gone import com.frogobox.sdk.ext.invisible +import com.frogobox.appkeyboard.suggestion.WordSuggestionEngine import com.frogobox.sdk.ext.visible import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -36,10 +41,145 @@ class KeyboardIME : BaseKeyboardIME() { @Inject lateinit var keyboardUtil: KeyboardUtil + @Inject + lateinit var suggestionEngine: WordSuggestionEngine + override fun setupViewBinding(): KeyboardImeBinding { return KeyboardImeBinding.inflate(LayoutInflater.from(this), null, false) } + private val featureKeyboardCallback = object : + IFrogoBindingAdapter { + + override fun areContentsTheSame( + oldItem: KeyboardFeatureModel, + newItem: KeyboardFeatureModel + ): Boolean { + return oldItem == newItem + } + + override fun areItemsTheSame( + oldItem: KeyboardFeatureModel, + newItem: KeyboardFeatureModel + ): Boolean { + return oldItem.id == newItem.id + } + + override fun setViewBinding(parent: ViewGroup): ItemKeyboardHeaderBinding { + return ItemKeyboardHeaderBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + } + + override fun setupInitComponent( + binding: ItemKeyboardHeaderBinding, + data: KeyboardFeatureModel, + position: Int, + notifyListener: FrogoRecyclerNotifyListener, + ) { + binding.ivIcon.setImageResource(data.icon) + binding.tvTitle.text = data.text + + if (getStateToggle(data.id)) { + binding.root.visible() + } else { + binding.root.gone() + } + + } + + override fun onItemClicked( + binding: ItemKeyboardHeaderBinding, + data: KeyboardFeatureModel, + position: Int, + notifyListener: FrogoRecyclerNotifyListener, + ) { + + when (KeyboardFeatureType.from(data.id)) { + KeyboardFeatureType.NEWS -> { + hideMainKeyboard() + this@KeyboardIME.binding?.keyboardNews?.visible() + } + + KeyboardFeatureType.MOVIE -> { + hideMainKeyboard() + this@KeyboardIME.binding?.keyboardMoview?.visible() + } + + KeyboardFeatureType.WEB -> { + this@KeyboardIME.binding?.keyboardHeader?.gone() + this@KeyboardIME.binding?.keyboardWebview?.visible() + } + + KeyboardFeatureType.FORM -> { + this@KeyboardIME.binding?.keyboardHeader?.gone() + this@KeyboardIME.binding?.keyboardForm?.visible() + this@KeyboardIME.binding?.keyboardForm?.binding?.etText?.showKeyboardExt() + this@KeyboardIME.binding?.keyboardForm?.binding?.etText2?.showKeyboardExt() + this@KeyboardIME.binding?.keyboardForm?.binding?.etText3?.showKeyboardExt() + + this@KeyboardIME.binding?.keyboardForm?.setOnClickListener { + hideOnlyKeyboard() + } + } + + KeyboardFeatureType.AUTO_TEXT -> { + hideMainKeyboard() + this@KeyboardIME.binding?.keyboardAutotext?.visible() + } + + KeyboardFeatureType.TEMPLATE_TEXT_GAME -> { + hideMainKeyboard() + this@KeyboardIME.binding?.keyboardTemplateText?.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_GAME) + this@KeyboardIME.binding?.keyboardTemplateText?.visible() + } + + KeyboardFeatureType.TEMPLATE_TEXT_APP -> { + hideMainKeyboard() + this@KeyboardIME.binding?.keyboardTemplateText?.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_APP) + this@KeyboardIME.binding?.keyboardTemplateText?.visible() + } + + KeyboardFeatureType.TEMPLATE_TEXT_SALE -> { + hideMainKeyboard() + this@KeyboardIME.binding?.keyboardTemplateText?.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_SALE) + this@KeyboardIME.binding?.keyboardTemplateText?.visible() + } + + KeyboardFeatureType.TEMPLATE_TEXT_LOVE -> { + hideMainKeyboard() + this@KeyboardIME.binding?.keyboardTemplateText?.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_LOVE) + this@KeyboardIME.binding?.keyboardTemplateText?.visible() + } + + KeyboardFeatureType.TEMPLATE_TEXT_GREETING -> { + hideMainKeyboard() + this@KeyboardIME.binding?.keyboardTemplateText?.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_GREETING) + this@KeyboardIME.binding?.keyboardTemplateText?.visible() + } + + KeyboardFeatureType.SUGGESTION -> { + this@KeyboardIME.showSuggestionBar() + } + + KeyboardFeatureType.CHANGE_KEYBOARD -> { + (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker() + } + + KeyboardFeatureType.SETTING -> { + binding.root.context.startActivity(Intent( + binding.root.context, MainActivity::class.java + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }) + } + + } + + } + + } + override fun setupTheme() { binding?.apply { @@ -48,25 +188,35 @@ class KeyboardIME : BaseKeyboardIME() { R.color.color_bg_keyboard_default ) - val backgroundType = ThemeType.valueOf( - pref.getPrefString( - KeyboardUtil.KEYBOARD_COLOR_TYPE, - ThemeType.COLOR.name - ) + val typeString = pref.getPrefString( + KeyboardUtil.KEYBOARD_COLOR_TYPE, + ThemeType.COLOR.name ) + val backgroundType = runCatching { + ThemeType.valueOf(typeString) + }.getOrDefault(ThemeType.COLOR) + when (backgroundType) { ThemeType.COLOR -> { + ivBackgroundKeyboard.setImageDrawable(null) ivBackgroundKeyboard.setBackgroundColor(getColorExt(background)) } ThemeType.IMAGE -> { + ivBackgroundKeyboard.setBackgroundColor(android.graphics.Color.TRANSPARENT) ivBackgroundKeyboard.setImageResource(background) } } } } + override fun onWindowShown() { + super.onWindowShown() + applySoundAndHapticSettings() + } + override fun initialSetupKeyboard() { + applySoundAndHapticSettings() binding?.keyboardMain?.setKeyboard(keyboard!!) } @@ -100,12 +250,14 @@ class KeyboardIME : BaseKeyboardIME() { binding?.apply { keyboardMain.invisible() keyboardHeader.invisible() + keyboardSuggestion.gone() } } override fun showMainKeyboard() { binding?.apply { keyboardMain.visible() + keyboardSuggestion.gone() if (keyboardUtil.menuKeyboard().isEmpty()) { keyboardHeader.gone() } else { @@ -197,170 +349,107 @@ class KeyboardIME : BaseKeyboardIME() { } else { keyboardHeader.visible() keyboardHeader.injectorBinding() - .addData(keyboardUtil.menuKeyboard()).addCallback(object : - IFrogoBindingAdapter { - - override fun areContentsTheSame( - oldItem: KeyboardFeatureModel, - newItem: KeyboardFeatureModel - ): Boolean { - return oldItem == newItem - } - - override fun areItemsTheSame( - oldItem: KeyboardFeatureModel, - newItem: KeyboardFeatureModel - ): Boolean { - return oldItem.id == newItem.id - } - - override fun setViewBinding(parent: ViewGroup): ItemKeyboardHeaderBinding { - return ItemKeyboardHeaderBinding.inflate( - LayoutInflater.from(parent.context), parent, false - ) - } - - override fun setupInitComponent( - binding: ItemKeyboardHeaderBinding, - data: KeyboardFeatureModel, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - binding.ivIcon.setImageResource(data.icon) - binding.tvTitle.text = data.text - - if (getStateToggle(data.id)) { - binding.root.visible() - } else { - binding.root.gone() - } - - } - - override fun onItemClicked( - binding: ItemKeyboardHeaderBinding, - data: KeyboardFeatureModel, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - - when (KeyboardFeatureType.from(data.id)) { - KeyboardFeatureType.NEWS -> { - hideMainKeyboard() - keyboardNews.visible() - } - - KeyboardFeatureType.MOVIE -> { - hideMainKeyboard() - keyboardMoview.visible() - } - - KeyboardFeatureType.WEB -> { - keyboardHeader.gone() - keyboardWebview.visible() - } - - KeyboardFeatureType.FORM -> { - keyboardHeader.gone() - keyboardForm.visible() - keyboardForm.binding.etText.showKeyboardExt() - keyboardForm.binding.etText2.showKeyboardExt() - keyboardForm.binding.etText3.showKeyboardExt() - - keyboardForm.setOnClickListener { - hideOnlyKeyboard() - } - } - - KeyboardFeatureType.AUTO_TEXT -> { - hideMainKeyboard() - keyboardAutotext.visible() - } - - KeyboardFeatureType.TEMPLATE_TEXT_GAME -> { - hideMainKeyboard() - keyboardTemplateText.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_GAME) - keyboardTemplateText.visible() - } - - KeyboardFeatureType.TEMPLATE_TEXT_APP -> { - hideMainKeyboard() - keyboardTemplateText.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_APP) - keyboardTemplateText.visible() - } - - KeyboardFeatureType.TEMPLATE_TEXT_SALE -> { - hideMainKeyboard() - keyboardTemplateText.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_SALE) - keyboardTemplateText.visible() - } - - KeyboardFeatureType.TEMPLATE_TEXT_LOVE -> { - hideMainKeyboard() - keyboardTemplateText.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_LOVE) - keyboardTemplateText.visible() - } - - KeyboardFeatureType.TEMPLATE_TEXT_GREETING -> { - hideMainKeyboard() - keyboardTemplateText.setupTemplateTextType(KeyboardFeatureType.TEMPLATE_TEXT_GREETING) - keyboardTemplateText.visible() - } - - KeyboardFeatureType.CHANGE_KEYBOARD -> { - (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker() - } - - KeyboardFeatureType.SETTING -> { - binding.root.context.startActivity(Intent( - binding.root.context, MainActivity::class.java - ).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - }) - } - - } - - } - - }).createLayoutGrid(gridSize).build() + .addData(keyboardUtil.menuKeyboard()) + .addCallback(featureKeyboardCallback) + .createLayoutGrid(gridSize).build() } } } - override fun onKey(code: Int) { + private fun getActiveInputConnection(): InputConnection? { val formView = binding?.keyboardForm - var inputConnection = currentInputConnection - if (formView?.visibility == View.VISIBLE) { val et1 = formView.binding.etText - val et1Connection = et1.onCreateInputConnection(EditorInfo()) - val et2 = formView.binding.etText2 - val et2Connection = et2.onCreateInputConnection(EditorInfo()) - val et3 = formView.binding.etText3 - val et3Connection = et3.onCreateInputConnection(EditorInfo()) if (et1.isFocused) { - inputConnection = et1Connection + return et1.onCreateInputConnection(EditorInfo()) } else if (et2.isFocused) { - inputConnection = et2Connection + return et2.onCreateInputConnection(EditorInfo()) } else if (et3.isFocused) { - inputConnection = et3Connection + return et3.onCreateInputConnection(EditorInfo()) } - } else if (binding?.keyboardWebview?.visibility == View.VISIBLE) { - inputConnection = - binding?.keyboardWebview?.binding?.webview?.onCreateInputConnection(EditorInfo()) - } else { - inputConnection = currentInputConnection + return binding?.keyboardWebview?.binding?.webview?.onCreateInputConnection(EditorInfo()) + } + return currentInputConnection + } + + private fun getWordBeforeCursor(ic: InputConnection): String { + val text = ic.getTextBeforeCursor(40, 0)?.toString() ?: "" + return text.takeLastWhile { it.isLetterOrDigit() || it == '\'' } + } + + private fun setupSuggestionBar() { + binding?.keyboardSuggestion?.apply { + onCandidateSelected = { selectedWord, _ -> + val ic = getActiveInputConnection() + if (ic != null) { + val currentWord = getWordBeforeCursor(ic) + if (currentWord.isNotEmpty()) { + ic.deleteSurroundingText(currentWord.length, 0) + } + ic.commitText("$selectedWord ", 1) + clearSuggestions() + showFeatureHeader() + } + } + + onSwitchMenuClicked = { + showFeatureHeader() + } + + onCloseClicked = { + clearSuggestions() + showFeatureHeader() + } + } + } + + private fun showSuggestionBar() { + binding?.apply { + keyboardHeader.gone() + keyboardSuggestion.visible() + } + } + + private fun showFeatureHeader() { + binding?.apply { + keyboardSuggestion.gone() + if (keyboardUtil.menuKeyboard().isEmpty()) { + keyboardHeader.gone() + } else { + keyboardHeader.visible() + } + } + } + + override fun onKey(code: Int) { + val ic = getActiveInputConnection() ?: return + onKeyExt(code, ic) + + if (keyboardUtil.isSuggestionEnabled()) { + val word = getWordBeforeCursor(ic) + if (word.isNotEmpty()) { + val suggestions = suggestionEngine.getSuggestions(word) + binding?.keyboardSuggestion?.setSuggestions(suggestions) + showSuggestionBar() + } else { + binding?.keyboardSuggestion?.clearSuggestions() + if (code == ItemMainKeyboard.KEYCODE_SPACE || + code == ItemMainKeyboard.KEYCODE_ENTER || + code == ItemMainKeyboard.KEYCODE_DELETE) { + showFeatureHeader() + } + } } - onKeyExt(code, inputConnection) } override fun initView() { + suggestionEngine.loadDictionaryFromAsset(this) + setupSuggestionBar() setupFeatureKeyboard() initBackToMainKeyboard() } @@ -387,4 +476,24 @@ class KeyboardIME : BaseKeyboardIME() { return pref.getPrefBoolean(key, true) } + private fun applySoundAndHapticSettings() { + val soundEnabled = pref.getPrefBoolean(MechanicalSoundManager.PREF_KEYBOARD_SOUND_ENABLED, true) + val soundType = pref.getPrefString( + MechanicalSoundManager.PREF_KEYBOARD_SOUND_TYPE, + MechanicalSoundType.CHERRY_MX_BLUE.id + ) + val soundVolumeInt = pref.getPrefInt(MechanicalSoundManager.PREF_KEYBOARD_SOUND_VOLUME, 80) + val vibrateEnabled = pref.getPrefBoolean(MechanicalSoundManager.PREF_KEYBOARD_VIBRATE_ENABLED, true) + + ItemMainKeyboard.SOUND_ON_KEYPRESS = soundEnabled + ItemMainKeyboard.MECHANICAL_SOUND_TYPE = soundType + ItemMainKeyboard.SOUND_VOLUME = (soundVolumeInt / 100f).coerceIn(0.05f, 1.0f) + ItemMainKeyboard.VIBRATE_ON_KEYPRESS = vibrateEnabled + } + + override fun onDestroy() { + super.onDestroy() + MechanicalSoundManager.getInstance(this).release() + } + } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardUtil.kt b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardUtil.kt index bc97531..62f2a3e 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardUtil.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardUtil.kt @@ -22,14 +22,20 @@ class KeyboardUtil @Inject constructor( const val KEYBOARD_TYPE = "KEYBOARD_TYPE" const val KEYBOARD_COLOR = "KEYBOARD_COLOR" const val KEYBOARD_COLOR_TYPE = "KEYBOARD_COLOR_TYPE" + const val KEYBOARD_SUGGESTION_ENABLED = "KEYBOARD_SUGGESTION_ENABLED" } private fun getStateToggle(key: String) : Boolean { return pref.getPrefBoolean(key, true) } + fun isSuggestionEnabled(): Boolean { + return getStateToggle(KeyboardFeatureType.SUGGESTION.id) + } + fun menuToggle(): List { return listOf( + KeyboardFeatureType.SUGGESTION.mapToModel(), KeyboardFeatureType.AUTO_TEXT.mapToModel(), KeyboardFeatureType.TEMPLATE_TEXT_APP.mapToModel(), KeyboardFeatureType.TEMPLATE_TEXT_GAME.mapToModel(), @@ -55,14 +61,7 @@ class KeyboardUtil @Inject constructor( } fun keyboardTheme(): List { - return listOf( - KeyboardThemeType.DEFAULT.mapToModel(), - KeyboardThemeType.RED.mapToModel(), - KeyboardThemeType.GREEN.mapToModel(), - KeyboardThemeType.YELLOW.mapToModel(), - KeyboardThemeType.BLUE.mapToModel(), - KeyboardThemeType.IMAGE_BG_DARK.mapToModel() - ) + return KeyboardThemeType.entries.map { it.mapToModel() } } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/suggestion/KeyboardSuggestionView.kt b/app/src/main/java/com/frogobox/appkeyboard/suggestion/KeyboardSuggestionView.kt new file mode 100644 index 0000000..c0a90b3 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/suggestion/KeyboardSuggestionView.kt @@ -0,0 +1,115 @@ +package com.frogobox.appkeyboard.suggestion + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.FrameLayout +import com.frogobox.appkeyboard.databinding.LayoutKeyboardSuggestionBinding +import com.frogobox.sdk.ext.gone +import com.frogobox.sdk.ext.visible + +/** + * Custom Suggestion Strip View displaying the 3 candidate sections: + * Section 1: User's typed word + * Section 2: Predicted word (central hero) + * Section 3: Auto-correct word + */ +class KeyboardSuggestionView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + val binding: LayoutKeyboardSuggestionBinding = + LayoutKeyboardSuggestionBinding.inflate(LayoutInflater.from(context), this, true) + + private var currentResult: SuggestionResult = SuggestionResult.EMPTY + + var onCandidateSelected: ((selectedWord: String, candidateType: CandidateType) -> Unit)? = null + var onSwitchMenuClicked: (() -> Unit)? = null + var onCloseClicked: (() -> Unit)? = null + + enum class CandidateType { + USER_WORD, + PREDICTED_WORD, + AUTOCORRECT_WORD + } + + init { + setupListeners() + } + + private fun setupListeners() { + binding.sectionUserWord.setOnClickListener { + val word = currentResult.userWord + if (word.isNotEmpty()) { + onCandidateSelected?.invoke(word, CandidateType.USER_WORD) + } + } + + binding.sectionPredictedWord.setOnClickListener { + val word = currentResult.predictedWord + if (word.isNotEmpty()) { + onCandidateSelected?.invoke(word, CandidateType.PREDICTED_WORD) + } + } + + binding.sectionAutocorrectWord.setOnClickListener { + val word = currentResult.autoCorrectWord + if (word.isNotEmpty()) { + onCandidateSelected?.invoke(word, CandidateType.AUTOCORRECT_WORD) + } + } + + binding.btnSwitchMenu.setOnClickListener { + onSwitchMenuClicked?.invoke() + } + + binding.btnClearSuggestion.setOnClickListener { + onCloseClicked?.invoke() + } + } + + /** + * Updates the 3 suggestion candidates in real-time + */ + fun setSuggestions(result: SuggestionResult) { + currentResult = result + + // Section 1: User's literal word + if (result.userWord.isNotEmpty()) { + binding.tvUserWord.text = "“${result.userWord}”" + binding.sectionUserWord.visible() + binding.divider1.visible() + } else { + binding.tvUserWord.text = "" + binding.sectionUserWord.gone() + binding.divider1.gone() + } + + // Section 2: Predicted word + if (result.predictedWord.isNotEmpty()) { + binding.tvPredictedWord.text = result.predictedWord + binding.sectionPredictedWord.visible() + } else { + binding.tvPredictedWord.text = "" + binding.sectionPredictedWord.gone() + } + + // Section 3: Auto-correct word + if (result.autoCorrectWord.isNotEmpty() && result.autoCorrectWord != result.predictedWord) { + binding.tvAutocorrectWord.text = result.autoCorrectWord + binding.sectionAutocorrectWord.visible() + binding.divider2.visible() + } else { + binding.tvAutocorrectWord.text = "" + binding.sectionAutocorrectWord.gone() + binding.divider2.gone() + } + } + + fun clearSuggestions() { + setSuggestions(SuggestionResult.EMPTY) + } + +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/suggestion/SuggestionResult.kt b/app/src/main/java/com/frogobox/appkeyboard/suggestion/SuggestionResult.kt new file mode 100644 index 0000000..ff20c3a --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/suggestion/SuggestionResult.kt @@ -0,0 +1,21 @@ +package com.frogobox.appkeyboard.suggestion + +/** + * Data class representing the 3-section candidate suggestion model + * satisfying GitHub Issue #50: + * - Section 1: userWord (literal verbatim word typed by user) + * - Section 2: predictedWord (highest frequency completion or contextual prediction) + * - Section 3: autoCorrectWord (best typographical correction or alternative) + */ +data class SuggestionResult( + val userWord: String = "", + val predictedWord: String = "", + val autoCorrectWord: String = "" +) { + fun hasSuggestions(): Boolean = + userWord.isNotEmpty() || predictedWord.isNotEmpty() || autoCorrectWord.isNotEmpty() + + companion object { + val EMPTY = SuggestionResult("", "", "") + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/suggestion/WordSuggestionEngine.kt b/app/src/main/java/com/frogobox/appkeyboard/suggestion/WordSuggestionEngine.kt new file mode 100644 index 0000000..85d7dde --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/suggestion/WordSuggestionEngine.kt @@ -0,0 +1,294 @@ +package com.frogobox.appkeyboard.suggestion + +import android.content.Context +import java.io.BufferedReader +import java.io.InputStreamReader +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.abs +import kotlin.math.min + +/** + * Intelligent on-device Word Suggestion & Auto-Correction Engine + * Provides sub-millisecond candidate computation satisfying GitHub Issue #50: + * - Section 1: User's typed word (verbatim) + * - Section 2: Predicted word (prefix match / completion) + * - Section 3: Auto-correct word (edit distance / typo correction) + */ +@Singleton +class WordSuggestionEngine @Inject constructor() { + + // Common instant typo and shorthand substitution mapping + private val commonTypoMap = mapOf( + "teh" to "the", + "wht" to "what", + "adn" to "and", + "dont" to "don't", + "cant" to "can't", + "wont" to "won't", + "im" to "I'm", + "ive" to "I've", + "youre" to "you're", + "theyre" to "they're", + "alot" to "a lot", + "helo" to "hello", + "hw" to "how", + "pls" to "please", + "thx" to "thanks", + "ty" to "thank you", + "u" to "you", + "r" to "are", + "ur" to "your", + "recive" to "receive", + "recieve" to "receive", + "becuase" to "because", + "definately" to "definitely", + "seperate" to "separate", + "goverment" to "government", + "occured" to "occurred", + "untill" to "until", + "beleive" to "believe", + "frogo" to "Frogo" + ) + + // In-memory frequency-ranked dictionary + private val dictionaryList = CopyOnWriteArrayList() + private val dictionarySet = ConcurrentHashMap.newKeySet() + private val userCustomWords = ConcurrentHashMap.newKeySet() + + @Volatile + private var isDictionaryLoaded = false + + init { + loadBuiltinFallbackVocabulary() + } + + /** + * Fallback high-frequency words loaded synchronously so engine works immediately + */ + private fun loadBuiltinFallbackVocabulary() { + val fallbackWords = listOf( + "the", "be", "to", "of", "and", "a", "in", "that", "have", "i", + "it", "for", "not", "on", "with", "he", "as", "you", "do", "at", + "this", "but", "his", "by", "from", "they", "we", "say", "her", "she", + "or", "an", "will", "my", "one", "all", "would", "there", "their", "what", + "so", "up", "out", "if", "about", "who", "get", "which", "go", "me", + "when", "make", "can", "like", "time", "no", "just", "him", "know", "take", + "people", "into", "year", "your", "good", "some", "could", "them", "see", "other", + "than", "then", "now", "look", "only", "come", "its", "over", "think", "also", + "back", "after", "use", "two", "how", "our", "work", "first", "well", "way", + "even", "new", "want", "because", "any", "these", "give", "day", "most", "us", + "hello", "thanks", "please", "sorry", "today", "tomorrow", "keyboard", "android", + "apple", "application", "screen", "mobile", "message", "email", "happy", "great", + "help", "home", "call", "start", "stop", "test", "ready", "super", "cool", "fine" + ) + for (w in fallbackWords) { + addWordInternal(w) + } + } + + /** + * Loads extended dictionary from assets/text/dictionary_en.txt + */ + fun loadDictionaryFromAsset(context: Context) { + if (isDictionaryLoaded) return + try { + val assetManager = context.assets + val inputStream = assetManager.open("text/dictionary_en.txt") + BufferedReader(InputStreamReader(inputStream)).use { reader -> + var line: String? = reader.readLine() + while (line != null) { + val word = line.trim().lowercase() + if (word.isNotEmpty() && !dictionarySet.contains(word)) { + addWordInternal(word) + } + line = reader.readLine() + } + } + isDictionaryLoaded = true + } catch (_: Exception) { + // Gracefully keep fallback vocabulary if asset read fails + } + } + + private fun addWordInternal(word: String) { + if (!dictionarySet.contains(word)) { + dictionarySet.add(word) + dictionaryList.add(word) + } + } + + /** + * Allows dynamically learning or adding user custom words + */ + fun addUserWord(word: String) { + val trimmed = word.trim() + if (trimmed.length > 1) { + userCustomWords.add(trimmed.lowercase()) + addWordInternal(trimmed.lowercase()) + } + } + + /** + * Core suggestion generation returning the 3 required sections: + * - Section 1: User's verbatim word + * - Section 2: Predicted completion / highest rank prediction + * - Section 3: Auto-corrected candidate or alternative suggestion + */ + fun getSuggestions(input: String): SuggestionResult { + val trimmedInput = input.trim() + if (trimmedInput.isEmpty()) { + return SuggestionResult( + userWord = "", + predictedWord = matchCasing(input, "I"), + autoCorrectWord = matchCasing(input, "The") + ) + } + + val lowerQuery = trimmedInput.lowercase() + val userWord = trimmedInput + + // 1. Direct typo replacement check + val typoMatch = commonTypoMap[lowerQuery] + + // 2. Find prefix predictions + val prefixMatches = mutableListOf() + for (word in dictionaryList) { + if (word.startsWith(lowerQuery)) { + prefixMatches.add(word) + if (prefixMatches.size >= 5) break + } + } + + // 3. Compute predicted word (Section 2) + val predictedWordRaw = when { + typoMatch != null -> typoMatch + prefixMatches.isNotEmpty() -> { + // If the exact word is typed and there is a longer continuation, use continuation + if (prefixMatches[0] == lowerQuery && prefixMatches.size > 1) { + prefixMatches[1] + } else { + prefixMatches[0] + } + } + else -> findClosestCorrection(lowerQuery) ?: lowerQuery + } + + // 4. Compute auto-correct word (Section 3) + val autoCorrectWordRaw = when { + typoMatch != null -> { + // Typo was matched, provide alternative prefix or closest candidate + prefixMatches.firstOrNull { it != lowerQuery && it != typoMatch.lowercase() } + ?: findClosestCorrection(lowerQuery, exclude = listOf(typoMatch.lowercase())) + ?: lowerQuery + } + !dictionarySet.contains(lowerQuery) -> { + // Misspelled: find best correction different from predicted + findClosestCorrection(lowerQuery, exclude = listOf(predictedWordRaw.lowercase())) + ?: prefixMatches.firstOrNull { it != predictedWordRaw.lowercase() } + ?: lowerQuery + } + prefixMatches.size > 1 -> { + // Word is valid, provide alternative candidate + val secondOption = prefixMatches.firstOrNull { it != predictedWordRaw.lowercase() && it != lowerQuery } + secondOption ?: (prefixMatches.getOrNull(1) ?: lowerQuery) + } + else -> { + findClosestCorrection(lowerQuery, exclude = listOf(predictedWordRaw.lowercase())) + ?: lowerQuery + } + } + + return SuggestionResult( + userWord = userWord, + predictedWord = matchCasing(trimmedInput, predictedWordRaw), + autoCorrectWord = matchCasing(trimmedInput, autoCorrectWordRaw) + ) + } + + /** + * Finds closest word in dictionary within Levenshtein distance <= 2 + */ + private fun findClosestCorrection(query: String, exclude: List = emptyList()): String? { + if (query.isEmpty()) return null + var bestMatch: String? = null + var minDistance = Int.MAX_VALUE + + val maxAllowedDistance = if (query.length <= 3) 1 else 2 + + for (word in dictionaryList) { + if (exclude.contains(word) || word == query) continue + + // Quick length filter: skip words with length difference > maxAllowedDistance + if (abs(word.length - query.length) > maxAllowedDistance) continue + + // Bonus heuristic: prefer words sharing the first letter + val firstCharSame = word[0] == query[0] + val distance = calculateLevenshteinDistance(query, word) + + val adjustedDistance = if (firstCharSame) distance else distance + 1 + + if (distance <= maxAllowedDistance && adjustedDistance < minDistance) { + minDistance = adjustedDistance + bestMatch = word + if (distance == 1 && firstCharSame) { + // Fast break on high-confidence match + break + } + } + } + + return bestMatch + } + + /** + * Efficient iterative Levenshtein Distance calculation + */ + fun calculateLevenshteinDistance(s1: String, s2: String): Int { + val len1 = s1.length + val len2 = s2.length + + var prev = IntArray(len2 + 1) { it } + var curr = IntArray(len2 + 1) + + for (i in 1..len1) { + curr[0] = i + for (j in 1..len2) { + val cost = if (s1[i - 1] == s2[j - 1]) 0 else 1 + curr[j] = min( + min(curr[j - 1] + 1, prev[j] + 1), + prev[j - 1] + cost + ) + } + val temp = prev + prev = curr + curr = temp + } + + return prev[len2] + } + + /** + * Adapts candidate string casing to match the user's input style: + * - UPPERCASE if input is all caps (e.g., "HEL" -> "HELLO") + * - TitleCase if input is capitalized (e.g., "Hel" -> "Hello") + * - lowercase otherwise (e.g., "hel" -> "hello") + */ + fun matchCasing(source: String, candidate: String): String { + if (source.isEmpty() || candidate.isEmpty()) return candidate + return when { + source.length > 1 && source.all { it.isLetter() && it.isUpperCase() } -> { + candidate.uppercase() + } + source.first().isUpperCase() -> { + candidate.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + } + else -> { + candidate.lowercase() + } + } + } + +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextActivity.kt index 11d60cd..12712f6 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextActivity.kt @@ -2,141 +2,76 @@ package com.frogobox.appkeyboard.ui.autotext import android.content.Intent import android.os.Bundle -import android.view.ViewGroup -import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import com.frogobox.appkeyboard.common.base.BaseActivity -import com.frogobox.appkeyboard.databinding.ActivityAutotextBinding -import com.frogobox.appkeyboard.databinding.ItemAutotextBinding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.frogobox.appkeyboard.common.base.BaseComposeActivity import com.frogobox.appkeyboard.model.AutoTextEntity import com.frogobox.coresdk.source.Resource -import com.frogobox.recycler.core.FrogoRecyclerNotifyListener -import com.frogobox.recycler.core.IFrogoBindingAdapter -import com.frogobox.recycler.ext.injectorBinding -import com.frogobox.sdk.ext.gone -import com.frogobox.sdk.ext.showLogD -import com.frogobox.sdk.ext.visible import com.google.gson.Gson import dagger.hilt.android.AndroidEntryPoint -/** - * Created by Faisal Amir on 11/03/23 - * https://github.com/amirisback - */ - - @AndroidEntryPoint -class AutoTextActivity : BaseActivity() { +class AutoTextActivity : BaseComposeActivity() { private val viewModel: AutoTextViewModel by viewModels() - override fun setupViewBinding(): ActivityAutotextBinding { - return ActivityAutotextBinding.inflate(layoutInflater) - } - - override fun setupViewModel() { - viewModel.apply { - autoText.observe(this@AutoTextActivity) { - - showLogD("${it}", "AutoText") - - when (it) { - is Resource.Error -> {} - is Resource.Loading -> {} - is Resource.Success -> { - if (it.result.isEmpty()) { - binding.emptyView.root.visible() - binding.rvAutotext.gone() - } else { - binding.emptyView.root.gone() - binding.rvAutotext.visible() - setupRvAutoText(it.result) - } - } - } - } - } - } - - override fun onCreateExt(savedInstanceState: Bundle?) { - super.onCreateExt(savedInstanceState) - setupDetailActivity("Auto Text") - setupUI() - viewModel.getAutoText() - } - - override fun setupActivityResultExt(result: ActivityResult) { - super.setupActivityResultExt(result) + private var autoTextList by mutableStateOf>(emptyList()) + private var isLoading by mutableStateOf(false) + private val activityResultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> when (result.resultCode) { - AutoTextDetailActivity.RESULT_CODE_DELETE, AutoTextEditorActivity.RESULT_CODE_UPDATE, - AutoTextEditorActivity.RESULT_CODE_ADD, - -> { + AutoTextEditorActivity.RESULT_CODE_ADD -> { viewModel.getAutoText() } - } } - private fun setupUI() { - binding.apply { - btnAdd.setOnClickListener { - startActivityResultExt(Intent(this@AutoTextActivity, AutoTextEditorActivity::class.java)) - } - } - } - - private fun setupRvAutoText(data: List) { - binding.rvAutotext.injectorBinding() - .addData(data) - .addCallback(object : IFrogoBindingAdapter { - override fun areContentsTheSame( - oldItem: AutoTextEntity, - newItem: AutoTextEntity, - ): Boolean { - return oldItem == newItem - } - - override fun areItemsTheSame( - oldItem: AutoTextEntity, - newItem: AutoTextEntity, - ): Boolean { - return oldItem.id == newItem.id - } - - override fun setViewBinding(parent: ViewGroup): ItemAutotextBinding { - return ItemAutotextBinding.inflate(layoutInflater, parent, false) + override fun onCreateExt(savedInstanceState: Bundle?) { + super.onCreateExt(savedInstanceState) + viewModel.autoText.observe(this) { resource -> + when (resource) { + is Resource.Loading -> { + isLoading = true } - - override fun setupInitComponent( - binding: ItemAutotextBinding, - data: AutoTextEntity, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - binding.tvAutotextTitle.text = data.title - binding.tvAutotextContent.text = data.body + is Resource.Success -> { + isLoading = false + autoTextList = resource.result } - - override fun onItemClicked( - binding: ItemAutotextBinding, - data: AutoTextEntity, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - val extra = Gson().toJson(data) - startActivityResultExt( - Intent(this@AutoTextActivity, AutoTextDetailActivity::class.java).apply { - putExtra(AutoTextDetailActivity.EXTRA_AUTO_TEXT, extra) - } - ) + is Resource.Error -> { + isLoading = false } - - }) - .createLayoutLinearVertical(false) - .build() + } + } + viewModel.getAutoText() } + @Composable + override fun Content() { + AutoTextScreen( + autoTextList = autoTextList, + isLoading = isLoading, + onItemClick = { data -> + val extra = Gson().toJson(data) + activityResultLauncher.launch( + Intent(this@AutoTextActivity, AutoTextDetailActivity::class.java).apply { + putExtra(AutoTextDetailActivity.EXTRA_AUTO_TEXT, extra) + } + ) + }, + onAddClick = { + activityResultLauncher.launch( + Intent(this@AutoTextActivity, AutoTextEditorActivity::class.java) + ) + }, + onBackClick = { finish() } + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailActivity.kt index 654dbf5..c3537da 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailActivity.kt @@ -2,22 +2,19 @@ package com.frogobox.appkeyboard.ui.autotext import android.content.Intent import android.os.Bundle -import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import com.frogobox.appkeyboard.common.base.BaseActivity -import com.frogobox.appkeyboard.databinding.ActivityAutotextDetailBinding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.frogobox.appkeyboard.common.base.BaseComposeActivity import com.frogobox.appkeyboard.model.AutoTextEntity import com.google.gson.Gson import dagger.hilt.android.AndroidEntryPoint -/** - * Created by Faisal Amir on 11/03/23 - * https://github.com/amirisback - */ - - @AndroidEntryPoint -class AutoTextDetailActivity : BaseActivity() { +class AutoTextDetailActivity : BaseComposeActivity() { companion object { const val EXTRA_AUTO_TEXT = "EXTRA_AUTO_TEXT" @@ -26,33 +23,29 @@ class AutoTextDetailActivity : BaseActivity() { private val viewModel: AutoTextViewModel by viewModels() - override fun setupViewModel() { - super.setupViewModel() - viewModel.eventSuccessState.observe(this) { - setResult(RESULT_CODE_DELETE) - finish() - } - } + private var currentData by mutableStateOf(AutoTextEntity()) - override fun setupViewBinding(): ActivityAutotextDetailBinding { - return ActivityAutotextDetailBinding.inflate(layoutInflater) + private val editLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == AutoTextEditorActivity.RESULT_CODE_UPDATE) { + val json = result.data?.getStringExtra(AutoTextEditorActivity.EXTRA_AUTO_TEXT_EDIT_RESULT) + if (!json.isNullOrBlank()) { + val updatedData = Gson().fromJson(json, AutoTextEntity::class.java) + if (updatedData != null) { + currentData = updatedData + } + } + } } override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) - setupDetailActivity("Detail Auto Text") - setupUI(autoText()) - } + currentData = extractAutoText() - override fun setupActivityResultExt(result: ActivityResult) { - super.setupActivityResultExt(result) - if (result.resultCode == AutoTextEditorActivity.RESULT_CODE_UPDATE) { - result.data?.getStringExtra(AutoTextEditorActivity.EXTRA_AUTO_TEXT_EDIT_RESULT) - val data = Gson().fromJson( - result.data?.getStringExtra(AutoTextEditorActivity.EXTRA_AUTO_TEXT_EDIT_RESULT), - AutoTextEntity::class.java - ) - setupUI(data) + viewModel.eventSuccessState.observe(this) { + setResult(RESULT_CODE_DELETE) + finish() } } @@ -61,42 +54,37 @@ class AutoTextDetailActivity : BaseActivity() { super.doOnBackPressedExt() } - - private fun autoText(): AutoTextEntity { + private fun extractAutoText(): AutoTextEntity { return if (intent.hasExtra(EXTRA_AUTO_TEXT)) { Gson().fromJson( intent.extras?.getString(EXTRA_AUTO_TEXT), AutoTextEntity::class.java - ) + ) ?: AutoTextEntity() } else { AutoTextEntity() } } - private fun setupUI(data: AutoTextEntity) { - binding.apply { - - tvTitle.text = data.title - tvContent.text = data.body - - btnDelete.setOnClickListener { - viewModel.deleteAutoText(data) - } - - btnEdit.setOnClickListener { - startActivityResultExt( - Intent( - this@AutoTextDetailActivity, - AutoTextEditorActivity::class.java - ).apply { + @Composable + override fun Content() { + AutoTextDetailScreen( + data = currentData, + onEditClick = { + editLauncher.launch( + Intent(this@AutoTextDetailActivity, AutoTextEditorActivity::class.java).apply { putExtra( AutoTextEditorActivity.EXTRA_AUTO_TEXT_EDIT, - Gson().toJson(data) + Gson().toJson(currentData) ) } ) + }, + onDeleteClick = { + viewModel.deleteAutoText(currentData) + }, + onBackClick = { + doOnBackPressedExt() } - } + ) } - } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailScreen.kt new file mode 100644 index 0000000..7b47779 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextDetailScreen.kt @@ -0,0 +1,429 @@ +package com.frogobox.appkeyboard.ui.autotext + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ShortText +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.model.AutoTextEntity +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusFailed +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar + +@Composable +fun AutoTextDetailScreen( + data: AutoTextEntity, + onEditClick: () -> Unit, + onDeleteClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + var showDeleteDialog by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + val context = LocalContext.current + + val wordCount = remember(data.body) { + if (data.body.isBlank()) 0 + else data.body.trim().split(Regex("\\s+")).count { it.isNotEmpty() } + } + + val onCopySnippet = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(data.title, data.body) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Copied \"${data.title}\" to clipboard", Toast.LENGTH_SHORT).show() + } + + val onShareSnippet = { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, data.body) + type = "text/plain" + } + val shareIntent = Intent.createChooser(sendIntent, "Share Auto Text Snippet") + context.startActivity(shareIntent) + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Detail Auto Text", + onBackClick = onBackClick + ) + }, + bottomBar = { + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp, + shadowElevation = 8.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Delete Button + OutlinedButton( + onClick = { showDeleteDialog = true }, + modifier = Modifier + .weight(1f) + .height(52.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = FrogoStatusFailed + ), + border = BorderStroke(1.dp, FrogoStatusFailed.copy(alpha = 0.4f)) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Delete", + style = MaterialTheme.typography.labelLarge.copy( + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ), + color = FrogoStatusFailed + ) + } + + // Edit Button + Button( + onClick = onEditClick, + modifier = Modifier + .weight(1f) + .height(52.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = FrogoPrimary + ) + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit", + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Edit", + style = MaterialTheme.typography.labelLarge.copy( + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ), + color = Color.White + ) + } + } + } + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Header Hero Card + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(12.dp)) + .background(FrogoPrimary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ShortText, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(24.dp) + ) + } + + IconButton(onClick = onShareSnippet) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(14.dp)) + + Text( + text = data.title, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + fontSize = 22.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(10.dp)) + + // Metadata Row + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + ) { + Text( + text = "${data.body.length} characters", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + ) { + Text( + text = "$wordCount words", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 11.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Primary Copy Button + Button( + onClick = onCopySnippet, + modifier = Modifier + .fillMaxWidth() + .height(46.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = FrogoPrimary.copy(alpha = 0.12f), + contentColor = FrogoPrimary + ), + elevation = ButtonDefaults.buttonElevation(0.dp, 0.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy text", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Copy to Clipboard", + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + ) + } + } + } + + // Snippet Content Card + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "EXPANDED CONTENT", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) + ) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + Text( + text = data.body, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = 15.sp, + lineHeight = 24.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + icon = { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(FrogoStatusFailed.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = FrogoStatusFailed, + modifier = Modifier.size(24.dp) + ) + } + }, + title = { + Text( + text = "Delete 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.") + }, + confirmButton = { + Button( + onClick = { + showDeleteDialog = false + onDeleteClick() + }, + colors = ButtonDefaults.buttonColors( + containerColor = FrogoStatusFailed + ), + shape = RoundedCornerShape(10.dp) + ) { + Text( + text = "Delete", + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + TextButton( + onClick = { showDeleteDialog = false } + ) { + Text("Cancel") + } + }, + shape = RoundedCornerShape(16.dp) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun AutoTextDetailScreenPreview() { + FrogoKeyboardTheme(darkTheme = false) { + AutoTextDetailScreen( + data = AutoTextEntity( + id = 1, + title = "Greeting Message", + body = "Hello! Thanks for reaching out. How can I assist you with your project today?" + ), + onEditClick = {}, + onDeleteClick = {}, + onBackClick = {} + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorActivity.kt index 719f80f..5728ddd 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorActivity.kt @@ -3,21 +3,14 @@ package com.frogobox.appkeyboard.ui.autotext import android.content.Intent import android.os.Bundle import androidx.activity.viewModels -import com.frogobox.appkeyboard.common.base.BaseActivity -import com.frogobox.appkeyboard.databinding.ActivityAutotextEditorBinding +import androidx.compose.runtime.Composable +import com.frogobox.appkeyboard.common.base.BaseComposeActivity import com.frogobox.appkeyboard.model.AutoTextEntity -import com.frogobox.sdk.ext.toText import com.google.gson.Gson import dagger.hilt.android.AndroidEntryPoint -/** - * Created by Faisal Amir on 11/03/23 - * https://github.com/amirisback - */ - - @AndroidEntryPoint -class AutoTextEditorActivity : BaseActivity() { +class AutoTextEditorActivity : BaseComposeActivity() { companion object { const val EXTRA_AUTO_TEXT_EDIT = "EXTRA_AUTO_TEXT_EDIT" @@ -28,77 +21,59 @@ class AutoTextEditorActivity : BaseActivity() { private val viewModel: AutoTextViewModel by viewModels() - override fun setupViewBinding(): ActivityAutotextEditorBinding { - return ActivityAutotextEditorBinding.inflate(layoutInflater) - } - - override fun setupViewModel() { - super.setupViewModel() - viewModel.apply { - eventSuccessState.observe(this@AutoTextEditorActivity) { - if (hasExtraDetail()) { - setResult(RESULT_CODE_UPDATE, Intent().apply { - val extra = AutoTextEntity( - id = autoText().id, - title = binding.etTitle.toText(), - body = binding.etContent.toText() - ) - putExtra(EXTRA_AUTO_TEXT_EDIT_RESULT, Gson().toJson(extra)) - }) - } else { - setResult(RESULT_CODE_ADD) - } - finish() - } - } - } + private var lastSavedTitle: String = "" + private var lastSavedBody: String = "" override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) - setupDetailActivity("Editor Auto Text") - setupUI() - } - - private fun setupUI() { - binding.apply { + viewModel.eventSuccessState.observe(this) { if (hasExtraDetail()) { - btnSave.text = "Update" - etTitle.setText(autoText().title) - etContent.setText(autoText().body) - } - - btnSave.setOnClickListener { - if (hasExtraDetail()) { - setupUpdate() - } else { - setupInsert() - } + setResult(RESULT_CODE_UPDATE, Intent().apply { + val extra = AutoTextEntity( + id = autoText().id, + title = lastSavedTitle, + body = lastSavedBody + ) + putExtra(EXTRA_AUTO_TEXT_EDIT_RESULT, Gson().toJson(extra)) + }) + } else { + setResult(RESULT_CODE_ADD) } + finish() } } - private fun autoText() : AutoTextEntity { + private fun autoText(): AutoTextEntity { return Gson().fromJson( intent.extras?.getString(EXTRA_AUTO_TEXT_EDIT), AutoTextEntity::class.java - ) + ) ?: AutoTextEntity() } - private fun hasExtraDetail() : Boolean { + private fun hasExtraDetail(): Boolean { return intent.hasExtra(EXTRA_AUTO_TEXT_EDIT) } - private fun setupInsert() { - val title = binding.etTitle.text.toString() - val body = binding.etContent.text.toString() - viewModel.insertAutoText(title, body) - } - - private fun setupUpdate() { - val title = binding.etTitle.toText() - val body = binding.etContent.toText() - viewModel.updateAutoText(autoText().id, title, body) + @Composable + override fun Content() { + val detail = autoText() + val isEdit = hasExtraDetail() + + AutoTextEditorScreen( + initialTitle = if (isEdit) detail.title else "", + initialBody = if (isEdit) detail.body else "", + isEditMode = isEdit, + onSave = { title, body -> + lastSavedTitle = title + lastSavedBody = body + if (isEdit) { + viewModel.updateAutoText(detail.id, title, body) + } else { + viewModel.insertAutoText(title, body) + } + }, + onBackClick = { finish() } + ) } - } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorScreen.kt new file mode 100644 index 0000000..959214f --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorScreen.kt @@ -0,0 +1,418 @@ +package com.frogobox.appkeyboard.ui.autotext + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.automirrored.filled.ShortText +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Lightbulb +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar + +@Composable +fun AutoTextEditorScreen( + initialTitle: String, + initialBody: String, + isEditMode: Boolean, + onSave: (title: String, body: String) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + var title by remember { mutableStateOf(initialTitle) } + var body by remember { mutableStateOf(initialBody) } + val isFormValid = title.isNotBlank() && body.isNotBlank() + val scrollState = rememberScrollState() + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = if (isEditMode) "Edit Auto Text" else "New Auto Text", + onBackClick = onBackClick + ) + }, + bottomBar = { + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp, + shadowElevation = 8.dp + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Button( + onClick = { + if (isFormValid) { + onSave(title.trim(), body.trim()) + } + }, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(16.dp), + enabled = isFormValid, + colors = ButtonDefaults.buttonColors( + containerColor = FrogoPrimary + ) + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (isEditMode) "Update Template" else "Save Template", + style = MaterialTheme.typography.labelLarge.copy( + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ), + color = Color.White + ) + } + } + } + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Live Keyboard Preview Section + LiveKeyboardPreviewCard( + title = title, + body = body + ) + + // Title Input + OutlinedTextField( + value = title, + onValueChange = { if (it.length <= 50) title = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Shortcut Title") }, + placeholder = { Text("e.g. Greeting, BCA Rekening, Email") }, + leadingIcon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.ShortText, + contentDescription = null, + tint = FrogoPrimary + ) + }, + trailingIcon = { + if (title.isNotEmpty()) { + IconButton(onClick = { title = "" }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear Title", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + supportingText = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Appears in keyboard quick bar") + Text("${title.length} / 50") + } + }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = FrogoPrimary, + focusedLabelColor = FrogoPrimary, + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface + ) + ) + + // Content / Body Input + OutlinedTextField( + value = body, + onValueChange = { body = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Template Content") }, + placeholder = { Text("Enter the full text that will be inserted when tapped...") }, + leadingIcon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.Notes, + contentDescription = null, + tint = FrogoPrimary + ) + }, + trailingIcon = { + if (body.isNotEmpty()) { + IconButton(onClick = { body = "" }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear Content", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + supportingText = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Will be committed to input field") + Text("${body.length} characters") + } + }, + minLines = 6, + shape = RoundedCornerShape(16.dp), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = FrogoPrimary, + focusedLabelColor = FrogoPrimary, + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface + ) + ) + + // Helper Guidance Card + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = FrogoPrimary.copy(alpha = 0.07f) + ), + border = BorderStroke(1.dp, FrogoPrimary.copy(alpha = 0.20f)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.Top + ) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(FrogoPrimary.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Lightbulb, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(18.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Text( + text = "Keyboard Tip", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold + ), + color = FrogoPrimary + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Short, distinct titles make it effortless to select snippets directly from the Frogo Keyboard while chatting or filling forms.", + style = MaterialTheme.typography.bodySmall.copy( + lineHeight = 16.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun LiveKeyboardPreviewCard( + title: String, + body: String, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Keyboard, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "LIVE KEYBOARD PREVIEW", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + fontSize = 11.sp + ), + color = FrogoPrimary + ) + } + + Surface( + shape = RoundedCornerShape(6.dp), + color = FrogoPrimary.copy(alpha = 0.12f) + ) { + Text( + text = "Interactive", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 10.sp, + color = FrogoPrimary + ), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Simulated Keyboard Item Box + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(12.dp) + ) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp) + ) { + Text( + text = if (title.isBlank()) "Template Title" else title, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ), + color = if (title.isBlank()) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = if (body.isBlank()) "Expanded text preview will appear here..." else body, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp + ), + color = if (body.isBlank()) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun AutoTextEditorScreenPreview() { + FrogoKeyboardTheme(darkTheme = false) { + AutoTextEditorScreen( + initialTitle = "Quick Greeting", + initialBody = "Hello there! Wishing you a great day ahead.", + isEditMode = true, + onSave = { _, _ -> }, + onBackClick = {} + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextScreen.kt new file mode 100644 index 0000000..8da8755 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextScreen.kt @@ -0,0 +1,495 @@ +package com.frogobox.appkeyboard.ui.autotext + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.automirrored.filled.ShortText +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SearchOff +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.model.AutoTextEntity +import com.frogobox.appkeyboard.ui.theme.compose.FrogoEmptyView +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar + +@Composable +fun AutoTextScreen( + autoTextList: List, + isLoading: Boolean, + onItemClick: (AutoTextEntity) -> Unit, + onAddClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + var searchQuery by remember { mutableStateOf("") } + val context = LocalContext.current + + val filteredList = remember(autoTextList, searchQuery) { + if (searchQuery.isBlank()) { + autoTextList + } else { + autoTextList.filter { + it.title.contains(searchQuery, ignoreCase = true) || + it.body.contains(searchQuery, ignoreCase = true) + } + } + } + + val onCopySnippet: (AutoTextEntity) -> Unit = { item -> + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(item.title, item.body) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Copied \"${item.title}\" to clipboard", Toast.LENGTH_SHORT).show() + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Auto Text", + onBackClick = onBackClick + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = onAddClick, + containerColor = FrogoPrimary, + contentColor = Color.White, + shape = RoundedCornerShape(16.dp), + icon = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add Template" + ) + }, + text = { + Text( + text = "Add Template", + style = MaterialTheme.typography.labelLarge.copy( + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + ) + } + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Header Overview Banner & Search Bar + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + AutoTextHeaderBanner( + totalCount = autoTextList.size, + filteredCount = filteredList.size, + isSearching = searchQuery.isNotBlank() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Modern Search Field + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = "Search templates by title or content...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = FrogoPrimary + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = FrogoPrimary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface + ) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f) + ) { + when { + isLoading && autoTextList.isEmpty() -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = FrogoPrimary + ) + } + autoTextList.isEmpty() -> { + FrogoEmptyView( + title = "NO AUTO TEXT FOUND", + subtitle = "Tap 'Add Template' below to create a quick template.", + modifier = Modifier.align(Alignment.Center) + ) + } + filteredList.isEmpty() -> { + EmptySearchState( + query = searchQuery, + onClearSearch = { searchQuery = "" }, + modifier = Modifier.align(Alignment.Center) + ) + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 4.dp, + bottom = 88.dp // Space for Extended FAB + ), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(filteredList, key = { it.id }) { item -> + AutoTextCard( + item = item, + onClick = { onItemClick(item) }, + onCopy = { onCopySnippet(item) } + ) + } + } + } + } + } + } + } +} + +@Composable +private fun AutoTextHeaderBanner( + totalCount: Int, + filteredCount: Int, + isSearching: Boolean, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = FrogoPrimary.copy(alpha = 0.08f) + ), + border = BorderStroke(1.dp, FrogoPrimary.copy(alpha = 0.22f)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(FrogoPrimary.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ShortText, + contentDescription = "Templates Icon", + tint = FrogoPrimary, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Text( + text = "Quick Templates", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 15.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Instant shortcuts on your keyboard", + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Surface( + shape = RoundedCornerShape(10.dp), + color = FrogoPrimary.copy(alpha = 0.16f) + ) { + Text( + text = if (isSearching) "$filteredCount / $totalCount" else "$totalCount Saved", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + color = FrogoPrimary + ), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp) + ) + } + } + } +} + +@Composable +private fun AutoTextCard( + item: AutoTextEntity, + onClick: () -> Unit, + onCopy: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(FrogoPrimary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Notes, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + IconButton( + onClick = onCopy, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy text", + tint = FrogoPrimary, + modifier = Modifier.size(18.dp) + ) + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + // Styled Snippet Container + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = item.body, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 13.sp, + lineHeight = 18.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +private fun EmptySearchState( + query: String, + onClearSearch: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.SearchOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(32.dp) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "No templates found", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "No template matches \"$query\"", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(12.dp)) + + TextButton(onClick = onClearSearch) { + Text( + text = "Clear Search", + color = FrogoPrimary, + fontWeight = FontWeight.SemiBold + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun AutoTextScreenPreview() { + FrogoKeyboardTheme(darkTheme = false) { + AutoTextScreen( + autoTextList = listOf( + AutoTextEntity(id = 1, title = "Greeting", body = "Hello, good morning! How can I help you today?"), + AutoTextEntity(id = 2, title = "Account Number", body = "BCA: 1234567890 a/n Faisal Amir") + ), + isLoading = false, + onItemClick = {}, + onAddClick = {}, + onBackClick = {} + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextViewModel.kt index 79692da..f53c14a 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextViewModel.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextViewModel.kt @@ -2,15 +2,17 @@ package com.frogobox.appkeyboard.ui.autotext import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope import com.frogobox.appkeyboard.common.base.BaseViewModel -import com.frogobox.appkeyboard.common.callback.DataResponseCallback -import com.frogobox.appkeyboard.common.callback.StateResponseCallback import com.frogobox.appkeyboard.common.ext.getTimeNow import com.frogobox.appkeyboard.model.AutoTextEntity import com.frogobox.appkeyboard.model.AutoTextLabelType import com.frogobox.appkeyboard.repository.autotext.AutoTextRepository import com.frogobox.coresdk.source.Resource import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -53,23 +55,18 @@ class AutoTextViewModel @Inject constructor( fun getAutoText() { - repository.getAutoText(object : DataResponseCallback> { - override fun onFinish() {} - override fun onHideProgress() {} - - override fun onFailed(statusCode: Int, errorMessage: String) { - _autoText.postValue(Resource.Error(statusCode, errorMessage)) - } - - override fun onShowProgress() { - _autoText.postValue(Resource.Loading()) - } - - override fun onSuccess(data: List) { - _autoText.postValue(Resource.Success(data)) - } - - }) + viewModelScope.launch { + repository.getAutoText() + .onStart { + _autoText.postValue(Resource.Loading()) + } + .catch { e -> + _autoText.postValue(Resource.Error(-1, e.localizedMessage ?: "Unknown Error")) + } + .collect { data -> + _autoText.postValue(Resource.Success(data)) + } + } } fun insertAutoText(title: String, body: String) { @@ -80,54 +77,35 @@ class AutoTextViewModel @Inject constructor( body = body, isActive = true ) - repository.insertAutoText(data, object : StateResponseCallback { - override fun onFailed(statusCode: Int, errorMessage: String) { - _eventFailed.postValue(errorMessage) - } - - override fun onFinish() { - _eventFinishState.postValue(true) - } - - override fun onHideProgress() { + viewModelScope.launch { + _eventShowProgressState.postValue(true) + try { + repository.insertAutoText(data) _eventShowProgressState.postValue(false) - - } - - override fun onShowProgress() { - _eventShowProgressState.postValue(true) - - } - - override fun onSuccess() { _eventSuccessState.postValue(true) + _eventFinishState.postValue(true) + } catch (e: Exception) { + _eventShowProgressState.postValue(false) + _eventFailed.postValue(e.localizedMessage ?: "Failed to insert AutoText") + _eventFinishState.postValue(true) } } - ) } fun deleteAutoText(data: AutoTextEntity) { - repository.deleteAutoText(data, object : StateResponseCallback { - override fun onFailed(statusCode: Int, errorMessage: String) { - _eventFailed.postValue(errorMessage) - } - - override fun onFinish() { - _eventFinishState.postValue(true) - } - - override fun onHideProgress() { + viewModelScope.launch { + _eventShowProgressState.postValue(true) + try { + repository.deleteAutoText(data) _eventShowProgressState.postValue(false) - } - - override fun onShowProgress() { - _eventShowProgressState.postValue(true) - } - - override fun onSuccess() { _eventSuccessState.postValue(true) + _eventFinishState.postValue(true) + } catch (e: Exception) { + _eventShowProgressState.postValue(false) + _eventFailed.postValue(e.localizedMessage ?: "Failed to delete AutoText") + _eventFinishState.postValue(true) } - }) + } } fun updateAutoText(id: Int, title: String, body: String) { @@ -139,27 +117,19 @@ class AutoTextViewModel @Inject constructor( body = body, isActive = true ) - repository.updateAutoText(data, object : StateResponseCallback { - override fun onFailed(statusCode: Int, errorMessage: String) { - _eventFailed.postValue(errorMessage) - } - - override fun onFinish() { - _eventFinishState.postValue(true) - } - - override fun onHideProgress() { + viewModelScope.launch { + _eventShowProgressState.postValue(true) + try { + repository.updateAutoText(data) _eventShowProgressState.postValue(false) - } - - override fun onShowProgress() { - _eventShowProgressState.postValue(true) - } - - override fun onSuccess() { _eventSuccessState.postValue(true) + _eventFinishState.postValue(true) + } catch (e: Exception) { + _eventShowProgressState.postValue(false) + _eventFailed.postValue(e.localizedMessage ?: "Failed to update AutoText") + _eventFinishState.postValue(true) } - }) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboard.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboard.kt index c91974d..1cd69ef 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboard.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboard.kt @@ -1,13 +1,16 @@ package com.frogobox.appkeyboard.ui.keyboard.autotext import android.content.Context +import android.content.Intent import android.util.AttributeSet import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import android.widget.LinearLayout -import com.frogobox.appkeyboard.databinding.ItemKeyboardNewsBinding +import com.frogobox.appkeyboard.databinding.ItemKeyboardAutotextBinding import com.frogobox.appkeyboard.databinding.KeyboardAutotextBinding import com.frogobox.appkeyboard.model.AutoTextEntity +import com.frogobox.appkeyboard.ui.autotext.AutoTextActivity import com.frogobox.libkeyboard.common.core.BaseKeyboard import com.frogobox.recycler.core.FrogoRecyclerNotifyListener import com.frogobox.recycler.core.IFrogoBindingAdapter @@ -31,6 +34,12 @@ class AutoTextKeyboard( super.initUI() binding.apply { tvToolbarTitle.text = "Auto Text" + toolbarManage.setOnClickListener { + val intent = Intent(context, AutoTextActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + context.startActivity(intent) + } } } @@ -44,21 +53,27 @@ class AutoTextKeyboard( private fun setupRv(data: List) { binding.apply { + if (data.isEmpty()) { + llEmptyState.visibility = View.VISIBLE + rvKeyboardMain.visibility = View.GONE + } else { + llEmptyState.visibility = View.GONE + rvKeyboardMain.visibility = View.VISIBLE + } - val adapterCallback = object : IFrogoBindingAdapter { + val adapterCallback = object : IFrogoBindingAdapter { override fun onItemClicked( - binding: ItemKeyboardNewsBinding, + binding: ItemKeyboardAutotextBinding, data: AutoTextEntity, position: Int, notifyListener: FrogoRecyclerNotifyListener, ) { - // Your Clicked val output = data.body currentInputConnection?.commitText(output, 1) } override fun onItemLongClicked( - binding: ItemKeyboardNewsBinding, + binding: ItemKeyboardAutotextBinding, data: AutoTextEntity, position: Int, notifyListener: FrogoRecyclerNotifyListener, @@ -79,26 +94,28 @@ class AutoTextKeyboard( return oldItem.id == newItem.id } - override fun setViewBinding(parent: ViewGroup): ItemKeyboardNewsBinding { - return ItemKeyboardNewsBinding.inflate( + override fun setViewBinding(parent: ViewGroup): ItemKeyboardAutotextBinding { + return ItemKeyboardAutotextBinding.inflate( LayoutInflater.from(context), parent, - false) + false + ) } override fun setupInitComponent( - binding: ItemKeyboardNewsBinding, + binding: ItemKeyboardAutotextBinding, data: AutoTextEntity, position: Int, notifyListener: FrogoRecyclerNotifyListener, ) { binding.apply { tvItemKeyboardMain.text = data.title + tvItemKeyboardBody.text = data.body } } } - rvKeyboardMain.injectorBinding() + rvKeyboardMain.injectorBinding() .addData(data) .createLayoutLinearVertical(false) .addCallback(adapterCallback) diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardViewModel.kt index 1c001b3..c9c6758 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardViewModel.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardViewModel.kt @@ -1,34 +1,42 @@ package com.frogobox.appkeyboard.ui.keyboard.autotext import android.content.Context -import com.frogobox.appkeyboard.common.callback.DataResponseCallback import com.frogobox.appkeyboard.data.local.db.AppDatabase import com.frogobox.appkeyboard.model.AutoTextEntity import com.frogobox.appkeyboard.repository.autotext.AutoTextRepository import com.frogobox.appkeyboard.repository.autotext.AutoTextRepositoryImpl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Created by Faisal Amir on 11/03/23 * https://github.com/amirisback */ - class AutoTextKeyboardViewModel(val context: Context) { - private fun getRepository() : AutoTextRepository { + private fun getRepository(): AutoTextRepository { return AutoTextRepositoryImpl(AppDatabase.newInstance(context).autoTextDao()) } + fun getAutoTextFlow(): Flow> { + return getRepository().getAutoText() + } + fun getAutoText(onSuccessData: (List) -> Unit) { - getRepository().getAutoText(object : DataResponseCallback> { - override fun onFailed(statusCode: Int, errorMessage: String) {} - override fun onFinish() {} - override fun onHideProgress() {} - override fun onShowProgress() {} - override fun onSuccess(data: List) { - onSuccessData(data) + CoroutineScope(Dispatchers.IO).launch { + try { + getRepository().getAutoText().collect { data -> + withContext(Dispatchers.Main) { + onSuccessData(data) + } + } + } catch (_: Exception) { } - }) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/form/FormKeyboard.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/form/FormKeyboard.kt index f3fb5b2..3a253e8 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/form/FormKeyboard.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/form/FormKeyboard.kt @@ -5,7 +5,6 @@ import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout import com.frogobox.appkeyboard.databinding.KeyboardFormBinding -import com.frogobox.appkeyboard.ui.keyboard.autotext.AutoTextKeyboardViewModel import com.frogobox.appkeyboard.ui.keyboard.movie.MovieKeyboard import com.frogobox.appkeyboard.util.KeyboardNavigationHelper import com.frogobox.libkeyboard.common.core.BaseKeyboard diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/movie/MovieKeyboard.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/movie/MovieKeyboard.kt index 5844d78..6f3a610 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/movie/MovieKeyboard.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/movie/MovieKeyboard.kt @@ -87,7 +87,7 @@ class MovieKeyboard( val output = "Movie: ${data.title} - ${data.release_date} \n " + "Overview: ${data.overview} \n " + "Vote: ${data.vote_average} \n " + - "${MovieUrl.BASE_URL_IMAGE_ORIGNAL}${data.poster_path}" + + "${MovieUrl.BASE_URL_IMAGE_ORIGINAL}${data.poster_path}" + "\n" + "Terima Kasih" currentInputConnection?.commitText(output, 1) @@ -131,7 +131,7 @@ class MovieKeyboard( frogoRvGridType6TvTitle.text = data.title frogoRvGridType6TvSubtitle.text = data.release_date frogoRvGridType6TvDesc.text = data.overview - Glide.with(context).load("${MovieUrl.BASE_URL_IMAGE_ORIGNAL}${data.poster_path}").into(frogoRvGridType6CivPoster) + Glide.with(context).load("${MovieUrl.BASE_URL_IMAGE_ORIGINAL}${data.poster_path}").into(frogoRvGridType6CivPoster) } } } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguage.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguage.kt index 0afb5ba..9804d69 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguage.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguage.kt @@ -14,4 +14,8 @@ package com.frogobox.appkeyboard.ui.language data class KeyboardLanguage( val name: String, val xml: Int, + val layoutType: String = "Standard", + val code: String = "EN", + val script: String = "Latin", + val isRtl: Boolean = false ) \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageActivity.kt index 50ae054..f5fc81a 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageActivity.kt @@ -1,135 +1,47 @@ package com.frogobox.appkeyboard.ui.language import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup import androidx.activity.viewModels -import com.frogobox.appkeyboard.common.base.BaseActivity -import com.frogobox.appkeyboard.databinding.ActivityKeyboardLanguageBinding -import com.frogobox.appkeyboard.databinding.ItemLanguageKeyboardBinding -import com.frogobox.recycler.core.FrogoRecyclerNotifyListener -import com.frogobox.recycler.core.IFrogoBindingAdapter -import com.frogobox.recycler.ext.injectorBinding -import com.frogobox.sdk.ext.gone +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.frogobox.appkeyboard.common.base.BaseComposeActivity import com.frogobox.sdk.ext.showToast -import com.frogobox.sdk.ext.visible -import com.frogobox.sdk.util.FrogoFunc -import com.frogobox.sdk.util.SimpleDialogUtil import dagger.hilt.android.AndroidEntryPoint -/** - * Created by Faisal Amir on 24/10/22 - * ----------------------------------------- - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - * Copyright (C) Frogobox ID / amirisback - * All rights reserved - */ - @AndroidEntryPoint -class KeyboardLanguageActivity : BaseActivity(), - IFrogoBindingAdapter { +class KeyboardLanguageActivity : BaseComposeActivity() { private val viewModel: KeyboardLanguageViewModel by viewModels() - override fun setupViewBinding(): ActivityKeyboardLanguageBinding { - return ActivityKeyboardLanguageBinding.inflate(layoutInflater) - } - - override fun setupViewModel() { - super.setupViewModel() - viewModel.apply { - keyboardLanguage.observe(this@KeyboardLanguageActivity) { - setupRV(it) - } - } - } + private var languageList by mutableStateOf>(emptyList()) + private var activeLanguageXml by mutableStateOf(0) override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) - if (savedInstanceState == null) { - viewModel.getKeyboardLanguage(this) - } - } - - override fun initView() { - super.initView() - setupDetailActivity("Keyboard Language") - binding.apply { - + viewModel.keyboardLanguage.observe(this) { + languageList = it + val active = it.firstOrNull { lang -> viewModel.checkKeyboardType(lang.xml) } + activeLanguageXml = active?.xml ?: 0 } - } - - override fun areContentsTheSame(oldItem: KeyboardLanguage, newItem: KeyboardLanguage): Boolean { - return oldItem == newItem - } - - override fun areItemsTheSame(oldItem: KeyboardLanguage, newItem: KeyboardLanguage): Boolean { - return oldItem.xml == newItem.xml - } - - override fun setViewBinding(parent: ViewGroup): ItemLanguageKeyboardBinding { - return ItemLanguageKeyboardBinding.inflate(LayoutInflater.from(parent.context), parent, false) - } - - override fun onItemClicked( - binding: ItemLanguageKeyboardBinding, - data: KeyboardLanguage, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) { - applyLanguage(data) - } - - override fun onItemLongClicked( - binding: ItemLanguageKeyboardBinding, - data: KeyboardLanguage, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) {} - - override fun setupInitComponent( - binding: ItemLanguageKeyboardBinding, - data: KeyboardLanguage, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) { - binding.apply { - tvItemKeyboardMain.text = data.name - if (viewModel.checkKeyboardType(data.xml)) { - this.ivIcon.visible() - } else { - this.ivIcon.gone() - } - } - } - - private fun setupRV(data: List) { - binding.rvKeyboardLanguage - .injectorBinding() - .addData(data) - .addCallback(this) - .createLayoutLinearVertical(false) - .build() - } - - private fun applyLanguage(data: KeyboardLanguage) { - SimpleDialogUtil.create( - this@KeyboardLanguageActivity, - "Change Theme", - "Are you sure you want to change keyboard theme?", - object : SimpleDialogUtil.OnDialogClickListener { - - override fun negativeButton() {} - override fun positiveButton() { - viewModel.setKeyboard(data.xml) { - showToast("${data.name} Language Applied") - viewModel.getKeyboardLanguage(this@KeyboardLanguageActivity) - } + viewModel.getKeyboardLanguage(this) + } + + @Composable + override fun Content() { + KeyboardLanguageScreen( + languageList = languageList, + activeLanguageXml = activeLanguageXml, + checkIsSelected = { xml -> xml == activeLanguageXml }, + onApplyLanguage = { language -> + viewModel.setKeyboard(language.xml) { + activeLanguageXml = language.xml + showToast("${language.name} Language Applied") + viewModel.getKeyboardLanguage(this@KeyboardLanguageActivity) } - } + }, + onBackClick = { finish() } ) } - } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageScreen.kt new file mode 100644 index 0000000..2daf85d --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageScreen.kt @@ -0,0 +1,831 @@ +package com.frogobox.appkeyboard.ui.language + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusSuccess +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar + +@Composable +fun KeyboardLanguageScreen( + languageList: List, + checkIsSelected: (Int) -> Boolean, + onApplyLanguage: (KeyboardLanguage) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + activeLanguageXml: Int = 0 +) { + var searchQuery by remember { mutableStateOf("") } + var selectedLanguageForDialog by remember { mutableStateOf(null) } + + // Resolve active language object + val activeLanguage = remember(activeLanguageXml, languageList) { + if (activeLanguageXml != 0) { + languageList.firstOrNull { it.xml == activeLanguageXml } + } else { + languageList.firstOrNull { checkIsSelected(it.xml) } + } + } + + // Filter languages reactively based on query + val filteredLanguages = remember(searchQuery, languageList) { + if (searchQuery.isBlank()) { + languageList + } else { + val query = searchQuery.trim().lowercase() + languageList.filter { + it.name.lowercase().contains(query) || + it.code.lowercase().contains(query) || + it.layoutType.lowercase().contains(query) || + it.script.lowercase().contains(query) + } + } + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Keyboard Language", + onBackClick = onBackClick + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 1. Active Language Hero Card (Only shown when not searching, or when active matches query) + if (activeLanguage != null && searchQuery.isBlank()) { + item(key = "active_hero_card") { + ActiveLanguageHeroCard( + language = activeLanguage + ) + } + } + + // 2. Search Field & Results Header + item(key = "search_header") { + SearchAndFilterHeader( + query = searchQuery, + onQueryChange = { searchQuery = it }, + totalLanguages = languageList.size, + filteredCount = filteredLanguages.size + ) + } + + // 3. Empty Search State + if (filteredLanguages.isEmpty()) { + item(key = "empty_state") { + EmptySearchState( + query = searchQuery, + onClearSearch = { searchQuery = "" } + ) + } + } else { + // 4. Filtered / Full Language Cards List + items( + items = filteredLanguages, + key = { it.name + it.xml } + ) { language -> + val isSelected = if (activeLanguageXml != 0) { + language.xml == activeLanguageXml + } else { + checkIsSelected(language.xml) + } + + LanguageCard( + language = language, + isSelected = isSelected, + onClick = { + selectedLanguageForDialog = language + } + ) + } + } + } + } + + // Confirmation Dialog + selectedLanguageForDialog?.let { language -> + AlertDialog( + onDismissRequest = { selectedLanguageForDialog = null }, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(FrogoPrimary.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Language, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(20.dp) + ) + } + Text( + text = "Change Keyboard Layout", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold + ) + ) + } + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Are you sure you want to switch your active typing layout?", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Selected Language Preview Box + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(FrogoPrimary.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center + ) { + Text( + text = language.code, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + color = FrogoPrimary + ) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = language.name, + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "${language.layoutType} Layout • ${language.script}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + }, + confirmButton = { + Button( + onClick = { + val current = language + selectedLanguageForDialog = null + onApplyLanguage(current) + }, + colors = ButtonDefaults.buttonColors( + containerColor = FrogoPrimary + ), + shape = RoundedCornerShape(10.dp) + ) { + Text( + text = "Apply Layout", + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + TextButton( + onClick = { selectedLanguageForDialog = null } + ) { + Text("Cancel") + } + }, + shape = RoundedCornerShape(18.dp) + ) + } +} + +/** + * Overload function for backward compatibility and clean call sites. + */ +@Composable +fun KeyboardLanguageScreen( + languageList: List, + activeLanguageXml: Int, + onApplyLanguage: (KeyboardLanguage) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + KeyboardLanguageScreen( + languageList = languageList, + checkIsSelected = { it == activeLanguageXml }, + onApplyLanguage = onApplyLanguage, + onBackClick = onBackClick, + modifier = modifier, + activeLanguageXml = activeLanguageXml + ) +} + +/** + * Prominent Hero Card displaying the currently active keyboard language. + */ +@Composable +private fun ActiveLanguageHeroCard( + language: KeyboardLanguage, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors( + containerColor = FrogoPrimary.copy(alpha = 0.08f) + ), + border = BorderStroke(1.5.dp, FrogoPrimary.copy(alpha = 0.35f)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Header: Badge Pill + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = FrogoStatusSuccess.copy(alpha = 0.15f) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background(FrogoStatusSuccess) + ) + Text( + text = "CURRENT ACTIVE", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + color = FrogoStatusSuccess + ) + ) + } + } + + Text( + text = "Default Input Method", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Main Content Row + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // Language Code Avatar + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(FrogoPrimary.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center + ) { + Text( + text = language.code, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + color = FrogoPrimary, + fontSize = 17.sp + ) + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + // Titles & Subtitles + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center + ) { + Text( + text = language.name, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 17.sp + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = "${language.layoutType} Layout • ${language.script}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Active Indicator", + tint = FrogoStatusSuccess, + modifier = Modifier.size(26.dp) + ) + } + } + } +} + +/** + * Modern Search Field with Result Statistics + */ +@Composable +private fun SearchAndFilterHeader( + query: String, + onQueryChange: (String) -> Unit, + totalLanguages: Int, + filteredCount: Int, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + placeholder = { + Text( + text = "Search language, code, or layout...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = FrogoPrimary, + modifier = Modifier.size(20.dp) + ) + }, + trailingIcon = { + if (query.isNotBlank()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Clear Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + } + }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = FrogoPrimary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface + ) + ) + + // Count / Section Label + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = if (query.isBlank()) "Available Languages" else "Search Results", + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) { + Text( + text = if (query.isBlank()) "$totalLanguages available" else "$filteredCount found", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) + ) + } + } + } +} + +/** + * Individual Language Item Card with Avatar Badge, Subtitles, and Selection State. + */ +@Composable +private fun LanguageCard( + language: KeyboardLanguage, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation( + defaultElevation = if (isSelected) 3.dp else 1.dp + ), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + FrogoPrimary.copy(alpha = 0.04f) + } else { + MaterialTheme.colorScheme.surface + } + ), + border = BorderStroke( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) { + FrogoPrimary + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f) + } + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Circular Avatar Container with Language Code + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background( + if (isSelected) FrogoPrimary.copy(alpha = 0.18f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = language.code, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + color = if (isSelected) FrogoPrimary else MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + // Text Info Column + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = language.name, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Layout Badge Chip + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + ) { + Text( + text = language.layoutType, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + + // Script details + Text( + text = "• ${language.script}", + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // RTL Tag if applicable + if (language.isRtl) { + Surface( + shape = RoundedCornerShape(6.dp), + color = FrogoPrimary.copy(alpha = 0.1f) + ) { + Text( + text = "RTL", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 9.sp, + color = FrogoPrimary + ), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) + ) + } + } + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Trailing Status Indicator + if (isSelected) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = FrogoStatusSuccess.copy(alpha = 0.15f) + ) { + Text( + text = "ACTIVE", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + color = FrogoStatusSuccess + ), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp) + ) + } + + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Active Language", + tint = FrogoStatusSuccess, + modifier = Modifier.size(22.dp) + ) + } + } else { + Icon( + imageVector = Icons.Default.RadioButtonUnchecked, + contentDescription = "Select Language", + tint = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.size(20.dp) + ) + } + } + } +} + +/** + * Empty State when search query yields no matches. + */ +@Composable +private fun EmptySearchState( + query: String, + onClearSearch: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 40.dp, horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.outline + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "No Language Found", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = "We couldn't find any language or layout matching \"$query\".\nTry searching by code or layout type.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(18.dp)) + + TextButton( + onClick = onClearSearch, + colors = ButtonDefaults.textButtonColors( + contentColor = FrogoPrimary + ) + ) { + Text( + text = "Reset Search", + fontWeight = FontWeight.Bold + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun KeyboardLanguageScreenPreview() { + FrogoKeyboardTheme(darkTheme = false) { + KeyboardLanguageScreen( + languageList = listOf( + KeyboardLanguage( + name = "English (QWERTY)", + xml = 1, + layoutType = "QWERTY", + code = "EN", + script = "Latin" + ), + KeyboardLanguage( + name = "German", + xml = 2, + layoutType = "QWERTZ", + code = "DE", + script = "Deutsch (Latin)" + ), + KeyboardLanguage( + name = "French", + xml = 3, + layoutType = "AZERTY", + code = "FR", + script = "Français (Latin)" + ), + KeyboardLanguage( + name = "Persian", + xml = 4, + layoutType = "Standard", + code = "FA", + script = "فارسی (Perso-Arabic)", + isRtl = true + ) + ), + checkIsSelected = { it == 1 }, + onApplyLanguage = {}, + onBackClick = {}, + activeLanguageXml = 1 + ) + } +} + +@Preview(showBackground = true) +@Composable +fun KeyboardLanguageScreenDarkPreview() { + FrogoKeyboardTheme(darkTheme = true) { + KeyboardLanguageScreen( + languageList = listOf( + KeyboardLanguage( + name = "English (QWERTY)", + xml = 1, + layoutType = "QWERTY", + code = "EN", + script = "Latin" + ), + KeyboardLanguage( + name = "Russian", + xml = 2, + layoutType = "ЙЦУКЕН", + code = "RU", + script = "Русский (Cyrillic)" + ) + ), + checkIsSelected = { it == 1 }, + onApplyLanguage = {}, + onBackClick = {}, + activeLanguageXml = 1 + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageViewModel.kt index 3158b85..a096b1a 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageViewModel.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/language/KeyboardLanguageViewModel.kt @@ -36,88 +36,149 @@ class KeyboardLanguageViewModel @Inject constructor( val list = mutableListOf() list.add( KeyboardLanguage( - "${context.getString(com.frogobox.libkeyboard.R.string.translation_english)} (QWERTY)", - com.frogobox.libkeyboard.R.xml.keys_letters_qwerty + name = "${context.getString(com.frogobox.libkeyboard.R.string.translation_english)} (QWERTY)", + xml = com.frogobox.libkeyboard.R.xml.keys_letters_qwerty, + layoutType = "QWERTY", + code = "EN", + script = "Latin" ) ) list.add( KeyboardLanguage( - "${context.getString(com.frogobox.libkeyboard.R.string.translation_english)} (QWERTZ)", - com.frogobox.libkeyboard.R.xml.keys_letters_english_qwertz + name = "Programmer / Coding (QWERTY)", + xml = com.frogobox.libkeyboard.R.xml.keys_letters_programmer, + layoutType = "Codeboard", + code = "DEV", + script = "Code & Symbols" ) ) list.add( KeyboardLanguage( - "${context.getString(com.frogobox.libkeyboard.R.string.translation_english)} (DVORAK)", - com.frogobox.libkeyboard.R.xml.keys_letters_english_dvorak + name = "${context.getString(com.frogobox.libkeyboard.R.string.translation_english)} (QWERTZ)", + xml = com.frogobox.libkeyboard.R.xml.keys_letters_english_qwertz, + layoutType = "QWERTZ", + code = "EN", + script = "Latin" + ) + ) + list.add( + KeyboardLanguage( + name = "${context.getString(com.frogobox.libkeyboard.R.string.translation_english)} (DVORAK)", + xml = com.frogobox.libkeyboard.R.xml.keys_letters_english_dvorak, + layoutType = "DVORAK", + code = "EN", + script = "Latin" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_bengali), - com.frogobox.libkeyboard.R.xml.keys_letters_bengali + name = context.getString(com.frogobox.libkeyboard.R.string.translation_bengali), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_bengali, + layoutType = "Standard", + code = "BN", + script = "বাংলা (Bengali)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_bulgarian), - com.frogobox.libkeyboard.R.xml.keys_letters_bulgarian + name = context.getString(com.frogobox.libkeyboard.R.string.translation_bulgarian), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_bulgarian, + layoutType = "Standard", + code = "BG", + script = "Български (Cyrillic)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_french), - com.frogobox.libkeyboard.R.xml.keys_letters_french + name = context.getString(com.frogobox.libkeyboard.R.string.translation_french), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_french, + layoutType = "AZERTY", + code = "FR", + script = "Français (Latin)" + ) + ) + list.add( + KeyboardLanguage( + name = context.getString(com.frogobox.libkeyboard.R.string.translation_german), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_german, + layoutType = "QWERTZ", + code = "DE", + script = "Deutsch (Latin)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_german), - com.frogobox.libkeyboard.R.xml.keys_letters_german + name = context.getString(com.frogobox.libkeyboard.R.string.translation_greek), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_greek, + layoutType = "Standard", + code = "EL", + script = "Ελληνικά (Greek)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_greek), - com.frogobox.libkeyboard.R.xml.keys_letters_greek + name = context.getString(com.frogobox.libkeyboard.R.string.translation_lithuanian), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_lithuanian, + layoutType = "ĄŽERTY", + code = "LT", + script = "Lietuvių (Baltic)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_lithuanian), - com.frogobox.libkeyboard.R.xml.keys_letters_lithuanian + name = context.getString(com.frogobox.libkeyboard.R.string.translation_romanian), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_romanian, + layoutType = "QWERTY", + code = "RO", + script = "Română (Latin)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_romanian), - com.frogobox.libkeyboard.R.xml.keys_letters_romanian + name = context.getString(com.frogobox.libkeyboard.R.string.translation_russian), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_russian, + layoutType = "ЙЦУКЕН", + code = "RU", + script = "Русский (Cyrillic)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_slovenian), - com.frogobox.libkeyboard.R.xml.keys_letters_slovenian + name = context.getString(com.frogobox.libkeyboard.R.string.translation_slovenian), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_slovenian, + layoutType = "QWERTZ", + code = "SL", + script = "Slovenščina (Latin)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_spanish), - com.frogobox.libkeyboard.R.xml.keys_letters_spanish_qwerty + name = context.getString(com.frogobox.libkeyboard.R.string.translation_spanish), + xml = com.frogobox.libkeyboard.R.xml.keys_letters_spanish_qwerty, + layoutType = "QWERTY", + code = "ES", + script = "Español (Latin)" ) ) list.add( KeyboardLanguage( - "${context.getString(com.frogobox.libkeyboard.R.string.translation_turkish)} (Q)", - com.frogobox.libkeyboard.R.xml.keys_letters_turkish_q + name = "${context.getString(com.frogobox.libkeyboard.R.string.translation_turkish)} (Q)", + xml = com.frogobox.libkeyboard.R.xml.keys_letters_turkish_q, + layoutType = "QWERTY", + code = "TR", + script = "Türkçe (Latin)" ) ) list.add( KeyboardLanguage( - context.getString(com.frogobox.libkeyboard.R.string.translation_persian), - com.frogobox.libkeyboard.R.xml.keys_letter_persian + name = context.getString(com.frogobox.libkeyboard.R.string.translation_persian), + xml = com.frogobox.libkeyboard.R.xml.keys_letter_persian, + layoutType = "Standard", + code = "FA", + script = "فارسی (Perso-Arabic)", + isRtl = true ) ) _keyboardLanguage.postValue(list) @@ -137,4 +198,11 @@ class KeyboardLanguageViewModel @Inject constructor( ) == xml } + fun getActiveKeyboardXml(): Int { + return pref.getPrefInt( + KeyboardUtil.KEYBOARD_TYPE, + com.frogobox.libkeyboard.R.xml.keys_letters_qwerty + ) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainActivity.kt index ea37ace..41ad94b 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainActivity.kt @@ -3,106 +3,50 @@ package com.frogobox.appkeyboard.ui.main import android.content.Intent import android.os.Bundle import android.provider.Settings -import android.util.Log import android.view.inputmethod.InputMethodManager import androidx.activity.viewModels -import com.frogobox.appkeyboard.databinding.ActivityMainBinding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.frogobox.appkeyboard.common.base.BaseComposeMainActivity import com.frogobox.appkeyboard.ui.autotext.AutoTextActivity import com.frogobox.appkeyboard.ui.language.KeyboardLanguageActivity +import com.frogobox.appkeyboard.ui.sound.SoundActivity import com.frogobox.appkeyboard.ui.test.TestActivity import com.frogobox.appkeyboard.ui.theme.ThemeActivity import com.frogobox.appkeyboard.ui.toggle.ToggleActivity -import com.frogobox.sdk.ext.getColorExt import com.frogobox.sdk.ext.startActivityExt import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint -class MainActivity : BaseMainActivity() { +class MainActivity : BaseComposeMainActivity() { + + private val viewModel: MainViewModel by viewModels() private val NONE = 0 private val PICKING = 1 private val CHOSEN = 2 - private var mState = 0 - companion object { - private val TAG: String = MainActivity::class.java.simpleName - } - - private val viewModel: MainViewModel by viewModels() - - override fun setupViewBinding(): ActivityMainBinding { - return ActivityMainBinding.inflate(layoutInflater) - } - - override fun setupViewModel() { - super.setupViewModel() - viewModel.apply { - - } - } + private var keyboardStatus by mutableStateOf(KeyboardStatus.NOT_ENABLED) override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) - if (savedInstanceState == null) { - // Call View Model Here - Log.d(TAG, "View Model : ${viewModel::class.java.simpleName}") - } - // TODO : Add your code here - initView() - } - - override fun onWindowFocusChanged(hasFocus: Boolean) { - super.onWindowFocusChanged(hasFocus) - if (mState === PICKING) { - mState = CHOSEN - } else if (mState === CHOSEN) { - handlingState() - } + updateKeyboardStatus() } override fun onResume() { super.onResume() - handlingState() + updateKeyboardStatus() } - override fun initView() { - super.initView() - binding.apply { - handlingState() - - btnChangeKeyboard.setOnClickListener { - (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker() - mState = PICKING - } - - btnGoToSetting.setOnClickListener { - Intent(Settings.ACTION_INPUT_METHOD_SETTINGS).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(this) - } - } - - btnDoSomeTest.setOnClickListener { - startActivityExt() - } - - btnAutoText.setOnClickListener { - startActivityExt() - } - - btnToggle.setOnClickListener { - startActivityExt() - } - - btnMultiLanguage.setOnClickListener { - startActivityExt() - } - - btnTheme.setOnClickListener { - startActivityExt() - } - + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (mState == PICKING) { + mState = CHOSEN + } else if (mState == CHOSEN) { + updateKeyboardStatus() } } @@ -120,21 +64,34 @@ class MainActivity : BaseMainActivity() { } } - private fun handlingState() { - binding.titleState.apply { - if (!isKeyboardEnabled()) { - text = "Frogo Keyboard Not Active" - setTextColor(getColorExt(com.frogobox.libkeyboard.R.color.status_failed)) - } else { - if (isUsingKeyboard()) { - text = "Frogo Keyboard Active" - setTextColor(getColorExt(com.frogobox.libkeyboard.R.color.status_success)) - } else { - text = "Not Using Frogo Keyboard" - setTextColor(getColorExt(com.frogobox.libkeyboard.R.color.status_warning)) - } - } + private fun updateKeyboardStatus() { + keyboardStatus = when { + !isKeyboardEnabled() -> KeyboardStatus.NOT_ENABLED + isUsingKeyboard() -> KeyboardStatus.ACTIVE + else -> KeyboardStatus.NOT_DEFAULT } } + @Composable + override fun Content() { + MainScreen( + status = keyboardStatus, + onGoToSettings = { + Intent(Settings.ACTION_INPUT_METHOD_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(this) + } + }, + onChangeKeyboard = { + (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker() + mState = PICKING + }, + onNavigateAutoText = { startActivityExt() }, + onNavigateToggle = { startActivityExt() }, + onNavigateLanguage = { startActivityExt() }, + onNavigateTheme = { startActivityExt() }, + onNavigateSound = { startActivityExt() }, + onNavigateTest = { startActivityExt() } + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainScreen.kt new file mode 100644 index 0000000..3e18982 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainScreen.kt @@ -0,0 +1,473 @@ +package com.frogobox.appkeyboard.ui.main + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.R +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusFailed +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusSuccess +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusWarning +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar + +enum class KeyboardStatus( + val title: String, + val description: String, + val badge: String, + val color: Color, + val icon: ImageVector +) { + ACTIVE( + title = "Frogo Keyboard Active", + description = "Keyboard is activated and set as default input method.", + badge = "ACTIVE", + color = FrogoStatusSuccess, + icon = Icons.Default.CheckCircle + ), + NOT_DEFAULT( + title = "Not Default Keyboard", + description = "Keyboard is enabled, but another keyboard is currently selected.", + badge = "SELECT FROGO", + color = FrogoStatusWarning, + icon = Icons.Default.Warning + ), + NOT_ENABLED( + title = "Frogo Keyboard Inactive", + description = "Enable Frogo Keyboard in Android system settings to get started.", + badge = "NOT ENABLED", + color = FrogoStatusFailed, + icon = Icons.Default.Info + ) +} + +@Composable +fun MainScreen( + status: KeyboardStatus, + onGoToSettings: () -> Unit, + onChangeKeyboard: () -> Unit, + onNavigateAutoText: () -> Unit, + onNavigateToggle: () -> Unit, + onNavigateLanguage: () -> Unit, + onNavigateTheme: () -> Unit, + onNavigateSound: () -> Unit, + onNavigateTest: () -> Unit, + modifier: Modifier = Modifier +) { + val scrollState = rememberScrollState() + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Frogo Keyboard" + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(paddingValues) + .padding(horizontal = 20.dp, vertical = 12.dp), + horizontalAlignment = Alignment.Start + ) { + // Header Description + Text( + text = "Enable and set as default keyboard to start typing with custom features and personalized tools.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Reactive Status Card + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = status.color.copy(alpha = 0.12f) + ), + border = BorderStroke(1.dp, status.color.copy(alpha = 0.35f)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(42.dp) + .clip(CircleShape) + .background(status.color.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = status.icon, + contentDescription = null, + tint = status.color, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = status.title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 15.sp + ), + color = status.color + ) + + Surface( + shape = RoundedCornerShape(6.dp), + color = status.color.copy(alpha = 0.2f) + ) { + Text( + text = status.badge, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp + ), + color = status.color, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = status.description, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Activation Steps Row (Step 1: Settings, Step 2: Switch Keyboard) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + ActivationCard( + stepNumber = "1", + title = "Enable Keyboard", + subtitle = "System Settings", + iconRes = R.drawable.ic_menu_setting, + onClick = onGoToSettings, + modifier = Modifier.weight(1f) + ) + + ActivationCard( + stepNumber = "2", + title = "Set Default", + subtitle = "Switch Keyboard", + iconRes = R.drawable.ic_menu_keyboard, + onClick = onChangeKeyboard, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Section Header + Text( + text = "Features & Customization", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ), + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Feature Menu List (Material 3 Cards with Icon, Subtitle, and Chevron) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + MainMenuItemCard( + title = "Auto Text", + subtitle = "Quick snippets & repetitive text templates", + iconRes = R.drawable.ic_menu_auto_text, + onClick = onNavigateAutoText + ) + + MainMenuItemCard( + title = "Toggle Function", + subtitle = "Keyboard shortcuts & toolbar switches", + iconRes = R.drawable.ic_menu_form, + onClick = onNavigateToggle + ) + + MainMenuItemCard( + title = "Sound & Haptics", + subtitle = "Mechanical switch sounds & key vibration", + iconRes = R.drawable.ic_menu_sound, + onClick = onNavigateSound + ) + + MainMenuItemCard( + title = "Keyboard Language", + subtitle = "Multilingual input languages & layouts", + iconRes = R.drawable.ic_menu_website, + onClick = onNavigateLanguage + ) + + MainMenuItemCard( + title = "Keyboard Theme", + subtitle = "Personalized colors, themes & wallpapers", + iconRes = R.drawable.ic_menu_ps_game, + onClick = onNavigateTheme + ) + + MainMenuItemCard( + title = "Do Some Test", + subtitle = "Interactive typing test & keyboard sandbox", + iconRes = R.drawable.ic_menu_keyboard, + onClick = onNavigateTest + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun ActivationCard( + stepNumber: String, + title: String, + subtitle: String, + iconRes: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.Start + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = iconRes), + contentDescription = title, + modifier = Modifier.size(36.dp) + ) + + Surface( + shape = RoundedCornerShape(6.dp), + color = FrogoPrimary.copy(alpha = 0.1f) + ) { + Text( + text = "STEP $stepNumber", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp + ), + color = FrogoPrimary, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = title, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun MainMenuItemCard( + title: String, + subtitle: String, + iconRes: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = iconRes), + contentDescription = title, + modifier = Modifier.size(36.dp) + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(20.dp) + ) + } + } +} + +@Preview(name = "Light Mode - Active", showBackground = true) +@Composable +fun MainScreenActivePreview() { + FrogoKeyboardTheme(darkTheme = false) { + MainScreen( + status = KeyboardStatus.ACTIVE, + onGoToSettings = {}, + onChangeKeyboard = {}, + onNavigateAutoText = {}, + onNavigateToggle = {}, + onNavigateLanguage = {}, + onNavigateTheme = {}, + onNavigateSound = {}, + onNavigateTest = {} + ) + } +} + +@Preview(name = "Dark Mode - Inactive", showBackground = true) +@Composable +fun MainScreenInactiveDarkPreview() { + FrogoKeyboardTheme(darkTheme = true) { + MainScreen( + status = KeyboardStatus.NOT_ENABLED, + onGoToSettings = {}, + onChangeKeyboard = {}, + onNavigateAutoText = {}, + onNavigateToggle = {}, + onNavigateLanguage = {}, + onNavigateTheme = {}, + onNavigateSound = {}, + onNavigateTest = {} + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundActivity.kt new file mode 100644 index 0000000..62e609b --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundActivity.kt @@ -0,0 +1,47 @@ +package com.frogobox.appkeyboard.ui.sound + +import android.os.Bundle +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.frogobox.appkeyboard.common.base.BaseComposeActivity +import com.frogobox.libkeyboard.common.sound.MechanicalSoundType +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class SoundActivity : BaseComposeActivity() { + + private val viewModel: SoundViewModel by viewModels() + + private var soundEnabled by mutableStateOf(true) + private var selectedSoundType by mutableStateOf(MechanicalSoundType.CHERRY_MX_BLUE) + private var soundVolume by mutableStateOf(80) + private var vibrateEnabled by mutableStateOf(true) + + override fun onCreateExt(savedInstanceState: Bundle?) { + super.onCreateExt(savedInstanceState) + viewModel.soundEnabled.observe(this) { soundEnabled = it } + viewModel.selectedSoundType.observe(this) { selectedSoundType = it } + viewModel.soundVolume.observe(this) { soundVolume = it } + viewModel.vibrateEnabled.observe(this) { vibrateEnabled = it } + viewModel.loadSettings() + } + + @Composable + override fun Content() { + SoundScreen( + soundEnabled = soundEnabled, + selectedSoundType = selectedSoundType, + soundVolume = soundVolume, + vibrateEnabled = vibrateEnabled, + onSoundEnabledChange = { viewModel.setSoundEnabled(it) }, + onSoundTypeSelected = { viewModel.setSoundType(it) }, + onVolumeChange = { viewModel.setSoundVolume(it) }, + onVibrateChange = { viewModel.setVibrateEnabled(it) }, + onTestKeyTap = { viewModel.playTestKeySound() }, + onBackClick = { finish() } + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundScreen.kt new file mode 100644 index 0000000..75b2b8e --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundScreen.kt @@ -0,0 +1,615 @@ +package com.frogobox.appkeyboard.ui.sound + +import android.view.HapticFeedbackConstants +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusSuccess +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar +import com.frogobox.libkeyboard.common.sound.MechanicalSoundType + +@Composable +fun SoundScreen( + soundEnabled: Boolean, + selectedSoundType: MechanicalSoundType, + soundVolume: Int, + vibrateEnabled: Boolean, + onSoundEnabledChange: (Boolean) -> Unit, + onSoundTypeSelected: (MechanicalSoundType) -> Unit, + onVolumeChange: (Int) -> Unit, + onVibrateChange: (Boolean) -> Unit, + onTestKeyTap: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + val view = LocalView.current + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Keyboard Sound & Haptics", + onBackClick = onBackClick + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 28.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Master Sound Feedback Switch Card + item(span = { GridItemSpan(maxLineSpan) }) { + MasterSoundCard( + soundEnabled = soundEnabled, + selectedSoundType = selectedSoundType, + onSoundEnabledChange = onSoundEnabledChange + ) + } + + // Section Header: Switch Profiles + item(span = { GridItemSpan(maxLineSpan) }) { + Column(modifier = Modifier.padding(top = 8.dp, bottom = 2.dp)) { + Text( + text = "MECHANICAL SWITCH PROFILES (MECHVIBES)", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + letterSpacing = 0.8.sp + ), + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Authentic tactile & acoustic mechanical switch models", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Grid of Switch Profiles + items(MechanicalSoundType.entries.toList(), key = { it.id }) { type -> + SoundProfileCard( + profile = type, + isSelected = selectedSoundType == type, + onClick = { + onSoundTypeSelected(type) + if (vibrateEnabled) { + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + } + } + ) + } + + // Section Header: Volume & Haptics + item(span = { GridItemSpan(maxLineSpan) }) { + Column(modifier = Modifier.padding(top = 10.dp, bottom = 2.dp)) { + Text( + text = "VOLUME & HAPTIC FEEDBACK", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + letterSpacing = 0.8.sp + ), + color = MaterialTheme.colorScheme.primary + ) + } + } + + // Volume Card + item(span = { GridItemSpan(maxLineSpan) }) { + VolumeControlCard( + volume = soundVolume, + soundEnabled = soundEnabled, + onVolumeChange = onVolumeChange + ) + } + + // Haptic Vibration Card + item(span = { GridItemSpan(maxLineSpan) }) { + HapticVibrationCard( + vibrateEnabled = vibrateEnabled, + onVibrateChange = { enabled -> + onVibrateChange(enabled) + if (enabled) { + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + } + } + ) + } + + // Interactive Mechanical Sound Playground + item(span = { GridItemSpan(maxLineSpan) }) { + LiveSoundPlaygroundCard( + selectedSoundType = selectedSoundType, + soundEnabled = soundEnabled, + onKeyTap = { + onTestKeyTap() + if (vibrateEnabled) { + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + } + } + ) + } + } + } +} + +@Composable +private fun MasterSoundCard( + soundEnabled: Boolean, + selectedSoundType: MechanicalSoundType, + onSoundEnabledChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val primaryColor = MaterialTheme.colorScheme.primary + val cardBackground by animateColorAsState( + targetValue = if (soundEnabled) primaryColor.copy(alpha = 0.08f) else MaterialTheme.colorScheme.surface, + label = "cardBg" + ) + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = cardBackground), + border = BorderStroke( + width = 1.dp, + color = if (soundEnabled) primaryColor.copy(alpha = 0.4f) else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(46.dp) + .clip(CircleShape) + .background( + if (soundEnabled) FrogoPrimary.copy(alpha = 0.15f) + else MaterialTheme.colorScheme.surfaceVariant + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = null, + tint = if (soundEnabled) FrogoPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Mechanical Sound Feedback", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 15.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = if (soundEnabled) "Active: ${selectedSoundType.title}" else "Acoustic feedback muted", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Switch( + checked = soundEnabled, + onCheckedChange = onSoundEnabledChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = FrogoPrimary + ) + ) + } + } +} + +@Composable +private fun SoundProfileCard( + profile: MechanicalSoundType, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val primaryColor = MaterialTheme.colorScheme.primary + val borderColor by animateColorAsState( + targetValue = if (isSelected) primaryColor else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), + label = "border" + ) + val containerColor by animateColorAsState( + targetValue = if (isSelected) primaryColor.copy(alpha = 0.08f) else MaterialTheme.colorScheme.surface, + label = "container" + ) + + Card( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = containerColor), + border = BorderStroke(if (isSelected) 1.5.dp else 1.dp, borderColor), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = if (isSelected) FrogoPrimary.copy(alpha = 0.15f) else MaterialTheme.colorScheme.surfaceVariant + ) { + Text( + text = when (profile) { + MechanicalSoundType.CHERRY_MX_BLUE -> "CLICKY" + MechanicalSoundType.CHERRY_MX_BROWN -> "TACTILE" + MechanicalSoundType.CHERRY_MX_RED -> "LINEAR" + MechanicalSoundType.TYPEWRITER -> "VINTAGE" + MechanicalSoundType.SYSTEM_CLICK -> "SYSTEM" + MechanicalSoundType.OFF -> "MUTE" + }, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 9.sp + ), + color = if (isSelected) FrogoPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + + if (isSelected) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Selected", + tint = FrogoStatusSuccess, + modifier = Modifier.size(18.dp) + ) + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = profile.title, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = profile.description, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun VolumeControlCard( + volume: Int, + soundEnabled: Boolean, + onVolumeChange: (Int) -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Acoustic Volume", + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + + Text( + text = if (soundEnabled) "$volume%" else "Muted", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + color = FrogoPrimary + ) + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + + Slider( + value = volume.toFloat(), + onValueChange = { onVolumeChange(it.toInt()) }, + valueRange = 5f..100f, + enabled = soundEnabled, + colors = SliderDefaults.colors( + thumbColor = FrogoPrimary, + activeTrackColor = FrogoPrimary, + inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) + } + } +} + +@Composable +private fun HapticVibrationCard( + vibrateEnabled: Boolean, + onVibrateChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(42.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.PhoneAndroid, + contentDescription = null, + tint = if (vibrateEnabled) FrogoPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Keypress Vibration (Haptic)", + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Subtle tactile pulse on key touch", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Switch( + checked = vibrateEnabled, + onCheckedChange = onVibrateChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = FrogoPrimary + ) + ) + } + } +} + +@Composable +private fun LiveSoundPlaygroundCard( + selectedSoundType: MechanicalSoundType, + soundEnabled: Boolean, + onKeyTap: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) + ), + border = BorderStroke(1.dp, FrogoPrimary.copy(alpha = 0.3f)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "LIVE MECHANICAL KEYBOARD PLAYGROUND", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + letterSpacing = 0.8.sp + ), + color = FrogoPrimary + ) + + Surface( + shape = RoundedCornerShape(6.dp), + color = FrogoPrimary.copy(alpha = 0.15f) + ) { + Text( + text = "TEST ACOUSTICS", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 9.sp + ), + color = FrogoPrimary, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = "Tap keycaps below to experience ${selectedSoundType.title} acoustics and tactile vibration:", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(14.dp)) + + // Simulated Keycaps Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + listOf("Q", "W", "E", "R").forEach { keyChar -> + PlaygroundKeycap( + label = keyChar, + onClick = onKeyTap, + modifier = Modifier.weight(1f) + ) + } + + PlaygroundKeycap( + label = "SPACE", + onClick = onKeyTap, + modifier = Modifier.weight(2f) + ) + + PlaygroundKeycap( + label = "ENTER", + onClick = onKeyTap, + modifier = Modifier.weight(1.5f), + isAction = true + ) + } + } + } +} + +@Composable +private fun PlaygroundKeycap( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isAction: Boolean = false +) { + Surface( + modifier = modifier + .height(44.dp) + .clip(RoundedCornerShape(10.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(10.dp), + color = if (isAction) FrogoPrimary else MaterialTheme.colorScheme.surface, + border = BorderStroke( + 1.dp, + if (isAction) FrogoPrimary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ), + shadowElevation = 2.dp + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = if (label.length > 2) 11.sp else 14.sp + ), + color = if (isAction) Color.White else MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundViewModel.kt new file mode 100644 index 0000000..2092b52 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/sound/SoundViewModel.kt @@ -0,0 +1,88 @@ +package com.frogobox.appkeyboard.ui.sound + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.frogobox.libkeyboard.common.sound.MechanicalSoundManager +import com.frogobox.libkeyboard.common.sound.MechanicalSoundType +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard +import com.frogobox.sdk.delegate.preference.PreferenceDelegates +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class SoundViewModel @Inject constructor( + application: Application, + private val pref: PreferenceDelegates +) : AndroidViewModel(application) { + + private val _soundEnabled = MutableLiveData() + val soundEnabled: LiveData = _soundEnabled + + private val _selectedSoundType = MutableLiveData() + val selectedSoundType: LiveData = _selectedSoundType + + private val _soundVolume = MutableLiveData() + val soundVolume: LiveData = _soundVolume + + private val _vibrateEnabled = MutableLiveData() + val vibrateEnabled: LiveData = _vibrateEnabled + + init { + loadSettings() + } + + fun loadSettings() { + val soundOn = pref.getPrefBoolean(MechanicalSoundManager.PREF_KEYBOARD_SOUND_ENABLED, true) + val soundTypeId = pref.getPrefString( + MechanicalSoundManager.PREF_KEYBOARD_SOUND_TYPE, + MechanicalSoundType.CHERRY_MX_BLUE.id + ) + val volume = pref.getPrefInt(MechanicalSoundManager.PREF_KEYBOARD_SOUND_VOLUME, 80) + val vibrateOn = pref.getPrefBoolean(MechanicalSoundManager.PREF_KEYBOARD_VIBRATE_ENABLED, true) + + _soundEnabled.value = soundOn + _selectedSoundType.value = MechanicalSoundType.fromId(soundTypeId) + _soundVolume.value = volume + _vibrateEnabled.value = vibrateOn + + ItemMainKeyboard.SOUND_ON_KEYPRESS = soundOn + ItemMainKeyboard.MECHANICAL_SOUND_TYPE = soundTypeId + ItemMainKeyboard.SOUND_VOLUME = volume / 100f + ItemMainKeyboard.VIBRATE_ON_KEYPRESS = vibrateOn + } + + fun setSoundEnabled(enabled: Boolean) { + pref.savePrefBoolean(MechanicalSoundManager.PREF_KEYBOARD_SOUND_ENABLED, enabled) + ItemMainKeyboard.SOUND_ON_KEYPRESS = enabled + _soundEnabled.value = enabled + } + + fun setSoundType(type: MechanicalSoundType) { + pref.savePrefString(MechanicalSoundManager.PREF_KEYBOARD_SOUND_TYPE, type.id) + ItemMainKeyboard.MECHANICAL_SOUND_TYPE = type.id + _selectedSoundType.value = type + playTestKeySound() + } + + fun setSoundVolume(volume: Int) { + pref.savePrefInt(MechanicalSoundManager.PREF_KEYBOARD_SOUND_VOLUME, volume) + ItemMainKeyboard.SOUND_VOLUME = volume / 100f + _soundVolume.value = volume + } + + fun setVibrateEnabled(enabled: Boolean) { + pref.savePrefBoolean(MechanicalSoundManager.PREF_KEYBOARD_VIBRATE_ENABLED, enabled) + ItemMainKeyboard.VIBRATE_ON_KEYPRESS = enabled + _vibrateEnabled.value = enabled + } + + fun playTestKeySound() { + val isSoundOn = _soundEnabled.value ?: true + if (!isSoundOn) return + val soundType = _selectedSoundType.value ?: MechanicalSoundType.CHERRY_MX_BLUE + val volume = (_soundVolume.value ?: 80) / 100f + MechanicalSoundManager.getInstance(getApplication()).playKeySound(soundType, volume) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestActivity.kt index fc3c713..03f0348 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestActivity.kt @@ -1,16 +1,21 @@ package com.frogobox.appkeyboard.ui.test +import android.content.Intent import android.os.Bundle +import android.provider.Settings import android.util.Log -import android.widget.ArrayAdapter +import android.view.inputmethod.InputMethodManager import androidx.activity.viewModels -import com.frogobox.appkeyboard.R -import com.frogobox.appkeyboard.common.base.BaseActivity -import com.frogobox.appkeyboard.databinding.ActivityTestBinding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.frogobox.appkeyboard.common.base.BaseComposeActivity import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint -class TestActivity : BaseActivity() { +class TestActivity : BaseComposeActivity() { companion object { private val TAG: String = TestActivity::class.java.simpleName @@ -18,43 +23,88 @@ class TestActivity : BaseActivity() { private val viewModel: TestViewModel by viewModels() - override fun setupViewBinding(): ActivityTestBinding { - return ActivityTestBinding.inflate(layoutInflater) - } + private var isUsingFrogoKeyboard by mutableStateOf(false) + private var isFrogoKeyboardEnabled by mutableStateOf(false) - override fun setupViewModel() { - super.setupViewModel() - viewModel.apply { - - } - } + private val dummyOptions = listOf( + "Kuningan", + "Menteng", + "Pegangsaan", + "Kebon Jeruk", + "Kemang", + "Senayan", + "Sudirman", + "Thamrin" + ) override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) - setupDetailActivity("Test Area") if (savedInstanceState == null) { - // Call View Model Here - Log.d(TAG, "View Model : ${viewModel::class.java.simpleName}") + Log.d(TAG, "Initialized TestActivity with ViewModel: ${viewModel::class.java.simpleName}") } - // TODO : Add your code here - - } - - override fun initView() { - super.initView() - binding.apply { - val dummy = listOf("Kuningan", - "Menteng", - "Menten213g", - "Men123teng", - "Mw23423", - "Me123nteng", - "Mente234234234ng", - "Pegangsaan" - ) - val adapterS = ArrayAdapter(this@TestActivity, R.layout.item_spinner, R.id.tv_text, dummy) - etTextAuto.setAdapter(adapterS) + updateKeyboardStatus() + } + + override fun onResume() { + super.onResume() + updateKeyboardStatus() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + updateKeyboardStatus() } } + private fun isUsingKeyboard(): Boolean { + val currentKeyboard = Settings.Secure.getString(contentResolver, Settings.Secure.DEFAULT_INPUT_METHOD) + val targetKeyboard = "$packageName/com.frogobox.appkeyboard.services.KeyboardIME" + return currentKeyboard == targetKeyboard + } + + private fun isKeyboardEnabled(): Boolean { + val inputMethodManager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + val enabledKeyboards = inputMethodManager.enabledInputMethodList + return enabledKeyboards.any { + it.serviceInfo.packageName == packageName + } + } + + private fun updateKeyboardStatus() { + isUsingFrogoKeyboard = isUsingKeyboard() + isFrogoKeyboardEnabled = isKeyboardEnabled() + } + + @Composable + override fun Content() { + val sandboxText by viewModel.sandboxText.collectAsState() + val metrics by viewModel.metrics.collectAsState() + val activeTab by viewModel.activeTab.collectAsState() + val autoTextList by viewModel.autoTextList.collectAsState() + + TestScreen( + sandboxText = sandboxText, + metrics = metrics, + activeTab = activeTab, + autoTextList = autoTextList, + isKeyboardActive = isUsingFrogoKeyboard, + dummyOptions = dummyOptions, + onTextChanged = viewModel::onTextChanged, + onInsertText = viewModel::onInsertText, + onClearText = viewModel::onClearText, + onResetTimer = viewModel::onResetTimer, + onTabSelected = viewModel::onTabSelected, + onChangeKeyboard = { + (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker() + }, + onGoToSettings = { + val intent = Intent(Settings.ACTION_INPUT_METHOD_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + startActivity(intent) + }, + onBackClick = { finish() } + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestScreen.kt new file mode 100644 index 0000000..7f82c6b --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestScreen.kt @@ -0,0 +1,765 @@ +package com.frogobox.appkeyboard.ui.test + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.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.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.model.AutoTextEntity +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusSuccess +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusWarning +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar + +/** + * Modern Material 3 Keyboard Testing Playground & Sandbox + * Redesigned for TASK-010 by Tim Mobile + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TestScreen( + sandboxText: String, + metrics: TypingMetrics, + activeTab: Int, + autoTextList: List, + isKeyboardActive: Boolean, + dummyOptions: List = emptyList(), + onTextChanged: (String) -> Unit, + onInsertText: (String) -> Unit, + onClearText: () -> Unit, + onResetTimer: () -> Unit, + onTabSelected: (Int) -> Unit, + onChangeKeyboard: () -> Unit, + onGoToSettings: () -> Unit, + onBackClick: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val scrollState = rememberScrollState() + + // Local states for specialized input tab + var searchInput by remember { mutableStateOf("") } + var numberInput by remember { mutableStateOf("") } + var phoneInput by remember { mutableStateOf("") } + var passwordInput by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + var autoDropdownText by remember { mutableStateOf("") } + var dropdownExpanded by remember { mutableStateOf(false) } + + val filteredDropdownOptions = remember(autoDropdownText, dummyOptions) { + if (autoDropdownText.isBlank()) { + dummyOptions + } else { + dummyOptions.filter { it.contains(autoDropdownText, ignoreCase = true) } + } + } + + FrogoKeyboardTheme { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Test Area", + onBackClick = onBackClick + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(scrollState) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + // 1. Active Keyboard Status Card + KeyboardStatusCard( + isFrogoActive = isKeyboardActive, + onChangeKeyboard = onChangeKeyboard, + onGoToSettings = onGoToSettings + ) + + // 2. Real-time Live Typing Metrics Card + LiveMetricsCard(metrics = metrics) + + // 3. Tab Navigation Row + PrimaryTabRow( + selectedTabIndex = activeTab, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = FrogoPrimary + ) { + Tab( + selected = activeTab == 0, + onClick = { onTabSelected(0) }, + text = { Text("Sandbox", fontWeight = FontWeight.SemiBold) }, + icon = { Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(20.dp)) } + ) + Tab( + selected = activeTab == 1, + onClick = { onTabSelected(1) }, + text = { Text("Inputs", fontWeight = FontWeight.SemiBold) }, + icon = { Icon(Icons.Default.Keyboard, contentDescription = null, modifier = Modifier.size(20.dp)) } + ) + Tab( + selected = activeTab == 2, + onClick = { onTabSelected(2) }, + text = { Text("AutoText", fontWeight = FontWeight.SemiBold) }, + icon = { Icon(Icons.AutoMirrored.Filled.Notes, contentDescription = null, modifier = Modifier.size(20.dp)) } + ) + } + + // 4. Tab Body Content + when (activeTab) { + 0 -> SandboxTabContent( + text = sandboxText, + onTextChanged = onTextChanged, + onInsertText = onInsertText, + onClearText = onClearText, + onResetTimer = onResetTimer, + onCopyText = { + if (sandboxText.isNotEmpty()) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Sandbox Text", sandboxText)) + Toast.makeText(context, "Text copied to clipboard", Toast.LENGTH_SHORT).show() + } + } + ) + 1 -> SpecializedInputsTabContent( + searchInput = searchInput, + onSearchChange = { searchInput = it }, + numberInput = numberInput, + onNumberChange = { numberInput = it }, + phoneInput = phoneInput, + onPhoneChange = { phoneInput = it }, + passwordInput = passwordInput, + onPasswordChange = { passwordInput = it }, + passwordVisible = passwordVisible, + onTogglePasswordVisibility = { passwordVisible = !passwordVisible }, + autoDropdownText = autoDropdownText, + onDropdownTextChange = { autoDropdownText = it }, + dropdownExpanded = dropdownExpanded, + onDropdownExpandedChange = { dropdownExpanded = it }, + dropdownOptions = filteredDropdownOptions + ) + 2 -> AutoTextTabContent( + snippets = autoTextList, + onSnippetClicked = { snippet -> + onInsertText(snippet.body) + Toast.makeText(context, "Inserted '${snippet.title}' to sandbox", Toast.LENGTH_SHORT).show() + onTabSelected(0) + } + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} + +/** + * 1. Active Keyboard Status Card Component + */ +@Composable +private fun KeyboardStatusCard( + isFrogoActive: Boolean, + onChangeKeyboard: () -> Unit, + onGoToSettings: () -> Unit, + modifier: Modifier = Modifier +) { + val statusColor = if (isFrogoActive) FrogoStatusSuccess else FrogoStatusWarning + val statusTitle = if (isFrogoActive) "Frogo Keyboard Active" else "Other Keyboard Active" + val statusDesc = if (isFrogoActive) { + "Frogo Keyboard is default. Testing real keyboard layouts." + } else { + "External keyboard is active. Switch to test Frogo features." + } + val icon = if (isFrogoActive) Icons.Default.CheckCircle else Icons.Default.Warning + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = statusColor.copy(alpha = 0.08f) + ), + border = BorderStroke(1.dp, statusColor.copy(alpha = 0.25f)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(statusColor.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = statusColor, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = statusTitle, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = statusDesc, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = onChangeKeyboard, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors(containerColor = FrogoPrimary) + ) { + Icon(Icons.Default.Keyboard, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Switch Keyboard", fontSize = 13.sp) + } + + OutlinedButton( + onClick = onGoToSettings, + shape = RoundedCornerShape(10.dp) + ) { + Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Settings", fontSize = 13.sp) + } + } + } + } +} + +/** + * 2. Real-time Live Typing Metrics Card Component + */ +@Composable +private fun LiveMetricsCard( + metrics: TypingMetrics, + modifier: Modifier = Modifier +) { + ElevatedCard( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Live Typing Analytics", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "${metrics.elapsedSeconds}s elapsed", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + MetricItem(label = "Characters", value = "${metrics.characterCount}", modifier = Modifier.weight(1f)) + MetricItem(label = "Words", value = "${metrics.wordCount}", modifier = Modifier.weight(1f)) + MetricItem(label = "Lines", value = "${metrics.lineCount}", modifier = Modifier.weight(1f)) + MetricItem( + label = "Typing Speed", + value = "${metrics.wordsPerMinute} WPM", + highlight = true, + modifier = Modifier.weight(1.2f) + ) + } + } + } +} + +@Composable +private fun MetricItem( + label: String, + value: String, + highlight: Boolean = false, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = value, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.ExtraBold, + color = if (highlight) FrogoPrimary else MaterialTheme.colorScheme.onSurface + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * 3. Tab 0: Freeform Sandbox & Preset Pangrams + */ +@Composable +private fun SandboxTabContent( + text: String, + onTextChanged: (String) -> Unit, + onInsertText: (String) -> Unit, + onClearText: () -> Unit, + onResetTimer: () -> Unit, + onCopyText: () -> Unit, + modifier: Modifier = Modifier +) { + val pangramsScroll = rememberScrollState() + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Preset Pangrams horizontal chips + Text( + text = "Quick Presets & Pangrams", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(pangramsScroll), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + AssistChip( + onClick = { onInsertText("The quick brown fox jumps over the lazy dog") }, + label = { Text("English Pangram") } + ) + AssistChip( + onClick = { onInsertText("Keluarga Jefri bernomor fax tujuh belas") }, + label = { Text("Indonesian Pangram") } + ) + AssistChip( + onClick = { onInsertText("1234567890 !@#$%^&*()_+") }, + label = { Text("Numbers & Symbols") } + ) + AssistChip( + onClick = { onInsertText("😀🚀✨🎉🔥👍💯❤️") }, + label = { Text("Emoji Palette") } + ) + } + + // Multiline Editor Area + OutlinedTextField( + value = text, + onValueChange = onTextChanged, + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + placeholder = { Text("Type anywhere here with your keyboard to test metrics, latency, and keys...") }, + shape = RoundedCornerShape(16.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = FrogoPrimary, + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface + ) + ) + + // Action Toolbar: Copy, Clear, Reset Timer + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onCopyText, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp) + ) { + Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Copy", fontSize = 13.sp) + } + + OutlinedButton( + onClick = onResetTimer, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp) + ) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Reset WPM", fontSize = 13.sp) + } + + Button( + onClick = onClearText, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Clear", fontSize = 13.sp) + } + } + } +} + +/** + * 4. Tab 1: Specialized Input Types (Search, Number, Phone, Password, AutoComplete) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SpecializedInputsTabContent( + searchInput: String, + onSearchChange: (String) -> Unit, + numberInput: String, + onNumberChange: (String) -> Unit, + phoneInput: String, + onPhoneChange: (String) -> Unit, + passwordInput: String, + onPasswordChange: (String) -> Unit, + passwordVisible: Boolean, + onTogglePasswordVisibility: () -> Unit, + autoDropdownText: String, + onDropdownTextChange: (String) -> Unit, + dropdownExpanded: Boolean, + onDropdownExpandedChange: (Boolean) -> Unit, + dropdownOptions: List, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + // Search Input + OutlinedTextField( + value = searchInput, + onValueChange = onSearchChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Search Input (ImeAction.Search)") }, + placeholder = { Text("Search keywords...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (searchInput.isNotEmpty()) { + IconButton(onClick = { onSearchChange("") }) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + }, + singleLine = true, + shape = RoundedCornerShape(14.dp), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Search + ) + ) + + // Number Input + OutlinedTextField( + value = numberInput, + onValueChange = onNumberChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Numeric Keypad (KeyboardType.Number)") }, + placeholder = { Text("12345678") }, + singleLine = true, + shape = RoundedCornerShape(14.dp), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next + ) + ) + + // Phone Input + OutlinedTextField( + value = phoneInput, + onValueChange = onPhoneChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Phone Keypad (KeyboardType.Phone)") }, + placeholder = { Text("+62 812-3456-7890") }, + singleLine = true, + shape = RoundedCornerShape(14.dp), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone, + imeAction = ImeAction.Next + ) + ) + + // Password Input + OutlinedTextField( + value = passwordInput, + onValueChange = onPasswordChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Password (Visibility Toggle)") }, + placeholder = { Text("Enter secret password...") }, + singleLine = true, + shape = RoundedCornerShape(14.dp), + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + trailingIcon = { + val icon = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff + IconButton(onClick = onTogglePasswordVisibility) { + Icon(icon, contentDescription = if (passwordVisible) "Hide password" else "Show password") + } + } + ) + + // AutoComplete Dropdown + ExposedDropdownMenuBox( + expanded = dropdownExpanded, + onExpandedChange = onDropdownExpandedChange, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = autoDropdownText, + onValueChange = { + onDropdownTextChange(it) + onDropdownExpandedChange(true) + }, + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, true) + .fillMaxWidth(), + label = { Text("Searchable Dropdown (Filter Chips)") }, + placeholder = { Text("Type city name...") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded) }, + shape = RoundedCornerShape(14.dp), + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors() + ) + + if (dropdownOptions.isNotEmpty()) { + ExposedDropdownMenu( + expanded = dropdownExpanded, + onDismissRequest = { onDropdownExpandedChange(false) } + ) { + dropdownOptions.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + onDropdownTextChange(option) + onDropdownExpandedChange(false) + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding + ) + } + } + } + } + } +} + +/** + * 5. Tab 2: AutoText Snippets Showcase (Tap to Insert) + */ +@Composable +private fun AutoTextTabContent( + snippets: List, + onSnippetClicked: (AutoTextEntity) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = "Tap any AutoText snippet to insert it into the test sandbox:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + snippets.forEach { snippet -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onSnippetClicked(snippet) }, + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(FrogoPrimary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Notes, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(18.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = snippet.title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = snippet.label.name, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = snippet.body, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestViewModel.kt index cd31092..67dfd24 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestViewModel.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestViewModel.kt @@ -1,24 +1,124 @@ package com.frogobox.appkeyboard.ui.test +import androidx.lifecycle.viewModelScope import com.frogobox.appkeyboard.common.base.BaseViewModel +import com.frogobox.appkeyboard.model.AutoTextEntity +import com.frogobox.appkeyboard.model.AutoTextLabelType import com.frogobox.appkeyboard.repository.autotext.AutoTextRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.launch import javax.inject.Inject /** * Created by Faisal Amir on 24/10/22 - * ----------------------------------------- - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - * Copyright (C) Frogobox ID / amirisback - * All rights reserved + * Redesigned for TASK-010 by Tim Mobile */ - @HiltViewModel class TestViewModel @Inject constructor( private val repository: AutoTextRepository -): BaseViewModel() { +) : BaseViewModel() { + + private val _sandboxText = MutableStateFlow("") + val sandboxText: StateFlow = _sandboxText.asStateFlow() + + private val _activeTab = MutableStateFlow(0) + val activeTab: StateFlow = _activeTab.asStateFlow() + + private val _metrics = MutableStateFlow(TypingMetrics()) + val metrics: StateFlow = _metrics.asStateFlow() + + private val _autoTextList = MutableStateFlow>(emptyList()) + val autoTextList: StateFlow> = _autoTextList.asStateFlow() + + private var typingStartTimestamp: Long = 0L + + init { + loadAutoTextSnippets() + } + + private fun loadAutoTextSnippets() { + viewModelScope.launch { + repository.getAutoText() + .catch { emit(getDefaultTestSnippets()) } + .collect { list -> + if (list.isEmpty()) { + _autoTextList.value = getDefaultTestSnippets() + } else { + _autoTextList.value = list + } + } + } + } + + private fun getDefaultTestSnippets(): List { + return listOf( + AutoTextEntity( + id = -1, + title = "Greeting", + body = "Hello! Hope you are having a wonderful and productive day.", + label = AutoTextLabelType.DEFAULT, + isActive = true + ), + AutoTextEntity( + id = -2, + title = "Meeting Confirmation", + body = "Looking forward to our sync meeting tomorrow at 10:00 AM.", + label = AutoTextLabelType.DEFAULT, + isActive = true + ), + AutoTextEntity( + id = -3, + title = "Quick Sign-off", + body = "Kind regards,\nFrogo Keyboard Development Team", + label = AutoTextLabelType.DEFAULT, + isActive = true + ), + AutoTextEntity( + id = -4, + title = "Fast Response", + body = "Thanks for reaching out! I will review and reply as soon as possible.", + label = AutoTextLabelType.DEFAULT, + isActive = true + ) + ) + } + + fun onTextChanged(newText: String) { + _sandboxText.value = newText + val now = System.currentTimeMillis() + if (typingStartTimestamp == 0L && newText.isNotEmpty()) { + typingStartTimestamp = now + } + val elapsedMillis = if (typingStartTimestamp > 0L) now - typingStartTimestamp else 0L + _metrics.value = TypingMetricsCalculator.calculate(newText, elapsedMillis) + } + + fun onInsertText(textToInsert: String) { + val current = _sandboxText.value + val updated = if (current.isEmpty()) { + textToInsert + } else { + "$current\n$textToInsert" + } + onTextChanged(updated) + } + + fun onClearText() { + _sandboxText.value = "" + typingStartTimestamp = 0L + _metrics.value = TypingMetrics() + } + fun onResetTimer() { + typingStartTimestamp = System.currentTimeMillis() + _metrics.value = TypingMetricsCalculator.calculate(_sandboxText.value, 0L) + } + fun onTabSelected(tabIndex: Int) { + _activeTab.value = tabIndex + } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TypingMetricsCalculator.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TypingMetricsCalculator.kt new file mode 100644 index 0000000..cdebeca --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TypingMetricsCalculator.kt @@ -0,0 +1,47 @@ +package com.frogobox.appkeyboard.ui.test + +import kotlin.math.roundToInt + +data class TypingMetrics( + val characterCount: Int = 0, + val wordCount: Int = 0, + val lineCount: Int = 0, + val wordsPerMinute: Int = 0, + val elapsedSeconds: Long = 0L +) + +object TypingMetricsCalculator { + + fun calculate(text: String, elapsedMillis: Long): TypingMetrics { + val charCount = text.length + val trimmed = text.trim() + val wordCount = if (trimmed.isEmpty()) { + 0 + } else { + trimmed.split("\\s+".toRegex()).count { it.isNotEmpty() } + } + + val lineCount = if (text.isEmpty()) { + 0 + } else { + text.count { it == '\n' } + 1 + } + + val elapsedSeconds = elapsedMillis / 1000L + val wpm = if (elapsedSeconds < 2L || wordCount == 0) { + 0 + } else { + val minutes = elapsedSeconds / 60.0 + val rawWpm = (wordCount / minutes).roundToInt() + rawWpm.coerceIn(0, 300) + } + + return TypingMetrics( + characterCount = charCount, + wordCount = wordCount, + lineCount = lineCount, + wordsPerMinute = wpm, + elapsedSeconds = elapsedSeconds + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeActivity.kt index 0218a1c..236555b 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeActivity.kt @@ -1,150 +1,50 @@ package com.frogobox.appkeyboard.ui.theme import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup import androidx.activity.viewModels -import com.frogobox.appkeyboard.common.base.BaseActivity -import com.frogobox.appkeyboard.databinding.ActivityThemeBinding -import com.frogobox.appkeyboard.databinding.ItemThemeBinding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.frogobox.appkeyboard.common.base.BaseComposeActivity import com.frogobox.appkeyboard.model.KeyboardThemeModel -import com.frogobox.recycler.core.FrogoRecyclerNotifyListener -import com.frogobox.recycler.core.IFrogoBindingAdapter -import com.frogobox.recycler.ext.injectorBinding -import com.frogobox.sdk.ext.gone import com.frogobox.sdk.ext.showToast -import com.frogobox.sdk.ext.visible -import com.frogobox.sdk.util.FrogoFunc -import com.frogobox.sdk.util.SimpleDialogUtil import dagger.hilt.android.AndroidEntryPoint -/** - * Created by faisalamircs on 16/02/2024 - * ----------------------------------------- - * Name : Muhammad Faisal Amir - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - */ - - @AndroidEntryPoint -class ThemeActivity : BaseActivity(), - IFrogoBindingAdapter { - - companion object { - private val TAG: String = ThemeActivity::class.java.simpleName - } +class ThemeActivity : BaseComposeActivity() { private val viewModel: ThemeViewModel by viewModels() - override fun setupViewBinding(): ActivityThemeBinding { - return ActivityThemeBinding.inflate(layoutInflater) - } - - override fun setupViewModel() { - super.setupViewModel() - viewModel.keyboardThemeState.observe(this) { - setupRV(it) - } - } - + private var themeList by mutableStateOf>(emptyList()) + private var activeThemeVersion by mutableIntStateOf(0) override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) - setupDetailActivity("Setup Theme") - if (savedInstanceState == null) { - // Call View Model Here - viewModel.getThemeData() - } - // TODO : Add your code here - - } - - override fun initView() { - super.initView() - binding.apply { - - } - } - - override fun onItemClicked( - binding: ItemThemeBinding, - data: KeyboardThemeModel, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) { - applyTheme(data) - } - - override fun onItemLongClicked( - binding: ItemThemeBinding, - data: KeyboardThemeModel, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) {} - - override fun areContentsTheSame( - oldItem: KeyboardThemeModel, - newItem: KeyboardThemeModel - ): Boolean { - return oldItem == newItem - } - - override fun areItemsTheSame( - oldItem: KeyboardThemeModel, - newItem: KeyboardThemeModel - ): Boolean { - return oldItem.name == newItem.name - } - - override fun setViewBinding(parent: ViewGroup): ItemThemeBinding { - return ItemThemeBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - } - - override fun setupInitComponent( - binding: ItemThemeBinding, - data: KeyboardThemeModel, - position: Int, - notifyListener: FrogoRecyclerNotifyListener - ) { - binding.ivBackground.setImageResource(data.background) - binding.tvTitle.text = data.name - binding.tvDesc.text = data.description - if (viewModel.getThemeColor() == data.background) { - binding.ivIcon.visible() - } else { - binding.ivIcon.gone() + viewModel.keyboardThemeState.observe(this) { + themeList = it + activeThemeVersion++ } - } - - private fun setupRV(data: List) { - binding.root.injectorBinding() - .addData(data) - .addCallback(this) - .createLayoutLinearVertical(false) - .build() - } - - private fun applyTheme(data: KeyboardThemeModel) { - SimpleDialogUtil.create( - this@ThemeActivity, - "Change Theme", - "Are you sure you want to change keyboard theme?", - object : SimpleDialogUtil.OnDialogClickListener { - override fun negativeButton() {} - override fun positiveButton() { - viewModel.setThemeColor(data) { - viewModel.getThemeData() - showToast("${data.name} Theme Applied") - } + viewModel.getThemeData() + } + + @Composable + override fun Content() { + val version = activeThemeVersion + ThemeScreen( + themeList = themeList, + checkIsActive = { theme -> + version >= 0 && viewModel.isThemeActive(theme) + }, + onApplyTheme = { theme -> + viewModel.setThemeColor(theme) { + activeThemeVersion++ + viewModel.getThemeData() + showToast("${theme.name} Theme Applied") } - } + }, + onBackClick = { finish() } ) } - } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeScreen.kt new file mode 100644 index 0000000..b63daf8 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeScreen.kt @@ -0,0 +1,1103 @@ +package com.frogobox.appkeyboard.ui.theme + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Backspace +import androidx.compose.material.icons.automirrored.filled.KeyboardReturn +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.SentimentSatisfied +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.R +import com.frogobox.appkeyboard.model.KeyboardThemeModel +import com.frogobox.appkeyboard.model.ThemeType +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar + +enum class ThemeCategory(val title: String) { + ALL("All"), + SOLID("Solid Colors"), + WALLPAPER("Wallpapers") +} + +@Composable +fun ThemeScreen( + themeList: List, + checkIsActive: (KeyboardThemeModel) -> Boolean, + onApplyTheme: (KeyboardThemeModel) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + var selectedCategory by remember { mutableStateOf(ThemeCategory.ALL) } + var previewedTheme by remember { mutableStateOf(null) } + var confirmApplyDialogTheme by remember { mutableStateOf(null) } + + // Synchronize preview with active theme on initial load or change + LaunchedEffect(themeList) { + if (previewedTheme == null && themeList.isNotEmpty()) { + previewedTheme = themeList.firstOrNull { checkIsActive(it) } ?: themeList.first() + } + } + + // Filter themes based on selected category chip + val filteredThemes = remember(themeList, selectedCategory) { + when (selectedCategory) { + ThemeCategory.ALL -> themeList + ThemeCategory.SOLID -> themeList.filter { it.themType == ThemeType.COLOR } + ThemeCategory.WALLPAPER -> themeList.filter { it.themType == ThemeType.IMAGE } + } + } + + val activeTheme = themeList.firstOrNull { checkIsActive(it) } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Keyboard Themes", + onBackClick = onBackClick + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 1. Live Interactive Keyboard Preview Hero Section (Spans all columns) + item(span = { GridItemSpan(2) }) { + val currentPreview = previewedTheme ?: activeTheme ?: themeList.firstOrNull() + currentPreview?.let { theme -> + val isPreviewActive = checkIsActive(theme) + ThemePreviewHero( + theme = theme, + isActive = isPreviewActive, + onApplyClick = { + confirmApplyDialogTheme = theme + } + ) + } + } + + // 2. Category Filter Chips Section (Spans all columns) + item(span = { GridItemSpan(2) }) { + ThemeCategoryFilterRow( + selectedCategory = selectedCategory, + onCategorySelected = { selectedCategory = it }, + totalAllCount = themeList.size, + solidCount = themeList.count { it.themType == ThemeType.COLOR }, + wallpaperCount = themeList.count { it.themType == ThemeType.IMAGE } + ) + } + + // 3. Section Title (Spans all columns) + item(span = { GridItemSpan(2) }) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Theme Gallery (${filteredThemes.size})", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ), + color = MaterialTheme.colorScheme.onBackground + ) + + activeTheme?.let { + Text( + text = "Active: ${it.name}", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + color = FrogoPrimary + ) + ) + } + } + } + + // 4. 2-Column Responsive Theme Cards + items(filteredThemes, key = { it.name }) { theme -> + val isActive = checkIsActive(theme) + val isPreviewed = previewedTheme?.name == theme.name + + ThemeGridCard( + theme = theme, + isActive = isActive, + isPreviewed = isPreviewed, + onCardClick = { + previewedTheme = theme + }, + onApplyClick = { + confirmApplyDialogTheme = theme + } + ) + } + + // Bottom Spacer for edge-to-edge breathing room + item(span = { GridItemSpan(2) }) { + Spacer(modifier = Modifier.height(24.dp)) + } + } + } + + // Confirmation Dialog before applying theme + confirmApplyDialogTheme?.let { theme -> + AlertDialog( + onDismissRequest = { confirmApplyDialogTheme = null }, + icon = { + Surface( + shape = CircleShape, + color = FrogoPrimary.copy(alpha = 0.12f), + modifier = Modifier.size(48.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Palette, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(24.dp) + ) + } + } + }, + title = { + Text( + text = "Apply Theme", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold + ), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + }, + text = { + Text( + text = "Set \"${theme.name}\" as your active keyboard theme? All keyboard keys and panels will adopt this style.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + Button( + onClick = { + val current = theme + confirmApplyDialogTheme = null + onApplyTheme(current) + previewedTheme = current + }, + colors = ButtonDefaults.buttonColors(containerColor = FrogoPrimary), + shape = RoundedCornerShape(10.dp) + ) { + Text( + text = "Apply Now", + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + TextButton( + onClick = { confirmApplyDialogTheme = null } + ) { + Text("Cancel") + } + }, + shape = RoundedCornerShape(20.dp) + ) + } +} + +/** + * Backwards-compatible overload matching previous signature + */ +@Composable +fun ThemeScreen( + themeList: List, + activeThemeColor: Int, + onApplyTheme: (KeyboardThemeModel) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + ThemeScreen( + themeList = themeList, + checkIsActive = { it.background == activeThemeColor }, + onApplyTheme = onApplyTheme, + onBackClick = onBackClick, + modifier = modifier + ) +} + +/** + * 1. Live Interactive Keyboard Preview Hero Component + */ +@Composable +private fun ThemePreviewHero( + theme: KeyboardThemeModel, + isActive: Boolean, + onApplyClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 3.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke( + width = if (isActive) 2.dp else 1.dp, + color = if (isActive) FrogoPrimary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Header Row: Theme Label + Status / Apply Action + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = CircleShape, + color = FrogoPrimary.copy(alpha = 0.12f), + modifier = Modifier.size(32.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Palette, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(18.dp) + ) + } + } + + Spacer(modifier = Modifier.width(10.dp)) + + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = theme.name, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(6.dp)) + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Text( + text = if (theme.themType == ThemeType.COLOR) "Color" else "Wallpaper", + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold + ), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Text( + text = theme.description, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (isActive) { + Surface( + shape = RoundedCornerShape(12.dp), + color = Color(0xFF41AF60).copy(alpha = 0.12f), + border = BorderStroke(1.dp, Color(0xFF41AF60).copy(alpha = 0.4f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Active", + tint = Color(0xFF41AF60), + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Active", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + color = Color(0xFF41AF60), + fontSize = 12.sp + ) + ) + } + } + } else { + Button( + onClick = onApplyClick, + colors = ButtonDefaults.buttonColors(containerColor = FrogoPrimary), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 6.dp), + modifier = Modifier.height(34.dp) + ) { + Text( + text = "Apply", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ) + ) + } + } + } + + Spacer(modifier = Modifier.height(14.dp)) + + // Realistic Keyboard Mockup Canvas + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + shape = RoundedCornerShape(14.dp) + ) + ) { + // Background Layer (Color or Wallpaper) + when (theme.themType) { + ThemeType.COLOR -> { + Box( + modifier = Modifier + .matchParentSize() + .background(colorResource(id = theme.background)) + ) + } + ThemeType.IMAGE -> { + Image( + painter = painterResource(id = theme.background), + contentDescription = theme.name, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop + ) + } + } + + // Keyboard Layout Overlay (Utility Strip + Keycaps) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Top Suggestion & Toolbar Strip + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MockToolIcon(Icons.Default.SentimentSatisfied) + MockToolIcon(Icons.Default.TextFields) + MockToolIcon(Icons.Default.Language) + MockToolIcon(Icons.Default.Settings) + } + + Text( + text = "Frogo Keyboard", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + color = Color.White.copy(alpha = 0.85f) + ) + ) + } + + // Keycap Row 1: Q W E R T Y U I O P (10 keys) + MockKeyRow(keys = listOf("Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P")) + + // Keycap Row 2: A S D F G H J K L (9 keys with inset margins) + MockKeyRow( + keys = listOf("A", "S", "D", "F", "G", "H", "J", "K", "L"), + horizontalPadding = 12.dp + ) + + // Keycap Row 3: Shift, Z X C V B N M, Backspace + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MockTextKey( + text = "⇧", + weight = 1.3f + ) + listOf("Z", "X", "C", "V", "B", "N", "M").forEach { letter -> + MockKey( + text = letter, + modifier = Modifier.weight(1f) + ) + } + MockSpecialKey( + icon = Icons.AutoMirrored.Filled.Backspace, + weight = 1.3f + ) + } + + // Keycap Row 4: ?123, Language, Spacebar, Enter + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MockTextKey(text = "?123", weight = 1.4f) + MockIconKey(icon = Icons.Default.Language, weight = 1f) + MockSpacebarKey(text = "English", weight = 4.2f) + MockActionKey(icon = Icons.AutoMirrored.Filled.KeyboardReturn, weight = 1.6f) + } + } + } + } + } +} + +/** + * 2. Category Filter Chips Row Component + */ +@Composable +private fun ThemeCategoryFilterRow( + selectedCategory: ThemeCategory, + onCategorySelected: (ThemeCategory) -> Unit, + totalAllCount: Int, + solidCount: Int, + wallpaperCount: Int, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ThemeCategory.entries.forEach { category -> + val count = when (category) { + ThemeCategory.ALL -> totalAllCount + ThemeCategory.SOLID -> solidCount + ThemeCategory.WALLPAPER -> wallpaperCount + } + val isSelected = selectedCategory == category + + FilterChip( + selected = isSelected, + onClick = { onCategorySelected(category) }, + label = { + Text( + text = "${category.title} ($count)", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium + ) + ) + }, + leadingIcon = { + when (category) { + ThemeCategory.ALL -> Icon( + imageVector = Icons.Default.Palette, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + ThemeCategory.SOLID -> Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + ThemeCategory.WALLPAPER -> Icon( + imageVector = Icons.Default.SentimentSatisfied, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = FrogoPrimary.copy(alpha = 0.15f), + selectedLabelColor = FrogoPrimary, + selectedLeadingIconColor = FrogoPrimary + ), + shape = RoundedCornerShape(12.dp) + ) + } + } +} + +/** + * 3. 2-Column Visual Theme Card Component + */ +@Composable +private fun ThemeGridCard( + theme: KeyboardThemeModel, + isActive: Boolean, + isPreviewed: Boolean, + onCardClick: () -> Unit, + onApplyClick: () -> Unit, + modifier: Modifier = Modifier +) { + val borderColor by animateColorAsState( + targetValue = when { + isActive -> FrogoPrimary + isPreviewed -> FrogoPrimary.copy(alpha = 0.6f) + else -> MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + }, + animationSpec = tween(durationMillis = 200), + label = "cardBorderColor" + ) + + Card( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable { onCardClick() }, + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = if (isPreviewed || isActive) 3.dp else 1.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke( + width = if (isActive || isPreviewed) 2.dp else 1.dp, + color = borderColor + ) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Top Preview Swatch / Wallpaper Area + Box( + modifier = Modifier + .fillMaxWidth() + .height(92.dp) + ) { + when (theme.themType) { + ThemeType.COLOR -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(colorResource(id = theme.background)) + ) + } + ThemeType.IMAGE -> { + Image( + painter = painterResource(id = theme.background), + contentDescription = theme.name, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + } + + // Mini Keycap Silhouette Overlay for Authentic Keyboard Feel + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + repeat(10) { + Box( + modifier = Modifier + .weight(1f) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.28f)) + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + repeat(9) { + Box( + modifier = Modifier + .weight(1f) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.28f)) + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Box( + modifier = Modifier + .weight(1.3f) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.35f)) + ) + repeat(7) { + Box( + modifier = Modifier + .weight(1f) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.28f)) + ) + } + Box( + modifier = Modifier + .weight(1.3f) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.35f)) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Box( + modifier = Modifier + .weight(1.5f) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.28f)) + ) + Box( + modifier = Modifier + .weight(4.5f) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.38f)) + ) + Box( + modifier = Modifier + .weight(2f) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + .background(FrogoPrimary.copy(alpha = 0.75f)) + ) + } + } + + // Floating Active Badge (Top Right) + if (isActive) { + Surface( + shape = RoundedCornerShape(bottomStart = 10.dp), + color = Color(0xFF41AF60), + modifier = Modifier.align(Alignment.TopEnd) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Active", + tint = Color.White, + modifier = Modifier.size(12.dp) + ) + Spacer(modifier = Modifier.width(3.dp)) + Text( + text = "Active", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + color = Color.White + ) + ) + } + } + } else if (isPreviewed) { + Surface( + shape = RoundedCornerShape(bottomStart = 10.dp), + color = FrogoPrimary, + modifier = Modifier.align(Alignment.TopEnd) + ) { + Text( + text = "Previewing", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 9.sp, + color = Color.White + ), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp) + ) + } + } + } + + // Bottom Info Container + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp) + ) { + Text( + text = theme.name, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = theme.description, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +// -------------------------------------------------------------------------------- +// KEYBOARD MOCKUP HELPER COMPOSABLES +// -------------------------------------------------------------------------------- + +@Composable +private fun MockToolIcon( + icon: ImageVector, + modifier: Modifier = Modifier +) { + Surface( + shape = CircleShape, + color = Color.Black.copy(alpha = 0.25f), + modifier = modifier.size(20.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White.copy(alpha = 0.9f), + modifier = Modifier.size(12.dp) + ) + } + } +} + +@Composable +private fun MockKeyRow( + keys: List, + horizontalPadding: androidx.compose.ui.unit.Dp = 0.dp, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + keys.forEach { letter -> + MockKey( + text = letter, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun MockKey( + text: String, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape(5.dp), + color = Color.White.copy(alpha = 0.22f), + border = BorderStroke(0.5.dp, Color.White.copy(alpha = 0.35f)), + modifier = modifier.height(26.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + color = Color.White + ) + ) + } + } +} + +@Composable +private fun MockSpecialKey( + icon: ImageVector, + weight: Float, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape(5.dp), + color = Color.Black.copy(alpha = 0.25f), + border = BorderStroke(0.5.dp, Color.White.copy(alpha = 0.3f)), + modifier = modifier + .height(26.dp) + .then(Modifier.run { this }) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(horizontal = 6.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(14.dp) + ) + } + } +} + +@Composable +private fun MockTextKey( + text: String, + weight: Float, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape(5.dp), + color = Color.Black.copy(alpha = 0.25f), + border = BorderStroke(0.5.dp, Color.White.copy(alpha = 0.3f)), + modifier = modifier.height(26.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(horizontal = 6.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 10.sp, + color = Color.White + ) + ) + } + } +} + +@Composable +private fun MockIconKey( + icon: ImageVector, + weight: Float, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape(5.dp), + color = Color.Black.copy(alpha = 0.25f), + border = BorderStroke(0.5.dp, Color.White.copy(alpha = 0.3f)), + modifier = modifier.height(26.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(horizontal = 6.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(13.dp) + ) + } + } +} + +@Composable +private fun MockSpacebarKey( + text: String, + weight: Float, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape(5.dp), + color = Color.White.copy(alpha = 0.28f), + border = BorderStroke(0.5.dp, Color.White.copy(alpha = 0.4f)), + modifier = modifier.height(26.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + fontSize = 10.sp, + color = Color.White.copy(alpha = 0.9f) + ) + ) + } + } +} + +@Composable +private fun MockActionKey( + icon: ImageVector, + weight: Float, + modifier: Modifier = Modifier +) { + Surface( + shape = RoundedCornerShape(5.dp), + color = FrogoPrimary, + modifier = modifier.height(26.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(14.dp) + ) + } + } +} + +@Preview(showBackground = true, name = "ThemeScreen Light Preview") +@Composable +fun ThemeScreenPreview() { + FrogoKeyboardTheme(darkTheme = false) { + ThemeScreen( + themeList = listOf( + KeyboardThemeModel("Default", "Classic Adaptive", ThemeType.COLOR, R.color.color_bg_keyboard_default), + KeyboardThemeModel("Frogo Purple", "Signature Brand", ThemeType.COLOR, R.color.color_bg_keyboard_purple), + KeyboardThemeModel("Midnight AMOLED", "Deep OLED Black", ThemeType.COLOR, R.color.color_bg_keyboard_dark), + KeyboardThemeModel("Ocean Blue", "Calm & Focused", ThemeType.COLOR, R.color.color_bg_keyboard_blue), + KeyboardThemeModel("Forest Emerald", "Natural Harmony", ThemeType.COLOR, R.color.color_bg_keyboard_green), + KeyboardThemeModel("Crimson Sunset", "Vibrant Warmth", ThemeType.COLOR, R.color.color_bg_keyboard_red), + KeyboardThemeModel("Sunset Orange", "Energetic Twilight", ThemeType.COLOR, R.color.color_bg_keyboard_orange), + KeyboardThemeModel("Nordic Cyan", "Fresh & Clean", ThemeType.COLOR, R.color.color_bg_keyboard_cyan), + KeyboardThemeModel("Sakura Pink", "Aesthetic Pastel", ThemeType.COLOR, R.color.color_bg_keyboard_pink), + KeyboardThemeModel("Amber Gold", "Golden Accent", ThemeType.COLOR, R.color.color_bg_keyboard_yellow), + KeyboardThemeModel("Wallpaper", "Sample Artwork", ThemeType.IMAGE, R.drawable.ic_wallpaper_dummy) + ), + checkIsActive = { it.name == "Frogo Purple" }, + onApplyTheme = {}, + onBackClick = {} + ) + } +} + +@Preview(showBackground = true, name = "ThemeScreen Dark Preview") +@Composable +fun ThemeScreenDarkPreview() { + FrogoKeyboardTheme(darkTheme = true) { + ThemeScreen( + themeList = listOf( + KeyboardThemeModel("Default", "Classic Adaptive", ThemeType.COLOR, R.color.color_bg_keyboard_default), + KeyboardThemeModel("Frogo Purple", "Signature Brand", ThemeType.COLOR, R.color.color_bg_keyboard_purple), + KeyboardThemeModel("Midnight AMOLED", "Deep OLED Black", ThemeType.COLOR, R.color.color_bg_keyboard_dark), + KeyboardThemeModel("Wallpaper", "Sample Artwork", ThemeType.IMAGE, R.drawable.ic_wallpaper_dummy) + ), + checkIsActive = { it.name == "Midnight AMOLED" }, + onApplyTheme = {}, + onBackClick = {} + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeViewModel.kt index 2dcd26e..d80199b 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeViewModel.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeViewModel.kt @@ -5,8 +5,8 @@ import androidx.lifecycle.MutableLiveData import com.frogobox.appkeyboard.R import com.frogobox.appkeyboard.common.base.BaseViewModel import com.frogobox.appkeyboard.model.KeyboardThemeModel +import com.frogobox.appkeyboard.model.ThemeType import com.frogobox.appkeyboard.services.KeyboardUtil -import com.frogobox.coresdk.response.FrogoStateResponse import com.frogobox.sdk.delegate.preference.PreferenceDelegates import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -20,7 +20,6 @@ import javax.inject.Inject * ----------------------------------------- */ - @HiltViewModel class ThemeViewModel @Inject constructor( private val pref: PreferenceDelegates, @@ -38,6 +37,14 @@ class ThemeViewModel @Inject constructor( return pref.getPrefInt(KeyboardUtil.KEYBOARD_COLOR, R.color.color_bg_keyboard_default) } + fun getThemeType(): String { + return pref.getPrefString(KeyboardUtil.KEYBOARD_COLOR_TYPE, ThemeType.COLOR.name) + } + + fun isThemeActive(data: KeyboardThemeModel): Boolean { + return data.background == getThemeColor() && data.themType.name == getThemeType() + } + fun setThemeColor(data: KeyboardThemeModel, onSuccess: () -> Unit) { pref.savePrefString(KeyboardUtil.KEYBOARD_COLOR_TYPE, data.themType.name) pref.savePrefInt(KeyboardUtil.KEYBOARD_COLOR, data.background) diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Color.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Color.kt new file mode 100644 index 0000000..c50fb02 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Color.kt @@ -0,0 +1,27 @@ +package com.frogobox.appkeyboard.ui.theme.compose + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) + +// Frogo Keyboard Specific Colors +val FrogoPrimary = Color(0xFF6200EE) +val FrogoPrimaryVariant = Color(0xFF3700B3) +val FrogoSecondary = Color(0xFF03DAC5) +val FrogoBackground = Color(0xFFFCFCFF) +val FrogoText = Color(0xFF000000) +val FrogoTextDark = Color(0xFFFFFFFF) +val FrogoGrey = Color(0xFF424242) +val FrogoDivider = Color(0xFFE0E0E0) +val FrogoBorder = Color(0xFFBDBDBD) + +// Status Colors +val FrogoStatusSuccess = Color(0xFF41AF60) +val FrogoStatusWarning = Color(0xFFF57C00) +val FrogoStatusFailed = Color(0xFFC53741) diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/FrogoEmptyView.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/FrogoEmptyView.kt new file mode 100644 index 0000000..1d6d5ce --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/FrogoEmptyView.kt @@ -0,0 +1,67 @@ +package com.frogobox.appkeyboard.ui.theme.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Reusable Empty State Composable for lists across Frogo Keyboard. + */ +@Composable +fun FrogoEmptyView( + title: String = "NO DATA FOUND", + subtitle: String = "Please insert data", + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + modifier = Modifier.size(72.dp), + tint = MaterialTheme.colorScheme.outline + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = title.uppercase(), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ), + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/FrogoTopAppBar.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/FrogoTopAppBar.kt new file mode 100644 index 0000000..520c08c --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/FrogoTopAppBar.kt @@ -0,0 +1,56 @@ +package com.frogobox.appkeyboard.ui.theme.compose + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight + +/** + * Reusable Material 3 TopAppBar for Frogo Keyboard app screens. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FrogoTopAppBar( + title: String, + modifier: Modifier = Modifier, + onBackClick: (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {} +) { + TopAppBar( + title = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onBackground + ) + }, + modifier = modifier, + navigationIcon = { + if (onBackClick != null) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onBackground + ) + } + } + }, + actions = actions, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + scrolledContainerColor = MaterialTheme.colorScheme.surface + ) + ) +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Theme.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Theme.kt new file mode 100644 index 0000000..805fb5b --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Theme.kt @@ -0,0 +1,48 @@ +package com.frogobox.appkeyboard.ui.theme.compose + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, + background = Color(0xFF1C1B1F), + surface = Color(0xFF1C1B1F), + onPrimary = Color(0xFF381E72), + onSecondary = Color(0xFF332D41), + onTertiary = Color(0xFF492532), + onBackground = Color(0xFFE6E1E5), + onSurface = Color(0xFFE6E1E5), +) + +private val LightColorScheme = lightColorScheme( + primary = FrogoPrimary, + secondary = FrogoSecondary, + tertiary = FrogoPrimaryVariant, + background = FrogoBackground, + surface = Color.White, + onPrimary = Color.White, + onSecondary = Color.Black, + onTertiary = Color.White, + onBackground = FrogoText, + onSurface = FrogoText, +) + +@Composable +fun FrogoKeyboardTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Type.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Type.kt new file mode 100644 index 0000000..1be1f70 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/compose/Type.kt @@ -0,0 +1,45 @@ +package com.frogobox.appkeyboard.ui.theme.compose + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ) +) diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleActivity.kt index d5d55ff..5e665b2 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleActivity.kt @@ -1,107 +1,45 @@ package com.frogobox.appkeyboard.ui.toggle import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup import androidx.activity.viewModels -import com.frogobox.appkeyboard.databinding.ActivityToggleBinding -import com.frogobox.appkeyboard.databinding.ItemToggleBinding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.frogobox.appkeyboard.common.base.BaseComposeActivity import com.frogobox.appkeyboard.model.KeyboardFeatureModel -import com.frogobox.appkeyboard.ui.main.BaseMainActivity -import com.frogobox.recycler.core.FrogoRecyclerNotifyListener -import com.frogobox.recycler.core.IFrogoBindingAdapter -import com.frogobox.recycler.ext.injectorBinding import dagger.hilt.android.AndroidEntryPoint -/** - * Created by Faisal Amir on 24/10/22 - * ----------------------------------------- - * E-mail : faisalamircs@gmail.com - * Github : github.com/amirisback - * ----------------------------------------- - * Copyright (C) Frogobox ID / amirisback - * All rights reserved - */ - - @AndroidEntryPoint -class ToggleActivity : BaseMainActivity() { +class ToggleActivity : BaseComposeActivity() { private val viewModel: ToggleViewModel by viewModels() - override fun setupViewBinding(): ActivityToggleBinding { - return ActivityToggleBinding.inflate(layoutInflater) - } + private var featureList by mutableStateOf>(emptyList()) + private val toggleStates = mutableStateMapOf() override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) - if (savedInstanceState == null) { - viewModel.getKeyboardFeatureData() + viewModel.keyboardFeatureState.observe(this) { list -> + featureList = list + list.forEach { feature -> + toggleStates[feature.id] = viewModel.getSwitchToggle(feature.id) + } } + viewModel.getKeyboardFeatureData() } - override fun initView() { - super.initView() - setupDetailActivity("Toggle Feature") - } - - override fun setupViewModel() { - super.setupViewModel() - viewModel.keyboardFeatureState.observe(this) { - binding.root.injectorBinding() - .addData(it) - .addCallback(object : - IFrogoBindingAdapter { - override fun areContentsTheSame( - oldItem: KeyboardFeatureModel, - newItem: KeyboardFeatureModel - ): Boolean { - return oldItem == newItem - } - - override fun areItemsTheSame( - oldItem: KeyboardFeatureModel, - newItem: KeyboardFeatureModel - ): Boolean { - return oldItem.id == newItem.id - } - - override fun setViewBinding(parent: ViewGroup): ItemToggleBinding { - return ItemToggleBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - } - - override fun setupInitComponent( - binding: ItemToggleBinding, - data: KeyboardFeatureModel, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) { - binding.swToggle.setOnCheckedChangeListener(null) - binding.ivIcon.setImageResource(data.icon) - binding.tvTitle.text = data.text - binding.swToggle.isChecked = viewModel.getSwitchToggle(data.id) - binding.swToggle.setOnCheckedChangeListener { _, isChecked -> - viewModel.switchToggle(data.id, isChecked) - } - } - - override fun onItemClicked( - binding: ItemToggleBinding, - data: KeyboardFeatureModel, - position: Int, - notifyListener: FrogoRecyclerNotifyListener, - ) {} - - - - }) - .createLayoutGrid(2) - .build() - } + @Composable + override fun Content() { + ToggleScreen( + features = featureList, + getToggleState = { id -> toggleStates[id] ?: viewModel.getSwitchToggle(id) }, + onToggleChanged = { id, isChecked -> + toggleStates[id] = isChecked + viewModel.switchToggle(id, isChecked) + }, + onBackClick = { finish() } + ) } - } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt new file mode 100644 index 0000000..f9de6e9 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt @@ -0,0 +1,550 @@ +package com.frogobox.appkeyboard.ui.toggle + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.R +import com.frogobox.appkeyboard.model.KeyboardFeatureModel +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusSuccess +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar + +enum class ToggleFilterTab { + ALL, + ACTIVE, + DISABLED +} + +@Composable +fun ToggleScreen( + features: List, + getToggleState: (String) -> Boolean, + onToggleChanged: (String, Boolean) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + var selectedTab by remember { mutableStateOf(ToggleFilterTab.ALL) } + + val activeCount = features.count { getToggleState(it.id) } + val disabledCount = features.size - activeCount + + val filteredFeatures = when (selectedTab) { + ToggleFilterTab.ALL -> features + ToggleFilterTab.ACTIVE -> features.filter { getToggleState(it.id) } + ToggleFilterTab.DISABLED -> features.filter { !getToggleState(it.id) } + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Toggle Feature", + onBackClick = onBackClick + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 28.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Header summary banner + item(span = { GridItemSpan(maxLineSpan) }) { + ToggleHeaderBanner( + totalCount = features.size, + activeCount = activeCount, + onEnableAll = { + features.forEach { onToggleChanged(it.id, true) } + }, + onDisableAll = { + features.forEach { onToggleChanged(it.id, false) } + } + ) + } + + // Quick Filter Chips + item(span = { GridItemSpan(maxLineSpan) }) { + ToggleFilterRow( + selectedTab = selectedTab, + totalCount = features.size, + activeCount = activeCount, + disabledCount = disabledCount, + onTabSelected = { selectedTab = it } + ) + } + + if (filteredFeatures.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 40.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = if (selectedTab == ToggleFilterTab.ACTIVE) { + "No active features found" + } else { + "No disabled features found" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = { selectedTab = ToggleFilterTab.ALL }) { + Text( + text = "Show All Features", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } + } + } + } + } else { + items(filteredFeatures, key = { it.id }) { feature -> + ToggleFeatureCard( + feature = feature, + isChecked = getToggleState(feature.id), + onCheckedChange = { isChecked -> + onToggleChanged(feature.id, isChecked) + } + ) + } + } + } + } +} + +@Composable +private fun ToggleHeaderBanner( + totalCount: Int, + activeCount: Int, + onEnableAll: () -> Unit, + onDisableAll: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Keyboard Toolbar", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Manage shortcuts displayed on your keyboard", + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + ) { + Text( + text = "$activeCount/$totalCount Active", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 11.sp + ), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) + ) + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = onEnableAll, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = "Enable All", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.primary + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + TextButton( + onClick = onDisableAll, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = "Disable All", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Normal, + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun ToggleFilterRow( + selectedTab: ToggleFilterTab, + totalCount: Int, + activeCount: Int, + disabledCount: Int, + onTabSelected: (ToggleFilterTab) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ToggleFilterChip( + text = "All ($totalCount)", + isSelected = selectedTab == ToggleFilterTab.ALL, + onClick = { onTabSelected(ToggleFilterTab.ALL) } + ) + + ToggleFilterChip( + text = "Active ($activeCount)", + isSelected = selectedTab == ToggleFilterTab.ACTIVE, + onClick = { onTabSelected(ToggleFilterTab.ACTIVE) } + ) + + ToggleFilterChip( + text = "Disabled ($disabledCount)", + isSelected = selectedTab == ToggleFilterTab.DISABLED, + onClick = { onTabSelected(ToggleFilterTab.DISABLED) } + ) + } +} + +@Composable +private fun ToggleFilterChip( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier + .clip(RoundedCornerShape(20.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(20.dp), + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surface + }, + border = BorderStroke( + width = 1.dp, + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + } + ) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + fontSize = 12.sp + ), + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp) + ) + } +} + +@Composable +private fun ToggleFeatureCard( + feature: KeyboardFeatureModel, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val primaryColor = MaterialTheme.colorScheme.primary + val surfaceColor = MaterialTheme.colorScheme.surface + val outlineVariant = MaterialTheme.colorScheme.outlineVariant + + Card( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .clickable { onCheckedChange(!isChecked) }, + shape = RoundedCornerShape(18.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + border = BorderStroke( + width = 1.dp, + color = if (isChecked) { + primaryColor.copy(alpha = 0.45f) + } else { + outlineVariant.copy(alpha = 0.35f) + } + ), + colors = CardDefaults.cardColors( + containerColor = if (isChecked) { + primaryColor.copy(alpha = 0.08f) + } else { + surfaceColor + } + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp) + ) { + // Top row with Icon Container and Switch + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + if (isChecked) { + primaryColor.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + ), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = feature.icon), + contentDescription = feature.text, + modifier = Modifier.size(24.dp) + ) + } + + Switch( + checked = isChecked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = primaryColor, + uncheckedThumbColor = MaterialTheme.colorScheme.outline, + uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant, + uncheckedBorderColor = Color.Transparent + ) + ) + } + + Spacer(modifier = Modifier.height(14.dp)) + + Text( + text = feature.text, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ), + color = if (isChecked) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background( + if (isChecked) { + FrogoStatusSuccess + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.4f) + } + ) + ) + + Spacer(modifier = Modifier.width(6.dp)) + + Text( + text = if (isChecked) "Active" else "Disabled", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = if (isChecked) FontWeight.SemiBold else FontWeight.Normal, + fontSize = 11.sp + ), + color = if (isChecked) { + FrogoStatusSuccess + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + } + ) + } + } + } +} + +@Preview(name = "Light Mode", showBackground = true) +@Composable +fun ToggleScreenLightPreview() { + FrogoKeyboardTheme(darkTheme = false) { + ToggleScreen( + features = listOf( + KeyboardFeatureModel( + id = "auto_text", + text = "Auto Text", + icon = R.drawable.ic_menu_auto_text + ), + KeyboardFeatureModel( + id = "app_review", + text = "App Review", + icon = R.drawable.ic_menu_ps_app + ), + KeyboardFeatureModel( + id = "game_review", + text = "Game Review", + icon = R.drawable.ic_menu_ps_game + ), + KeyboardFeatureModel( + id = "love_emoji", + text = "Love Emoji", + icon = R.drawable.ic_menu_ps_love + ) + ), + getToggleState = { it == "auto_text" || it == "game_review" }, + onToggleChanged = { _, _ -> }, + onBackClick = {} + ) + } +} + +@Preview(name = "Dark Mode", showBackground = true) +@Composable +fun ToggleScreenDarkPreview() { + FrogoKeyboardTheme(darkTheme = true) { + ToggleScreen( + features = listOf( + KeyboardFeatureModel( + id = "auto_text", + text = "Auto Text", + icon = R.drawable.ic_menu_auto_text + ), + KeyboardFeatureModel( + id = "app_review", + text = "App Review", + icon = R.drawable.ic_menu_ps_app + ), + KeyboardFeatureModel( + id = "game_review", + text = "Game Review", + icon = R.drawable.ic_menu_ps_game + ), + KeyboardFeatureModel( + id = "love_emoji", + text = "Love Emoji", + icon = R.drawable.ic_menu_ps_love + ) + ), + getToggleState = { it == "auto_text" || it == "game_review" }, + onToggleChanged = { _, _ -> }, + onBackClick = {} + ) + } +} diff --git a/app/src/main/res/drawable/bg_suggestion_bar.xml b/app/src/main/res/drawable/bg_suggestion_bar.xml new file mode 100644 index 0000000..5736f68 --- /dev/null +++ b/app/src/main/res/drawable/bg_suggestion_bar.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_suggestion_pill.xml b/app/src/main/res/drawable/bg_suggestion_pill.xml new file mode 100644 index 0000000..6af3416 --- /dev/null +++ b/app/src/main/res/drawable/bg_suggestion_pill.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_close_suggestion.xml b/app/src/main/res/drawable/ic_close_suggestion.xml new file mode 100644 index 0000000..fc0a6e1 --- /dev/null +++ b/app/src/main/res/drawable/ic_close_suggestion.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu_sound.xml b/app/src/main/res/drawable/ic_menu_sound.xml new file mode 100644 index 0000000..c4fde8e --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_sound.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_menu_suggestion.xml b/app/src/main/res/drawable/ic_menu_suggestion.xml new file mode 100644 index 0000000..e6c052a --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_suggestion.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_suggestion_switch.xml b/app/src/main/res/drawable/ic_suggestion_switch.xml new file mode 100644 index 0000000..0629f82 --- /dev/null +++ b/app/src/main/res/drawable/ic_suggestion_switch.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_test.xml b/app/src/main/res/layout/activity_test.xml deleted file mode 100644 index 24deb49..0000000 --- a/app/src/main/res/layout/activity_test.xml +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_keyboard_autotext.xml b/app/src/main/res/layout/item_keyboard_autotext.xml index 3da3016..025b3f5 100644 --- a/app/src/main/res/layout/item_keyboard_autotext.xml +++ b/app/src/main/res/layout/item_keyboard_autotext.xml @@ -1,24 +1,38 @@ + android:elevation="@dimen/frogo_dimen_2dp" + android:orientation="vertical" + android:paddingStart="14dp" + android:paddingTop="10dp" + android:paddingEnd="14dp" + android:paddingBottom="10dp"> + android:textStyle="bold" + android:textSize="14sp" + tools:text="Greetings" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_keyboard_header.xml b/app/src/main/res/layout/item_keyboard_header.xml index 2570902..164282d 100644 --- a/app/src/main/res/layout/item_keyboard_header.xml +++ b/app/src/main/res/layout/item_keyboard_header.xml @@ -12,8 +12,7 @@ android:id="@+id/iv_icon" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:src="@drawable/ic_menu_news" - app:tint="@color/tv_text" /> + android:src="@drawable/ic_menu_news" /> + + + + + + + + + diff --git a/app/src/main/res/layout/keyboard_ime.xml b/app/src/main/res/layout/keyboard_ime.xml index c897c68..553303b 100644 --- a/app/src/main/res/layout/keyboard_ime.xml +++ b/app/src/main/res/layout/keyboard_ime.xml @@ -23,6 +23,12 @@ android:orientation="vertical" app:layout_constraintBottom_toBottomOf="parent"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/color.xml b/app/src/main/res/values-night/color.xml index 5a6dec7..d24e8ee 100644 --- a/app/src/main/res/values-night/color.xml +++ b/app/src/main/res/values-night/color.xml @@ -2,5 +2,22 @@ @color/keyboard_background + #7C4DFF + #000000 + #1E88E5 + #388E3C + #E53935 + #F57C00 + #00ACC1 + #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 000324b..4c9034d 100644 --- a/app/src/main/res/values/color.xml +++ b/app/src/main/res/values/color.xml @@ -2,9 +2,22 @@ @color/keyboard_background - #F44336 - #4CAF50 - #3F51B5 - #FFEB3B + #6200EE + #121212 + #1976D2 + #2E7D32 + #C62828 + #EF6C00 + #00838F + #C2185B + #FBC02D + + + #F4F6F8 + #546E7A + #0288D1 + #E1F5FE + #37474F + #E0E0E0 \ No newline at end of file diff --git a/app/src/test/java/com/frogobox/appkeyboard/AutoTextTest.kt b/app/src/test/java/com/frogobox/appkeyboard/AutoTextTest.kt new file mode 100644 index 0000000..2574d4c --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/AutoTextTest.kt @@ -0,0 +1,95 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.model.AutoTextEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AutoTextTest { + + private val sampleList = listOf( + AutoTextEntity(id = 1, title = "Greeting Morning", body = "Good morning! How can I help you today?"), + AutoTextEntity(id = 2, title = "Bank Account", body = "BCA: 1234567890 a/n Faisal Amir"), + AutoTextEntity(id = 3, title = "Email Address", body = "faisal.amir@example.com"), + AutoTextEntity(id = 4, title = "Closing Note", body = "Thank you for reaching out. Have a wonderful day!") + ) + + @Test + fun testAutoTextFilteringByTitle() { + val query = "bank" + val filtered = sampleList.filter { + it.title.contains(query, ignoreCase = true) || it.body.contains(query, ignoreCase = true) + } + assertEquals(1, filtered.size) + assertEquals("Bank Account", filtered[0].title) + } + + @Test + fun testAutoTextFilteringByBody() { + val query = "1234567890" + val filtered = sampleList.filter { + it.title.contains(query, ignoreCase = true) || it.body.contains(query, ignoreCase = true) + } + assertEquals(1, filtered.size) + assertEquals("Bank Account", filtered[0].title) + } + + @Test + fun testAutoTextFilteringCaseInsensitive() { + val query = "GREETING" + val filtered = sampleList.filter { + it.title.contains(query, ignoreCase = true) || it.body.contains(query, ignoreCase = true) + } + assertEquals(1, filtered.size) + assertEquals(1, filtered[0].id) + } + + @Test + fun testAutoTextFilteringEmptyQueryReturnsAll() { + val query = "" + val filtered = sampleList.filter { + it.title.contains(query, ignoreCase = true) || it.body.contains(query, ignoreCase = true) + } + assertEquals(sampleList.size, filtered.size) + } + + @Test + fun testAutoTextFilteringNoMatch() { + val query = "xyznonexistent" + val filtered = sampleList.filter { + it.title.contains(query, ignoreCase = true) || it.body.contains(query, ignoreCase = true) + } + assertTrue(filtered.isEmpty()) + } + + @Test + fun testAutoTextWordCountCalculation() { + val text = "Good morning! How can I help you today?" + val wordCount = text.trim().split(Regex("\\s+")).count { it.isNotEmpty() } + assertEquals(8, wordCount) + + val emptyText = " " + val emptyWordCount = if (emptyText.isBlank()) 0 else emptyText.trim().split(Regex("\\s+")).count { it.isNotEmpty() } + assertEquals(0, emptyWordCount) + } + + @Test + fun testAutoTextCharacterCount() { + val body = "Hello World" + assertEquals(11, body.length) + } + + @Test + fun testAutoTextFormValidation() { + val validTitle = "My Title" + val validBody = "My Body" + assertTrue(validTitle.isNotBlank() && validBody.isNotBlank()) + + val emptyTitle = "" + assertFalse(emptyTitle.isNotBlank() && validBody.isNotBlank()) + + val blankBody = " " + assertFalse(validTitle.isNotBlank() && blankBody.isNotBlank()) + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/KeyboardLanguageTest.kt b/app/src/test/java/com/frogobox/appkeyboard/KeyboardLanguageTest.kt new file mode 100644 index 0000000..db6bc21 --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/KeyboardLanguageTest.kt @@ -0,0 +1,181 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.ui.language.KeyboardLanguage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for KeyboardLanguage data model, metadata integrity, active resolution, and search filtering. + */ +class KeyboardLanguageTest { + + private val sampleLanguages = listOf( + KeyboardLanguage( + name = "English (QWERTY)", + xml = 101, + layoutType = "QWERTY", + code = "EN", + script = "Latin" + ), + KeyboardLanguage( + name = "English (QWERTZ)", + xml = 102, + layoutType = "QWERTZ", + code = "EN", + script = "Latin" + ), + KeyboardLanguage( + name = "English (DVORAK)", + xml = 103, + layoutType = "DVORAK", + code = "EN", + script = "Latin" + ), + KeyboardLanguage( + name = "French", + xml = 104, + layoutType = "AZERTY", + code = "FR", + script = "Français (Latin)" + ), + KeyboardLanguage( + name = "German", + xml = 105, + layoutType = "QWERTZ", + code = "DE", + script = "Deutsch (Latin)" + ), + KeyboardLanguage( + name = "Russian", + xml = 106, + layoutType = "ЙЦУКЕН", + code = "RU", + script = "Русский (Cyrillic)" + ), + KeyboardLanguage( + name = "Persian", + xml = 107, + layoutType = "Standard", + code = "FA", + script = "فارسی (Perso-Arabic)", + isRtl = true + ) + ) + + @Test + fun testKeyboardLanguageBackwardCompatibilityDefaults() { + // Constructor call without new optional parameters + val legacyLanguage = KeyboardLanguage( + name = "Legacy Language", + xml = 999 + ) + + assertEquals("Legacy Language", legacyLanguage.name) + assertEquals(999, legacyLanguage.xml) + assertEquals("Standard", legacyLanguage.layoutType) + assertEquals("EN", legacyLanguage.code) + assertEquals("Latin", legacyLanguage.script) + assertFalse(legacyLanguage.isRtl) + } + + @Test + fun testActiveLanguageResolution() { + val activeXml = 105 // German + val active = sampleLanguages.firstOrNull { it.xml == activeXml } + + assertNotNull(active) + assertEquals("German", active?.name) + assertEquals("DE", active?.code) + assertEquals("QWERTZ", active?.layoutType) + + val nonExistentActive = sampleLanguages.firstOrNull { it.xml == 9999 } + assertNull(nonExistentActive) + } + + @Test + fun testSearchFilteringByName() { + val query = "french" + val results = sampleLanguages.filter { + it.name.lowercase().contains(query.lowercase()) || + it.code.lowercase().contains(query.lowercase()) || + it.layoutType.lowercase().contains(query.lowercase()) || + it.script.lowercase().contains(query.lowercase()) + } + + assertEquals(1, results.size) + assertEquals("French", results[0].name) + assertEquals("FR", results[0].code) + } + + @Test + fun testSearchFilteringByLayoutType() { + val query = "QWERTZ" + val results = sampleLanguages.filter { + it.name.lowercase().contains(query.lowercase()) || + it.code.lowercase().contains(query.lowercase()) || + it.layoutType.lowercase().contains(query.lowercase()) || + it.script.lowercase().contains(query.lowercase()) + } + + // Both English (QWERTZ) and German (QWERTZ) + assertEquals(2, results.size) + assertTrue(results.any { it.name == "English (QWERTZ)" }) + assertTrue(results.any { it.name == "German" }) + } + + @Test + fun testSearchFilteringByCode() { + val query = "RU" + val results = sampleLanguages.filter { + it.name.lowercase().contains(query.lowercase()) || + it.code.lowercase().contains(query.lowercase()) || + it.layoutType.lowercase().contains(query.lowercase()) || + it.script.lowercase().contains(query.lowercase()) + } + + assertEquals(1, results.size) + assertEquals("Russian", results[0].name) + assertEquals("ЙЦУКЕН", results[0].layoutType) + } + + @Test + fun testSearchFilteringByScript() { + val query = "cyrillic" + val results = sampleLanguages.filter { + it.name.lowercase().contains(query.lowercase()) || + it.code.lowercase().contains(query.lowercase()) || + it.layoutType.lowercase().contains(query.lowercase()) || + it.script.lowercase().contains(query.lowercase()) + } + + assertEquals(1, results.size) + assertEquals("Russian", results[0].name) + } + + @Test + fun testSearchFilteringNoResults() { + val query = "NonExistentLanguage123" + val results = sampleLanguages.filter { + it.name.lowercase().contains(query.lowercase()) || + it.code.lowercase().contains(query.lowercase()) || + it.layoutType.lowercase().contains(query.lowercase()) || + it.script.lowercase().contains(query.lowercase()) + } + + assertTrue(results.isEmpty()) + } + + @Test + fun testRtlMetadataIntegrity() { + val persian = sampleLanguages.first { it.code == "FA" } + assertTrue(persian.isRtl) + assertEquals("Standard", persian.layoutType) + + val english = sampleLanguages.first { it.code == "EN" && it.layoutType == "QWERTY" } + assertFalse(english.isRtl) + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/MechanicalSoundAndProgrammerKeyboardTest.kt b/app/src/test/java/com/frogobox/appkeyboard/MechanicalSoundAndProgrammerKeyboardTest.kt new file mode 100644 index 0000000..03a606c --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/MechanicalSoundAndProgrammerKeyboardTest.kt @@ -0,0 +1,126 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.ui.language.KeyboardLanguage +import com.frogobox.libkeyboard.common.sound.MechanicalSoundType +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for Mechanical Sound Switch Profiles (Mechvibes) + * and Programmer / Coding Keyboard (Codeboard style). + */ +class MechanicalSoundAndProgrammerKeyboardTest { + + @Test + fun testMechanicalSoundTypeResolution() { + assertEquals(MechanicalSoundType.CHERRY_MX_BLUE, MechanicalSoundType.fromId("cherry_mx_blue")) + assertEquals(MechanicalSoundType.CHERRY_MX_BROWN, MechanicalSoundType.fromId("cherry_mx_brown")) + assertEquals(MechanicalSoundType.CHERRY_MX_RED, MechanicalSoundType.fromId("cherry_mx_red")) + assertEquals(MechanicalSoundType.TYPEWRITER, MechanicalSoundType.fromId("typewriter")) + assertEquals(MechanicalSoundType.SYSTEM_CLICK, MechanicalSoundType.fromId("system")) + assertEquals(MechanicalSoundType.OFF, MechanicalSoundType.fromId("off")) + + // Case insensitivity + assertEquals(MechanicalSoundType.CHERRY_MX_BLUE, MechanicalSoundType.fromId("CHERRY_MX_BLUE")) + assertEquals(MechanicalSoundType.TYPEWRITER, MechanicalSoundType.fromId("Typewriter")) + + // Fallback for null or unknown id + assertEquals(MechanicalSoundType.CHERRY_MX_BLUE, MechanicalSoundType.fromId(null)) + assertEquals(MechanicalSoundType.CHERRY_MX_BLUE, MechanicalSoundType.fromId("unknown_switch")) + } + + @Test + fun testMechanicalSoundTypeResources() { + val mechanicalProfiles = listOf( + MechanicalSoundType.CHERRY_MX_BLUE, + MechanicalSoundType.CHERRY_MX_BROWN, + MechanicalSoundType.CHERRY_MX_RED, + MechanicalSoundType.TYPEWRITER + ) + + for (profile in mechanicalProfiles) { + assertTrue("Raw resource ID should be valid for $profile", profile.rawResId > 0) + assertTrue("Title should not be empty for $profile", profile.title.isNotBlank()) + assertTrue("Description should not be empty for $profile", profile.description.isNotBlank()) + } + + assertEquals(0, MechanicalSoundType.OFF.rawResId) + assertEquals(-1, MechanicalSoundType.SYSTEM_CLICK.rawResId) + } + + @Test + fun testProgrammerKeycodes() { + assertEquals(-7, ItemMainKeyboard.KEYCODE_TAB) + assertEquals(-8, ItemMainKeyboard.KEYCODE_ARROW_LEFT) + assertEquals(-9, ItemMainKeyboard.KEYCODE_ARROW_RIGHT) + assertEquals(-10, ItemMainKeyboard.KEYCODE_ARROW_UP) + assertEquals(-11, ItemMainKeyboard.KEYCODE_ARROW_DOWN) + + // Ensure keycodes are unique and negative + val keycodes = listOf( + ItemMainKeyboard.KEYCODE_SHIFT, + ItemMainKeyboard.KEYCODE_MODE_CHANGE, + ItemMainKeyboard.KEYCODE_ENTER, + ItemMainKeyboard.KEYCODE_DELETE, + ItemMainKeyboard.KEYCODE_EMOJI, + ItemMainKeyboard.KEYCODE_TAB, + ItemMainKeyboard.KEYCODE_ARROW_LEFT, + ItemMainKeyboard.KEYCODE_ARROW_RIGHT, + ItemMainKeyboard.KEYCODE_ARROW_UP, + ItemMainKeyboard.KEYCODE_ARROW_DOWN + ) + + assertEquals(keycodes.size, keycodes.toSet().size) + assertTrue(keycodes.all { it < 0 }) + } + + @Test + fun testProgrammerKeyboardMetadataAndSearch() { + val programmerKeyboard = KeyboardLanguage( + name = "Programmer / Coding (QWERTY)", + xml = com.frogobox.libkeyboard.R.xml.keys_letters_programmer, + layoutType = "Codeboard", + code = "DEV", + script = "Code & Symbols" + ) + + assertEquals("Programmer / Coding (QWERTY)", programmerKeyboard.name) + assertEquals("Codeboard", programmerKeyboard.layoutType) + assertEquals("DEV", programmerKeyboard.code) + assertEquals("Code & Symbols", programmerKeyboard.script) + + val languages = listOf( + KeyboardLanguage("English (QWERTY)", 1, "QWERTY", "EN", "Latin"), + programmerKeyboard + ) + + // Search by 'programmer' + val searchProgrammer = languages.filter { it.name.lowercase().contains("programmer") } + assertEquals(1, searchProgrammer.size) + assertEquals("DEV", searchProgrammer[0].code) + + // Search by 'codeboard' + val searchCodeboard = languages.filter { it.layoutType.lowercase().contains("codeboard") } + assertEquals(1, searchCodeboard.size) + + // Search by 'dev' + val searchDev = languages.filter { it.code.lowercase().contains("dev") } + assertEquals(1, searchDev.size) + } + + @Test + fun testVolumeClamping() { + fun clampVolume(rawVolumePercent: Int): Float { + return (rawVolumePercent / 100f).coerceIn(0.05f, 1.0f) + } + + assertEquals(0.8f, clampVolume(80), 0.001f) + assertEquals(0.05f, clampVolume(0), 0.001f) + assertEquals(0.05f, clampVolume(-20), 0.001f) + assertEquals(1.0f, clampVolume(100), 0.001f) + assertEquals(1.0f, clampVolume(150), 0.001f) + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt b/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt new file mode 100644 index 0000000..dcfbc7f --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt @@ -0,0 +1,96 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.model.KeyboardThemeModel +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 + +/** + * Unit test for Keyboard Theme models and theme logic verification. + */ +class ThemeTest { + + @Test + fun testKeyboardThemeTypeMapping() { + val themes = KeyboardThemeType.entries.map { it.mapToModel() } + assertEquals(11, themes.size) + + val defaultTheme = themes.first { it.name == "Default" } + assertEquals(ThemeType.COLOR, defaultTheme.themType) + assertTrue(defaultTheme.background != 0) + + val purpleTheme = themes.first { it.name == "Frogo Purple" } + assertEquals(ThemeType.COLOR, purpleTheme.themType) + assertTrue(purpleTheme.background != 0) + + val imageTheme = themes.first { it.name == "Wallpaper" } + assertEquals(ThemeType.IMAGE, imageTheme.themType) + assertTrue(imageTheme.background != 0) + } + + @Test + fun testKeyboardThemeTypeFrom() { + assertEquals(KeyboardThemeType.DEFAULT, KeyboardThemeType from "DEFAULT") + assertEquals(KeyboardThemeType.PURPLE, KeyboardThemeType from "PURPLE") + assertEquals(KeyboardThemeType.DARK, KeyboardThemeType from "DARK") + assertEquals(KeyboardThemeType.BLUE, KeyboardThemeType from "BLUE") + assertEquals(KeyboardThemeType.GREEN, KeyboardThemeType from "GREEN") + assertEquals(KeyboardThemeType.RED, KeyboardThemeType from "RED") + assertEquals(KeyboardThemeType.ORANGE, KeyboardThemeType from "ORANGE") + assertEquals(KeyboardThemeType.CYAN, KeyboardThemeType from "CYAN") + assertEquals(KeyboardThemeType.PINK, KeyboardThemeType from "PINK") + assertEquals(KeyboardThemeType.YELLOW, KeyboardThemeType from "YELLOW") + assertEquals(KeyboardThemeType.IMAGE_BG_DARK, KeyboardThemeType from "IMAGE_BG_DARK") + // Unknown fallback to DEFAULT + assertEquals(KeyboardThemeType.DEFAULT, KeyboardThemeType from "UNKNOWN_THEME") + } + + @Test + fun testThemeActiveMatchingLogic() { + val colorTheme = KeyboardThemeModel( + name = "Default", + description = "Default Color", + themType = ThemeType.COLOR, + background = 1001 + ) + val imageTheme = KeyboardThemeModel( + name = "Image", + description = "Sample Wallpaper", + themType = ThemeType.IMAGE, + background = 1001 // simulate identical integer resource ID + ) + + // Matching function considering both background and type + fun isThemeActive( + theme: KeyboardThemeModel, + activeColor: Int, + activeType: String + ): Boolean { + return theme.background == activeColor && theme.themType.name == activeType + } + + // When COLOR is active + assertTrue(isThemeActive(colorTheme, 1001, ThemeType.COLOR.name)) + assertFalse(isThemeActive(imageTheme, 1001, ThemeType.COLOR.name)) + + // When IMAGE is active + assertFalse(isThemeActive(colorTheme, 1001, ThemeType.IMAGE.name)) + assertTrue(isThemeActive(imageTheme, 1001, ThemeType.IMAGE.name)) + } + + @Test + fun testSafeThemeTypeParsing() { + val safeParse = { typeStr: String -> + runCatching { ThemeType.valueOf(typeStr) }.getOrDefault(ThemeType.COLOR) + } + + assertEquals(ThemeType.COLOR, safeParse("COLOR")) + assertEquals(ThemeType.IMAGE, safeParse("IMAGE")) + assertEquals(ThemeType.COLOR, safeParse("CORRUPTED_VALUE")) + assertEquals(ThemeType.COLOR, safeParse("")) + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/TypingMetricsTest.kt b/app/src/test/java/com/frogobox/appkeyboard/TypingMetricsTest.kt new file mode 100644 index 0000000..6ffff4a --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/TypingMetricsTest.kt @@ -0,0 +1,80 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.ui.test.TypingMetricsCalculator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for TypingMetricsCalculator domain logic + * Verifies character counting, word counting, line counting, and WPM calculation safety guards. + */ +class TypingMetricsTest { + + @Test + fun testEmptyTextMetrics() { + val metrics = TypingMetricsCalculator.calculate("", 5000L) + assertEquals(0, metrics.characterCount) + assertEquals(0, metrics.wordCount) + assertEquals(0, metrics.lineCount) + assertEquals(0, metrics.wordsPerMinute) + assertEquals(5L, metrics.elapsedSeconds) + } + + @Test + fun testWhitespaceOnlyMetrics() { + val metrics = TypingMetricsCalculator.calculate(" \n\t ", 10000L) + assertEquals(7, metrics.characterCount) + assertEquals(0, metrics.wordCount) + assertEquals(2, metrics.lineCount) + assertEquals(0, metrics.wordsPerMinute) + } + + @Test + fun testEnglishPangramWordCount() { + val pangram = "The quick brown fox jumps over the lazy dog" + val metrics = TypingMetricsCalculator.calculate(pangram, 12000L) + assertEquals(43, metrics.characterCount) + assertEquals(9, metrics.wordCount) + assertEquals(1, metrics.lineCount) + // 9 words in 12s = 9 / (12/60) = 45 WPM + assertEquals(45, metrics.wordsPerMinute) + } + + @Test + fun testIndonesianPangramMetrics() { + val pangram = "Keluarga Jefri bernomor fax tujuh belas" + val metrics = TypingMetricsCalculator.calculate(pangram, 6000L) + assertEquals(39, metrics.characterCount) + assertEquals(6, metrics.wordCount) + assertEquals(1, metrics.lineCount) + // 6 words in 6s = 6 / 0.1 = 60 WPM + assertEquals(60, metrics.wordsPerMinute) + } + + @Test + fun testMultilineCount() { + val text = "Line 1\nLine 2\nLine 3\nLine 4" + val metrics = TypingMetricsCalculator.calculate(text, 20000L) + assertEquals(4, metrics.lineCount) + assertEquals(8, metrics.wordCount) + } + + @Test + fun testWpmShortDurationReturnsZero() { + val text = "Hello world quick test" + val metrics = TypingMetricsCalculator.calculate(text, 1500L) // 1.5s < 2s threshold + assertEquals(4, metrics.wordCount) + assertEquals(0, metrics.wordsPerMinute) + } + + @Test + fun testWpmClampedToMax300() { + // 500 words pasted in 3 seconds -> extreme raw WPM + val manyWords = (1..500).joinToString(" ") { "word$it" } + val metrics = TypingMetricsCalculator.calculate(manyWords, 3000L) + assertEquals(500, metrics.wordCount) + assertTrue(metrics.wordsPerMinute <= 300) + assertEquals(300, metrics.wordsPerMinute) + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/WordSuggestionEngineTest.kt b/app/src/test/java/com/frogobox/appkeyboard/WordSuggestionEngineTest.kt new file mode 100644 index 0000000..eb9440f --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/WordSuggestionEngineTest.kt @@ -0,0 +1,114 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.suggestion.WordSuggestionEngine +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit test suite for WordSuggestionEngine + * Validates three-section candidates (Issue #50): + * - Section 1: user's literal word + * - Section 2: predicted word + * - Section 3: auto-correct word + */ +class WordSuggestionEngineTest { + + private lateinit var engine: WordSuggestionEngine + + @Before + fun setup() { + engine = WordSuggestionEngine() + } + + @Test + fun testThreeSectionIntegrity() { + val result = engine.getSuggestions("th") + assertNotNull(result) + assertEquals("th", result.userWord) + assertTrue(result.predictedWord.isNotEmpty()) + assertTrue(result.autoCorrectWord.isNotEmpty()) + assertTrue(result.hasSuggestions()) + } + + @Test + fun testPrefixPrediction() { + val result = engine.getSuggestions("appl") + assertEquals("appl", result.userWord) + assertEquals("apple", result.predictedWord.lowercase()) + } + + @Test + fun testCommonTypoAutoCorrection() { + // Test "teh" -> "the" + val resultThe = engine.getSuggestions("teh") + assertEquals("teh", resultThe.userWord) + assertEquals("the", resultThe.predictedWord.lowercase()) + + // Test "wht" -> "what" + val resultWhat = engine.getSuggestions("wht") + assertEquals("wht", resultWhat.userWord) + assertEquals("what", resultWhat.predictedWord.lowercase()) + + // Test "helo" -> "hello" + val resultHello = engine.getSuggestions("helo") + assertEquals("helo", resultHello.userWord) + assertEquals("hello", resultHello.predictedWord.lowercase()) + } + + @Test + fun testCasingPreservationLowercase() { + val result = engine.getSuggestions("appl") + assertEquals("appl", result.userWord) + assertEquals("apple", result.predictedWord) + } + + @Test + fun testCasingPreservationTitleCase() { + val result = engine.getSuggestions("Appl") + assertEquals("Appl", result.userWord) + assertEquals("Apple", result.predictedWord) + } + + @Test + fun testCasingPreservationUppercase() { + val result = engine.getSuggestions("APPL") + assertEquals("APPL", result.userWord) + assertEquals("APPLE", result.predictedWord) + } + + @Test + fun testLevenshteinDistanceCalculation() { + assertEquals(0, engine.calculateLevenshteinDistance("test", "test")) + assertEquals(1, engine.calculateLevenshteinDistance("hello", "helo")) + assertEquals(1, engine.calculateLevenshteinDistance("apply", "apple")) + assertEquals(3, engine.calculateLevenshteinDistance("kitten", "sitting")) + } + + @Test + fun testUserCustomWordLearning() { + engine.addUserWord("FrogoDeveloper") + val result = engine.getSuggestions("frogodev") + assertEquals("frogodev", result.userWord) + assertEquals("frogodeveloper", result.predictedWord.lowercase()) + } + + @Test + fun testEmptyInputHandling() { + val result = engine.getSuggestions("") + assertNotNull(result) + assertEquals("", result.userWord) + assertTrue(result.predictedWord.isNotEmpty()) + } + + @Test + fun testBlankWhitespaceInputHandling() { + val result = engine.getSuggestions(" ") + assertNotNull(result) + assertEquals("", result.userWord) + } + +} diff --git a/build.gradle.kts b/build.gradle.kts index 47d56ba..2d79dba 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,12 +2,13 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false - alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.jetbrains.kotlin.jvm) apply false + alias(libs.plugins.jetbrains.kotlin.compose) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false } tasks.register("clean", Delete::class) { + description = "" delete(rootProject.layout.buildDirectory) } \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/ProjectSetting.kt b/buildSrc/src/main/kotlin/ProjectSetting.kt index 337ef85..5e783c2 100644 --- a/buildSrc/src/main/kotlin/ProjectSetting.kt +++ b/buildSrc/src/main/kotlin/ProjectSetting.kt @@ -27,9 +27,9 @@ object ProjectSetting { // --------------------------------------------------------------------------------------------- - const val PROJECT_MIN_SDK = 24 - const val PROJECT_COMPILE_SDK = 36 - const val PROJECT_TARGET_SDK = PROJECT_COMPILE_SDK + const val PROJECT_MIN_SDK = 25 + const val PROJECT_COMPILE_SDK = 37 + const val PROJECT_TARGET_SDK = 36 // --------------------------------------------------------------------------------------------- diff --git a/frogo-keyboard/build.gradle.kts b/frogo-keyboard/build.gradle.kts index 28265bd..c8439dd 100644 --- a/frogo-keyboard/build.gradle.kts +++ b/frogo-keyboard/build.gradle.kts @@ -2,14 +2,13 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.library) - alias(libs.plugins.jetbrains.kotlin.android) `maven-publish` } android { namespace = ProjectSetting.PROJECT_NAME_SPACE_LIB_FROGO_KEYBOARD - compileSdk = ProjectSetting.PROJECT_TARGET_SDK + compileSdk = ProjectSetting.PROJECT_COMPILE_SDK defaultConfig { @@ -40,6 +39,13 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + } + } kotlin { diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt index 54429fd..16aea89 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/core/BaseKeyboardIME.kt @@ -27,13 +27,15 @@ import com.frogobox.libkeyboard.ui.main.OnKeyboardActionListener // based on https://www.androidauthority.com/lets-build-custom-keyboard-android-832362/ abstract class BaseKeyboardIME : InputMethodService(), OnKeyboardActionListener, IKeyboardIME { - // how quickly do we have to doubletap shift to enable permanent caps lock - var SHIFT_PERM_TOGGLE_SPEED = 500 - val KEYBOARD_LETTERS = 0 - val KEYBOARD_SYMBOLS = 1 - val KEYBOARD_SYMBOLS_SHIFT = 2 - val KEYBOARD_NUMBER = 3 - val KEYCODE_EMOJI = -6 + companion object { + // How quickly do we have to doubletap shift to enable permanent caps lock + const val SHIFT_PERM_TOGGLE_SPEED = 500L + + const val KEYBOARD_LETTERS = 0 + const val KEYBOARD_SYMBOLS = 1 + const val KEYBOARD_SYMBOLS_SHIFT = 2 + const val KEYBOARD_NUMBER = 3 + } var keyboard: ItemMainKeyboard? = null @@ -77,12 +79,12 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo } override fun onPress(primaryCode: Int) { - } override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) { super.onStartInput(attribute, restarting) - inputTypeClass = attribute!!.inputType and TYPE_MASK_CLASS + if (attribute == null) return + inputTypeClass = attribute.inputType and TYPE_MASK_CLASS enterKeyType = attribute.imeOptions and (IME_MASK_ACTION or IME_FLAG_NO_ENTER_ACTION) val keyboardXml = when (inputTypeClass) { @@ -107,9 +109,8 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo updateShiftKeyState() } - override fun onKey(code: Int) { - var inputConnection = currentInputConnection + val inputConnection = currentInputConnection ?: return onKeyExt(code, inputConnection) } @@ -120,7 +121,7 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo val editorInfo = currentInputEditorInfo if (editorInfo != null && editorInfo.inputType != InputType.TYPE_NULL && keyboard?.mShiftState != SHIFT_ON_PERMANENT) { - if (currentInputConnection.getCursorCapsMode(editorInfo.inputType) != 0) { + if (currentInputConnection?.getCursorCapsMode(editorInfo.inputType) != 0) { keyboard?.setShifted(SHIFT_ON_ONE_CHAR) } } @@ -139,7 +140,7 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo } override fun onText(text: String) { - currentInputConnection?.commitText(text, 0) + currentInputConnection?.commitText(text, 1) } override fun initialSetupKeyboard() {} @@ -193,7 +194,7 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo if (keyboardMode == KEYBOARD_LETTERS) { val editorInfo = currentInputEditorInfo if (editorInfo != null && editorInfo.inputType != InputType.TYPE_NULL && keyboard?.mShiftState != SHIFT_ON_PERMANENT) { - if (currentInputConnection.getCursorCapsMode(editorInfo.inputType) != 0) { + if (currentInputConnection?.getCursorCapsMode(editorInfo.inputType) != 0) { keyboard?.setShifted(SHIFT_ON_ONE_CHAR) invalidateAllKeys() } @@ -201,11 +202,8 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo } } - override fun onKeyExt(code: Int, inputConnection: InputConnection) { - if (keyboard == null) { - return - } + val kb = keyboard ?: return if (code != ItemMainKeyboard.KEYCODE_SHIFT) { lastShiftPressTS = 0 @@ -213,19 +211,18 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo when (code) { ItemMainKeyboard.KEYCODE_DELETE -> { - if (keyboard!!.mShiftState == SHIFT_ON_ONE_CHAR) { - keyboard!!.mShiftState = SHIFT_OFF + if (kb.mShiftState == SHIFT_ON_ONE_CHAR) { + kb.mShiftState = SHIFT_OFF } val selectedText = inputConnection.getSelectedText(0) if (TextUtils.isEmpty(selectedText)) { inputConnection.sendKeyEvent( - KeyEvent( - KeyEvent.ACTION_DOWN, - KeyEvent.KEYCODE_DEL - ) + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL) + ) + inputConnection.sendKeyEvent( + KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL) ) - inputConnection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL)) } else { inputConnection.commitText("", 1) } @@ -239,14 +236,10 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo ItemMainKeyboard.KEYCODE_SHIFT -> { if (keyboardMode == KEYBOARD_LETTERS) { when { - keyboard!!.mShiftState == SHIFT_ON_PERMANENT -> keyboard!!.mShiftState = - SHIFT_OFF - System.currentTimeMillis() - lastShiftPressTS < SHIFT_PERM_TOGGLE_SPEED -> keyboard!!.mShiftState = - SHIFT_ON_PERMANENT - keyboard!!.mShiftState == SHIFT_ON_ONE_CHAR -> keyboard!!.mShiftState = - SHIFT_OFF - keyboard!!.mShiftState == SHIFT_OFF -> keyboard!!.mShiftState = - SHIFT_ON_ONE_CHAR + kb.mShiftState == SHIFT_ON_PERMANENT -> kb.mShiftState = SHIFT_OFF + System.currentTimeMillis() - lastShiftPressTS < SHIFT_PERM_TOGGLE_SPEED -> kb.mShiftState = SHIFT_ON_PERMANENT + kb.mShiftState == SHIFT_ON_ONE_CHAR -> kb.mShiftState = SHIFT_OFF + kb.mShiftState == SHIFT_OFF -> kb.mShiftState = SHIFT_ON_ONE_CHAR } lastShiftPressTS = System.currentTimeMillis() @@ -269,23 +262,16 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo inputConnection.performEditorAction(imeOptionsActionId) } else { inputConnection.sendKeyEvent( - KeyEvent( - KeyEvent.ACTION_DOWN, - KeyEvent.KEYCODE_ENTER - ) + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER) ) inputConnection.sendKeyEvent( - KeyEvent( - KeyEvent.ACTION_UP, - KeyEvent.KEYCODE_ENTER - ) + KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER) ) } if (inputConnection != currentInputConnection) { inputConnection.commitText("\n", 1) } - } ItemMainKeyboard.KEYCODE_MODE_CHANGE -> { val keyboardXml = if (keyboardMode == KEYBOARD_LETTERS) { @@ -301,9 +287,45 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo ItemMainKeyboard.KEYCODE_EMOJI -> { runEmojiBoard() } + ItemMainKeyboard.KEYCODE_TAB -> { + val isMultiline = (currentInputEditorInfo?.inputType ?: 0) and InputType.TYPE_TEXT_FLAG_MULTI_LINE != 0 + if (isMultiline) { + inputConnection.commitText("\t", 1) + } else { + inputConnection.sendKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_TAB) + ) + inputConnection.sendKeyEvent( + KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_TAB) + ) + } + } + ItemMainKeyboard.KEYCODE_ARROW_LEFT -> { + moveCursor(false) + } + ItemMainKeyboard.KEYCODE_ARROW_RIGHT -> { + moveCursor(true) + } + ItemMainKeyboard.KEYCODE_ARROW_UP -> { + inputConnection.sendKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP) + ) + inputConnection.sendKeyEvent( + KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_UP) + ) + } + ItemMainKeyboard.KEYCODE_ARROW_DOWN -> { + inputConnection.sendKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN) + ) + inputConnection.sendKeyEvent( + KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_DOWN) + ) + } else -> { + if (code <= 0) return var codeChar = code.toChar() - if (Character.isLetter(codeChar) && keyboard!!.mShiftState > SHIFT_OFF) { + if (Character.isLetter(codeChar) && kb.mShiftState > SHIFT_OFF) { codeChar = Character.toUpperCase(codeChar) } @@ -314,14 +336,14 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo val originalText = inputConnection.getExtractedText(ExtractedTextRequest(), 0)?.text ?: return inputConnection.commitText(codeChar.toString(), 1) - val newText = inputConnection.getExtractedText(ExtractedTextRequest(), 0).text - switchToLetters = originalText != newText + val newText = inputConnection.getExtractedText(ExtractedTextRequest(), 0)?.text + switchToLetters = newText != null && originalText != newText } else { inputConnection.commitText(codeChar.toString(), 1) } - if (keyboard!!.mShiftState == SHIFT_ON_ONE_CHAR && keyboardMode == KEYBOARD_LETTERS) { - keyboard!!.mShiftState = SHIFT_OFF + if (kb.mShiftState == SHIFT_ON_ONE_CHAR && keyboardMode == KEYBOARD_LETTERS) { + kb.mShiftState = SHIFT_OFF invalidateAllKeys() } } @@ -336,10 +358,11 @@ abstract class BaseKeyboardIME : InputMethodService(), OnKeybo val extractedText = currentInputConnection?.getExtractedText(ExtractedTextRequest(), 0) ?: return var newCursorPosition = extractedText.selectionStart + val textLength = extractedText.text?.length ?: return newCursorPosition = if (moveRight) { - newCursorPosition + 1 + (newCursorPosition + 1).coerceAtMost(textLength) } else { - newCursorPosition - 1 + (newCursorPosition - 1).coerceAtLeast(0) } currentInputConnection?.setSelection(newCursorPosition, newCursorPosition) diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt index 85576ae..d00900e 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/ContextExt.kt @@ -3,11 +3,10 @@ package com.frogobox.libkeyboard.common.ext import android.content.Context import android.content.res.Configuration import android.content.res.Configuration.UI_MODE_NIGHT_YES -import android.graphics.PorterDuff import android.graphics.drawable.Drawable import android.widget.ImageView -import com.frogobox.libkeyboard.R -import com.frogobox.sdk.ext.getColorExt +import androidx.core.graphics.BlendModeColorFilterCompat +import androidx.core.graphics.BlendModeCompat /** * Created by Faisal Amir on 24/10/22 @@ -23,6 +22,11 @@ fun Context.isDarkThemeOn(): Boolean { return resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES } -fun Drawable.applyColorFilter(color: Int) = mutate().setColorFilter(color, PorterDuff.Mode.SRC_IN) +fun Drawable.applyColorFilter(color: Int) { + mutate() + colorFilter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN) +} -fun ImageView.applyColorFilter(color: Int) = setColorFilter(color, PorterDuff.Mode.SRC_IN) \ No newline at end of file +fun ImageView.applyColorFilter(color: Int) { + colorFilter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, BlendModeCompat.SRC_IN) +} \ No newline at end of file diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/IntExt.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/IntExt.kt index 0ecf452..bdb1602 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/IntExt.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/ext/IntExt.kt @@ -1,13 +1,19 @@ package com.frogobox.libkeyboard.common.ext import android.graphics.Color +import kotlin.math.roundToInt fun hsl2hsv(hsl: FloatArray): FloatArray { val hue = hsl[0] var sat = hsl[1] val light = hsl[2] sat *= if (light < .5) light else 1 - light - return floatArrayOf(hue, 2f * sat / (light + sat), light + sat) + val denominator = light + sat + return if (denominator == 0f) { + floatArrayOf(hue, 0f, 0f) + } else { + floatArrayOf(hue, 2f * sat / denominator, denominator) + } } fun hsv2hsl(hsv: FloatArray): FloatArray { @@ -16,45 +22,37 @@ fun hsv2hsl(hsv: FloatArray): FloatArray { val value = hsv[2] val newHue = (2f - sat) * value - var newSat = sat * value / if (newHue < 1f) newHue else 2f - newHue + val denominator = if (newHue < 1f) newHue else 2f - newHue + var newSat = if (denominator == 0f) 0f else sat * value / denominator if (newSat > 1f) newSat = 1f return floatArrayOf(hue, newSat, newHue / 2f) } -fun Int.lightenColor(factor: Int = 8): Int { +/** + * Adjusts the lightness of a color by a given factor (in percent). + * Positive factor lightens, negative factor darkens. + */ +private fun Int.adjustLightness(factor: Int): Int { if (this == Color.WHITE || this == Color.BLACK) { return this } - val LIGHT_FACTOR = factor var hsv = FloatArray(3) Color.colorToHSV(this, hsv) val hsl = hsv2hsl(hsv) - hsl[2] += LIGHT_FACTOR / 100f - if (hsl[2] < 0) hsl[2] = 0f + hsl[2] = (hsl[2] + factor / 100f).coerceIn(0f, 1f) hsv = hsl2hsv(hsl) return Color.HSVToColor(hsv) } -// taken from https://stackoverflow.com/a/40964456/1967672 -fun Int.darkenColor(factor: Int = 8): Int { - if (this == Color.WHITE || this == Color.BLACK) { - return this - } +fun Int.lightenColor(factor: Int = 8): Int = adjustLightness(factor) - val DARK_FACTOR = factor - var hsv = FloatArray(3) - Color.colorToHSV(this, hsv) - val hsl = hsv2hsl(hsv) - hsl[2] -= DARK_FACTOR / 100f - if (hsl[2] < 0) hsl[2] = 0f - hsv = hsl2hsv(hsl) - return Color.HSVToColor(hsv) -} +// taken from https://stackoverflow.com/a/40964456/1967672 +fun Int.darkenColor(factor: Int = 8): Int = adjustLightness(-factor) fun Int.adjustAlpha(factor: Float): Int { - val alpha = Math.round(Color.alpha(this) * factor) + val alpha = (Color.alpha(this) * factor).roundToInt() val red = Color.red(this) val green = Color.green(this) val blue = Color.blue(this) @@ -66,4 +64,3 @@ fun Int.getContrastColor(): Int { val y = (299 * Color.red(this) + 587 * Color.green(this) + 114 * Color.blue(this)) / 1000 return if (y >= 149 && this != Color.BLACK) DARK_GREY else Color.WHITE } - diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/sound/MechanicalSoundManager.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/sound/MechanicalSoundManager.kt new file mode 100644 index 0000000..a90a937 --- /dev/null +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/common/sound/MechanicalSoundManager.kt @@ -0,0 +1,129 @@ +package com.frogobox.libkeyboard.common.sound + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.SoundPool +import android.util.Log +import androidx.annotation.RawRes +import com.frogobox.libkeyboard.R +import java.util.concurrent.ConcurrentHashMap +import kotlin.random.Random + +/** + * Mechanical Keyboard Switch Profiles (Mechvibes compatible) + */ +enum class MechanicalSoundType( + val id: String, + val title: String, + val description: String, + @RawRes val rawResId: Int +) { + OFF("off", "Off (Mute)", "Silent typing with no acoustic feedback", 0), + SYSTEM_CLICK("system", "System Default Click", "Native Android system keyclick sound effect", -1), + CHERRY_MX_BLUE("cherry_mx_blue", "Cherry MX Blue", "Crisp tactile click with high-frequency snap", R.raw.mechanical_cherry_blue), + CHERRY_MX_BROWN("cherry_mx_brown", "Cherry MX Brown", "Balanced tactile bump with rounded acoustic thock", R.raw.mechanical_cherry_brown), + CHERRY_MX_RED("cherry_mx_red", "Cherry MX Red", "Smooth linear travel with clean bottom-out thock", R.raw.mechanical_cherry_red), + TYPEWRITER("typewriter", "Classic Typewriter", "Vintage mechanical typewriter strike & carriage clink", R.raw.mechanical_typewriter); + + companion object { + fun fromId(id: String?): MechanicalSoundType { + return entries.firstOrNull { it.id.equals(id, ignoreCase = true) } ?: CHERRY_MX_BLUE + } + } +} + +/** + * Low-latency SoundPool Audio Engine for Mechanical Keyboard feedback. + */ +class MechanicalSoundManager private constructor(context: Context) { + + companion object { + private const val TAG = "MechanicalSoundManager" + const val PREF_KEYBOARD_SOUND_ENABLED = "KEYBOARD_SOUND_ENABLED" + const val PREF_KEYBOARD_SOUND_TYPE = "KEYBOARD_SOUND_TYPE" + const val PREF_KEYBOARD_SOUND_VOLUME = "KEYBOARD_SOUND_VOLUME" + const val PREF_KEYBOARD_VIBRATE_ENABLED = "KEYBOARD_VIBRATE_ENABLED" + + @Volatile + private var INSTANCE: MechanicalSoundManager? = null + + fun getInstance(context: Context): MechanicalSoundManager { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: MechanicalSoundManager(context.applicationContext).also { INSTANCE = it } + } + } + } + + private val appContext = context.applicationContext + private val audioManager = appContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + private var soundPool: SoundPool? = null + private val soundIdMap = ConcurrentHashMap() + private val loadedSounds = ConcurrentHashMap() + + init { + initSoundPool() + } + + private fun initSoundPool() { + val audioAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + + soundPool = SoundPool.Builder() + .setMaxStreams(8) + .setAudioAttributes(audioAttributes) + .build().apply { + setOnLoadCompleteListener { _, sampleId, status -> + if (status == 0) { + loadedSounds[sampleId] = true + } + } + } + + preloadSounds() + } + + private fun preloadSounds() { + val pool = soundPool ?: return + for (type in MechanicalSoundType.entries) { + if (type.rawResId > 0) { + try { + val soundId = pool.load(appContext, type.rawResId, 1) + soundIdMap[type] = soundId + } catch (e: Exception) { + Log.e(TAG, "Failed to load sound buffer for $type", e) + } + } + } + } + + fun playKeySound( + soundType: MechanicalSoundType = MechanicalSoundType.CHERRY_MX_BLUE, + volume: Float = 0.8f + ) { + if (soundType == MechanicalSoundType.OFF) return + + if (soundType == MechanicalSoundType.SYSTEM_CLICK) { + audioManager?.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD, volume) + return + } + + val pool = soundPool ?: return + val soundId = soundIdMap[soundType] ?: return + + // Slight human acoustic jitter (+/- 2% pitch variation) + val pitchVariation = 0.98f + Random.nextFloat() * 0.04f + val clampedVol = volume.coerceIn(0.05f, 1.0f) + + pool.play(soundId, clampedVol, clampedVol, 1, 0, pitchVariation) + } + + fun release() { + soundPool?.release() + soundPool = null + soundIdMap.clear() + loadedSounds.clear() + } +} diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiHelper.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiHelper.kt index f9c9a3f..878fef5 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiHelper.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiHelper.kt @@ -1,9 +1,12 @@ package com.frogobox.libkeyboard.ui.emoji import android.content.Context +import android.util.Log + +private const val TAG = "EmojiHelper" /** - * Reads the emoji list at the given [path] and returns an parsed [MutableList]. If the + * Reads the emoji list at the given [path] and returns a parsed [MutableList]. If the * given file path does not exist, an empty [MutableList] is returned. * * @param context The initiating view's context. @@ -17,54 +20,52 @@ fun parseRawEmojiSpecsFile(context: Context, path: String): MutableList fun commitEmojiEditorList() { emojiEditorList?.let { // add only the base emoji for now, ignore the variations - emojis.add(it.first()) + if (it.isNotEmpty()) { + emojis.add(it.first()) + } } emojiEditorList = null } - context.assets.open(path).bufferedReader().useLines { lines -> - for (line in lines) { - if (line.startsWith("#")) { - // Comment line - } else if (line.startsWith("[")) { - commitEmojiEditorList() - } else if (line.trim().isEmpty()) { - // Empty line - continue - } else { - if (!line.startsWith("\t")) { + try { + context.assets.open(path).bufferedReader().useLines { lines -> + for (line in lines) { + if (line.startsWith("#")) { + // Comment line + } else if (line.startsWith("[")) { commitEmojiEditorList() - } + } else if (line.trim().isEmpty()) { + // Empty line + continue + } else { + if (!line.startsWith("\t")) { + commitEmojiEditorList() + } - // Assume it is a data line - val data = line.split(";") - if (data.size == 3) { - val emoji = data[0].trim() - if (emojiEditorList != null) { - emojiEditorList!!.add(emoji) - } else { - emojiEditorList = mutableListOf(emoji) + // Assume it is a data line + val data = line.split(";") + if (data.size == 3) { + val emoji = data[0].trim() + emojiEditorList?.add(emoji) ?: run { + emojiEditorList = mutableListOf(emoji) + } } } } + commitEmojiEditorList() } - commitEmojiEditorList() + } catch (e: Exception) { + Log.e(TAG, "Failed to parse emoji file: $path", e) } - + return emojis } -fun getEmojiCategory() : List { - return listOf( - EmojiCategory(EmojiCategoryType.GENERAL.name, EmojiCategoryType.GENERAL.icon, EmojiCategoryType.GENERAL.path), - EmojiCategory(EmojiCategoryType.SMILEYS_EMOTION.name, EmojiCategoryType.SMILEYS_EMOTION.icon, EmojiCategoryType.SMILEYS_EMOTION.path), - EmojiCategory(EmojiCategoryType.PEOPLE_BODY.name, EmojiCategoryType.PEOPLE_BODY.icon, EmojiCategoryType.PEOPLE_BODY.path), - EmojiCategory(EmojiCategoryType.ACTIVITIES.name, EmojiCategoryType.ACTIVITIES.icon, EmojiCategoryType.ACTIVITIES.path), - EmojiCategory(EmojiCategoryType.ANIMAL_NATURE.name, EmojiCategoryType.ANIMAL_NATURE.icon, EmojiCategoryType.ANIMAL_NATURE.path), - EmojiCategory(EmojiCategoryType.FOOD_DRINK.name, EmojiCategoryType.FOOD_DRINK.icon, EmojiCategoryType.FOOD_DRINK.path), - EmojiCategory(EmojiCategoryType.FLAG.name, EmojiCategoryType.FLAG.icon, EmojiCategoryType.FLAG.path), - EmojiCategory(EmojiCategoryType.OBJECTS.name, EmojiCategoryType.OBJECTS.icon, EmojiCategoryType.OBJECTS.path), - EmojiCategory(EmojiCategoryType.TRAVEL_PLACES.name, EmojiCategoryType.TRAVEL_PLACES.icon, EmojiCategoryType.TRAVEL_PLACES.path), - EmojiCategory(EmojiCategoryType.SYMBOLS.name, EmojiCategoryType.SYMBOLS.icon, EmojiCategoryType.SYMBOLS.path) - ) +/** + * Returns all emoji categories auto-generated from [EmojiCategoryType] enum entries. + */ +fun getEmojiCategory(): List { + return EmojiCategoryType.entries.map { type -> + EmojiCategory(name = type.name, icon = type.icon, path = type.path) + } } \ No newline at end of file diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboard.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboard.kt index 5671c71..ed19597 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboard.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboard.kt @@ -36,6 +36,14 @@ class EmojiKeyboard( private var emojiCompatMetadataVersion = 0 + /** Cache parsed emoji lists to avoid re-parsing files on category switch */ + private val emojiCache = mutableMapOf>() + + /** Reusable Paint for glyph checking — avoid allocating every time */ + private val systemFontPaint = Paint().apply { + typeface = Typeface.DEFAULT + } + var mOnKeyboardActionListener: OnKeyboardActionListener? = null override fun setupViewBinding(inflater: LayoutInflater, parent: LinearLayout): KeyboardEmojiBinding { @@ -50,6 +58,11 @@ class EmojiKeyboard( setupEmojiCategory() } + /** Clears the emoji cache. Call this if emoji assets are updated. */ + fun clearEmojiCache() { + emojiCache.clear() + } + private fun setupEmojiCategory() { val adapterCallback = object : IFrogoBindingAdapter { @@ -106,11 +119,15 @@ class EmojiKeyboard( } private fun setupEmojis(path: String) { + // Check cache first + val cachedEmojis = emojiCache[path] + if (cachedEmojis != null) { + setupEmojiAdapter(cachedEmojis) + return + } + ensureBackgroundThread { val fullEmojiList = parseRawEmojiSpecsFile(context, path) - val systemFontPaint = Paint().apply { - typeface = Typeface.DEFAULT - } val emojis = fullEmojiList.filter { emoji -> systemFontPaint.hasGlyph(emoji) || (EmojiCompat.get().loadState == EmojiCompat.LOAD_STATE_SUCCEEDED @@ -120,10 +137,12 @@ class EmojiKeyboard( ) == EmojiCompat.EMOJI_SUPPORTED) } + // Store in cache + emojiCache[path] = emojis + Handler(Looper.getMainLooper()).post { setupEmojiAdapter(emojis) } - } } @@ -136,7 +155,7 @@ class EmojiKeyboard( position: Int, notifyListener: FrogoRecyclerNotifyListener ) { - mOnKeyboardActionListener!!.onText(data) + mOnKeyboardActionListener?.onText(data) } override fun onItemLongClicked( @@ -145,7 +164,7 @@ class EmojiKeyboard( position: Int, notifyListener: FrogoRecyclerNotifyListener ) { - mOnKeyboardActionListener!!.onText(data) + mOnKeyboardActionListener?.onText(data) } override fun areContentsTheSame(oldItem: String, newItem: String): Boolean { @@ -153,7 +172,7 @@ class EmojiKeyboard( } override fun areItemsTheSame(oldItem: String, newItem: String): Boolean { - return oldItem.hashCode() == newItem.hashCode() + return oldItem == newItem } override fun setViewBinding(parent: ViewGroup): ItemKeyboardEmojiBinding { diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt index a82c06b..7b3099f 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/ItemMainKeyboard.kt @@ -6,6 +6,7 @@ import android.content.res.Resources import android.content.res.TypedArray import android.content.res.XmlResourceParser import android.graphics.drawable.Drawable +import android.util.Log import android.util.TypedValue import android.util.Xml import android.view.inputmethod.EditorInfo @@ -42,7 +43,7 @@ class ItemMainKeyboard { var mMinWidth = 0 /** List of keys in this keyboard */ - var mKeys: MutableList? = null + val mKeys = mutableListOf() /** Width of the screen available to fit the keyboard */ private var mDisplayWidth = 0 @@ -51,9 +52,10 @@ class ItemMainKeyboard { private var mEnterKeyType = IME_ACTION_NONE /** Keyboard rows */ - private val mRows = ArrayList() + private val mRows = ArrayList() companion object { + private const val TAG = "ItemMainKeyboard" private const val TAG_KEYBOARD = "Keyboard" private const val TAG_ROW = "Row" private const val TAG_KEY = "Key" @@ -66,8 +68,24 @@ class ItemMainKeyboard { const val KEYCODE_DELETE = -5 const val KEYCODE_SPACE = 32 const val KEYCODE_EMOJI = -6 + const val KEYCODE_TAB = -7 + const val KEYCODE_ARROW_LEFT = -8 + const val KEYCODE_ARROW_RIGHT = -9 + const val KEYCODE_ARROW_UP = -10 + const val KEYCODE_ARROW_DOWN = -11 + + /** Whether haptic feedback is enabled on key press. Set via [MainKeyboard.vibrateOnKeypress]. */ + var VIBRATE_ON_KEYPRESS = true + + /** Whether key sound feedback is enabled on key press. Set via [MainKeyboard.soundOnKeypress]. */ + var SOUND_ON_KEYPRESS = true + + /** Selected mechanical switch sound profile id. Default is Cherry MX Blue */ + var MECHANICAL_SOUND_TYPE = "cherry_mx_blue" + + /** Sound volume multiplier (0.05f to 1.0f) */ + var SOUND_VOLUME = 0.8f - const val VIBRATE_ON_KEYPRESS = false const val SHOW_POPUP_ON_KEYPRESS = true const val SHIFT_OFF = 0 @@ -85,10 +103,7 @@ class ItemMainKeyboard { val value = a.peekValue(index) ?: return defValue return when (value.type) { TypedValue.TYPE_DIMENSION -> a.getDimensionPixelOffset(index, defValue) - TypedValue.TYPE_FRACTION -> Math.round(a.getFraction(index, - base, - base, - defValue.toFloat())) + TypedValue.TYPE_FRACTION -> a.getFraction(index, base, base, defValue.toFloat()).roundToInt() else -> defValue } } @@ -233,7 +248,9 @@ class ItemMainKeyboard { repeatable = a.getBoolean(R.styleable.FrogoKeyboard_Key_isRepeatable, false) edgeFlags = a.getInt(R.styleable.FrogoKeyboard_Key_keyEdgeFlags, 0) icon = a.getDrawable(R.styleable.FrogoKeyboard_Key_keyIcon) - icon?.setBounds(0, 0, icon!!.intrinsicWidth, icon!!.intrinsicHeight) + icon?.let { ic -> + ic.setBounds(0, 0, ic.intrinsicWidth, ic.intrinsicHeight) + } label = a.getText(R.styleable.FrogoKeyboard_Key_keyLabel) ?: "" topSmallNumber = a.getString(R.styleable.FrogoKeyboard_Key_topSmallNumber) ?: "" @@ -261,10 +278,9 @@ class ItemMainKeyboard { fun isInside(x: Int, y: Int): Boolean { val leftEdge = edgeFlags and EDGE_LEFT > 0 val rightEdge = edgeFlags and EDGE_RIGHT > 0 - return ((x >= this.x || leftEdge && x <= this.x + width) - && (x < this.x + width || rightEdge && x >= this.x) - && (y >= this.y && y <= this.y + height) - && (y < this.y + height && y >= this.y)) + val isInsideX = (x >= this.x || leftEdge) && (x < this.x + width || rightEdge) + val isInsideY = y >= this.y && y < this.y + height + return isInsideX && isInsideY } } @@ -280,7 +296,23 @@ class ItemMainKeyboard { mDefaultWidth = mDisplayWidth / 10 mDefaultHeight = mDefaultWidth mKeyboardHeightMultiplier = getKeyboardHeightMultiplier() - mKeys = ArrayList() + mEnterKeyType = enterKeyType + loadKeyboard(context, context.resources.getXml(xmlLayoutResId)) + } + + /** + * Creates a keyboard from the given xml key layout file with a custom height multiplier. + * @param context the application or service context + * @param xmlLayoutResId the resource file that contains the keyboard layout and keys. + * @param enterKeyType determines what icon should we show on Enter key + * @param heightMultiplier multiplier for keyboard height (1.0 = default, 1.2 = 20% taller) + */ + constructor(context: Context, @XmlRes xmlLayoutResId: Int, enterKeyType: Int, heightMultiplier: Float) { + mDisplayWidth = context.resources.displayMetrics.widthPixels + mDefaultHorizontalGap = 0 + mDefaultWidth = mDisplayWidth / 10 + mDefaultHeight = mDefaultWidth + mKeyboardHeightMultiplier = heightMultiplier mEnterKeyType = enterKeyType loadKeyboard(context, context.resources.getXml(xmlLayoutResId)) } @@ -326,7 +358,7 @@ class ItemMainKeyboard { key.code = character.code column++ x += key.width + key.gap - mKeys!!.add(key) + mKeys.add(key) row.mKeys.add(key) if (x > mMinWidth) { mMinWidth = x @@ -383,7 +415,7 @@ class ItemMainKeyboard { TAG_KEY -> { inKey = true key = createKeyFromXml(res, currentRow!!, x, y, parser) - mKeys!!.add(key) + mKeys.add(key) if (key.code == KEYCODE_ENTER) { val enterResourceId = when (mEnterKeyType) { EditorInfo.IME_ACTION_SEARCH -> R.drawable.ic_keyboard_search @@ -415,6 +447,7 @@ class ItemMainKeyboard { } } } catch (e: Exception) { + Log.e(TAG, "Error loading keyboard XML", e) } mHeight = y } diff --git a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt index 7192413..f265cfc 100644 --- a/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt +++ b/frogo-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt @@ -11,12 +11,15 @@ import android.graphics.PorterDuff import android.graphics.Rect import android.graphics.Typeface import android.graphics.drawable.LayerDrawable +import android.media.AudioManager import android.os.Handler +import android.os.Looper import android.os.Message import android.util.AttributeSet import android.util.TypedValue import android.view.Gravity import android.view.HapticFeedbackConstants +import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent import android.view.View @@ -29,12 +32,19 @@ import androidx.core.content.res.ResourcesCompat import com.frogobox.libkeyboard.R import com.frogobox.libkeyboard.common.ext.adjustAlpha import com.frogobox.libkeyboard.common.ext.applyColorFilter +import com.frogobox.libkeyboard.common.sound.MechanicalSoundManager +import com.frogobox.libkeyboard.common.sound.MechanicalSoundType +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_ARROW_DOWN +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_ARROW_LEFT +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_ARROW_RIGHT +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_ARROW_UP import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_DELETE import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_EMOJI import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_ENTER import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_MODE_CHANGE import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_SHIFT import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_SPACE +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.KEYCODE_TAB import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.MAX_KEYS_PER_MINI_ROW import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.SHIFT_OFF import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard.Companion.SHIFT_ON_ONE_CHAR @@ -116,6 +126,22 @@ class MainKeyboard @JvmOverloads constructor( private val mSpaceMoveThreshold: Int private var ignoreTouches = false + /** Reusable Paint for top small numbers — avoids per-frame allocation */ + private val mSmallLetterPaint = Paint() + + /** Audio manager for key click sound feedback */ + private val mAudioManager: AudioManager? + + /** Whether haptic feedback is enabled — can be toggled at runtime */ + var vibrateOnKeypress: Boolean + get() = ItemMainKeyboard.VIBRATE_ON_KEYPRESS + set(value) { ItemMainKeyboard.VIBRATE_ON_KEYPRESS = value } + + /** Whether key click sound is enabled — can be toggled at runtime */ + var soundOnKeypress: Boolean + get() = ItemMainKeyboard.SOUND_ON_KEYPRESS + set(value) { ItemMainKeyboard.SOUND_ON_KEYPRESS = value } + // For multi-tap private var mLastTapTime = 0L @@ -205,6 +231,7 @@ class MainKeyboard @JvmOverloads constructor( mMiniKeyboardCache = HashMap() mAccessibilityManager = (context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager) + mAudioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager mPopupMaxMoveDistance = resources.getDimension(R.dimen.popup_max_move_distance) mTopSmallNumberSize = resources.getDimension(com.frogobox.ui.R.dimen.frogo_dimen_font_10sp) @@ -212,14 +239,13 @@ class MainKeyboard @JvmOverloads constructor( mTopSmallNumberMarginHeight = resources.getDimension(R.dimen.top_small_number_margin_height) } - @SuppressLint("HandlerLeak") override fun onAttachedToWindow() { super.onAttachedToWindow() if (mHandler == null) { - mHandler = object : Handler() { + mHandler = object : Handler(Looper.getMainLooper()) { override fun handleMessage(msg: Message) { when (msg.what) { - MSG_REMOVE_PREVIEW -> mPreviewText!!.visibility = INVISIBLE + MSG_REMOVE_PREVIEW -> mPreviewText?.visibility = INVISIBLE MSG_REPEAT -> if (repeatKey(false)) { val repeat = Message.obtain(this, MSG_REPEAT) sendMessageDelayed(repeat, REPEAT_INTERVAL.toLong()) @@ -267,8 +293,7 @@ class MainKeyboard @JvmOverloads constructor( } removeMessages() mKeyboard = keyboard - val keys = mKeyboard!!.mKeys - mKeys = keys!!.toMutableList() as ArrayList + mKeys = ArrayList(keyboard.mKeys) requestLayout() mKeyboardChanged = true invalidateAllKeys() @@ -282,10 +307,15 @@ class MainKeyboard @JvmOverloads constructor( fun vibrateIfNeeded() { if (ItemMainKeyboard.VIBRATE_ON_KEYPRESS) { - performHapticFeedback( - HapticFeedbackConstants.VIRTUAL_KEY, - HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING - ) + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + } + } + + /** Plays mechanical keyboard sound if sound feedback is enabled. */ + fun playSoundIfNeeded() { + if (ItemMainKeyboard.SOUND_ON_KEYPRESS) { + val soundType = MechanicalSoundType.fromId(ItemMainKeyboard.MECHANICAL_SOUND_TYPE) + MechanicalSoundManager.getInstance(context).playKeySound(soundType, ItemMainKeyboard.SOUND_VOLUME) } } @@ -317,8 +347,9 @@ class MainKeyboard @JvmOverloads constructor( } private fun adjustCase(label: CharSequence): CharSequence? { + val kb = mKeyboard ?: return label var newLabel: CharSequence? = label - if (!newLabel.isNullOrEmpty() && mKeyboard!!.mShiftState > SHIFT_OFF && newLabel.length < 3 && Character.isLowerCase( + if (!newLabel.isNullOrEmpty() && kb.mShiftState > SHIFT_OFF && newLabel.length < 3 && Character.isLowerCase( newLabel[0] ) ) { @@ -328,14 +359,15 @@ class MainKeyboard @JvmOverloads constructor( } public override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - if (mKeyboard == null) { + val kb = mKeyboard + if (kb == null) { setMeasuredDimension(0, 0) } else { - var width = mKeyboard!!.mMinWidth + var width = kb.mMinWidth if (MeasureSpec.getSize(widthMeasureSpec) < width + 10) { width = MeasureSpec.getSize(widthMeasureSpec) } - setMeasuredDimension(width, mKeyboard!!.mHeight) + setMeasuredDimension(width, kb.mHeight) } } @@ -397,12 +429,14 @@ class MainKeyboard @JvmOverloads constructor( val paint = mPaint val keys = mKeys paint.color = mTextColor - val smallLetterPaint = Paint().apply { + // Reuse class-level paint instead of allocating each frame + mSmallLetterPaint.apply { set(paint) color = paint.color.adjustAlpha(0.8f) textSize = mTopSmallNumberSize typeface = Typeface.DEFAULT } + val smallLetterPaint = mSmallLetterPaint canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) @@ -416,7 +450,12 @@ class MainKeyboard @JvmOverloads constructor( KEYCODE_SHIFT, KEYCODE_DELETE, KEYCODE_ENTER, - KEYCODE_MODE_CHANGE -> { + KEYCODE_MODE_CHANGE, + KEYCODE_TAB, + KEYCODE_ARROW_LEFT, + KEYCODE_ARROW_RIGHT, + KEYCODE_ARROW_UP, + KEYCODE_ARROW_DOWN -> { keyBackground = resources.getDrawable(R.drawable.keypad_action, context.theme) } @@ -468,8 +507,9 @@ class MainKeyboard @JvmOverloads constructor( // Turn off drop shadow paint.setShadowLayer(0f, 0f, 0f, 0) } else if (key.icon != null && mKeyboard != null) { + val kb = mKeyboard!! if (code == KEYCODE_SHIFT) { - val drawableId = when (mKeyboard!!.mShiftState) { + val drawableId = when (kb.mShiftState) { SHIFT_OFF -> R.drawable.ic_keyboard_caps_outline SHIFT_ON_ONE_CHAR -> R.drawable.ic_keyboard_caps else -> R.drawable.ic_keyboard_caps_underlined @@ -477,16 +517,20 @@ class MainKeyboard @JvmOverloads constructor( key.icon = ResourcesCompat.getDrawable(resources, drawableId, null) } - if (code == KEYCODE_DELETE || code == KEYCODE_SHIFT || code == KEYCODE_EMOJI || code == KEYCODE_ENTER) { - key.icon!!.applyColorFilter(mTextColor) - } + key.icon?.let { icon -> + if (code == KEYCODE_DELETE || code == KEYCODE_SHIFT || code == KEYCODE_EMOJI || code == KEYCODE_ENTER || + 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 - key.icon!!.intrinsicWidth) / 2 - val drawableY = (key.height - key.icon!!.intrinsicHeight) / 2 - canvas.translate(drawableX.toFloat(), drawableY.toFloat()) - key.icon!!.setBounds(0, 0, key.icon!!.intrinsicWidth, key.icon!!.intrinsicHeight) - key.icon!!.draw(canvas) - canvas.translate(-drawableX.toFloat(), -drawableY.toFloat()) + val drawableX = (key.width - icon.intrinsicWidth) / 2 + val drawableY = (key.height - icon.intrinsicHeight) / 2 + canvas.translate(drawableX.toFloat(), drawableY.toFloat()) + icon.setBounds(0, 0, icon.intrinsicWidth, icon.intrinsicHeight) + icon.draw(canvas) + canvas.translate(-drawableX.toFloat(), -drawableY.toFloat()) + } } canvas.translate(-key.x.toFloat(), -key.y.toFloat()) } @@ -512,7 +556,9 @@ class MainKeyboard @JvmOverloads constructor( if (index != NOT_A_KEY && index < mKeys.size) { val key = mKeys[index] getPressedKeyIndex(x, y) - mOnKeyboardActionListener!!.onKey(key.code) + vibrateIfNeeded() + playSoundIfNeeded() + mOnKeyboardActionListener?.onKey(key.code) mLastTapTime = eventTime } } @@ -541,16 +587,17 @@ class MainKeyboard @JvmOverloads constructor( if (mCurrentKeyIndex != NOT_A_KEY && keys.size > mCurrentKeyIndex) { val newKey = keys[mCurrentKeyIndex] + val keyCode = newKey.code - val code = newKey.code - if (code == KEYCODE_SHIFT || code == KEYCODE_MODE_CHANGE || code == KEYCODE_DELETE || code == KEYCODE_ENTER || code == KEYCODE_SPACE) { + if (keyCode == KEYCODE_SHIFT || keyCode == KEYCODE_MODE_CHANGE || keyCode == KEYCODE_DELETE || keyCode == KEYCODE_ENTER || keyCode == KEYCODE_SPACE || + keyCode == KEYCODE_TAB || keyCode == KEYCODE_ARROW_LEFT || keyCode == KEYCODE_ARROW_RIGHT || keyCode == KEYCODE_ARROW_UP || keyCode == KEYCODE_ARROW_DOWN) { newKey.pressed = true } invalidateKey(mCurrentKeyIndex) sendAccessibilityEventForUnicodeCharacter( AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, - code + keyCode ) } } @@ -664,13 +711,20 @@ class MainKeyboard @JvmOverloads constructor( private fun sendAccessibilityEventForUnicodeCharacter(eventType: Int, code: Int) { if (mAccessibilityManager.isEnabled) { - val event = AccessibilityEvent.obtain(eventType) + val event = AccessibilityEvent().apply { + this.eventType = eventType + } onInitializeAccessibilityEvent(event) val text: String = when (code) { KEYCODE_DELETE -> context.getString(R.string.keycode_delete) KEYCODE_ENTER -> context.getString(R.string.keycode_enter) KEYCODE_MODE_CHANGE -> context.getString(R.string.keycode_mode_change) KEYCODE_SHIFT -> context.getString(R.string.keycode_shift) + KEYCODE_TAB -> "Tab" + KEYCODE_ARROW_LEFT -> "Left Arrow" + KEYCODE_ARROW_RIGHT -> "Right Arrow" + KEYCODE_ARROW_UP -> "Up Arrow" + KEYCODE_ARROW_DOWN -> "Down Arrow" else -> code.toChar().toString() } event.text.add(text) @@ -704,10 +758,7 @@ class MainKeyboard @JvmOverloads constructor( key.x + key.width, key.y + key.height ) onBufferDraw() - invalidate( - key.x, key.y, - key.x + key.width, key.y + key.height - ) + invalidate() } private fun openPopupIfRequired(me: MotionEvent): Boolean { @@ -756,28 +807,28 @@ class MainKeyboard @JvmOverloads constructor( mMiniKeyboard!!.mOnKeyboardActionListener = object : OnKeyboardActionListener { override fun onKey(code: Int) { - mOnKeyboardActionListener!!.onKey(code) + mOnKeyboardActionListener?.onKey(code) dismissPopupKeyboard() } override fun onPress(primaryCode: Int) { - mOnKeyboardActionListener!!.onPress(primaryCode) + mOnKeyboardActionListener?.onPress(primaryCode) } override fun onActionUp() { - mOnKeyboardActionListener!!.onActionUp() + mOnKeyboardActionListener?.onActionUp() } override fun moveCursorLeft() { - mOnKeyboardActionListener!!.moveCursorLeft() + mOnKeyboardActionListener?.moveCursorLeft() } override fun moveCursorRight() { - mOnKeyboardActionListener!!.moveCursorRight() + mOnKeyboardActionListener?.moveCursorRight() } override fun onText(text: String) { - mOnKeyboardActionListener!!.onText(text) + mOnKeyboardActionListener?.onText(text) } } @@ -918,7 +969,7 @@ class MainKeyboard @JvmOverloads constructor( MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { mMiniKeyboard?.mKeys?.firstOrNull { it.focused }?.apply { - mOnKeyboardActionListener!!.onKey(code) + mOnKeyboardActionListener?.onKey(code) } mMiniKeyboardSelectedKeyIndex = -1 dismissPopupKeyboard() @@ -967,7 +1018,7 @@ class MainKeyboard @JvmOverloads constructor( val secondKeyCode = mKeys.getOrNull(secondKeyIndex)?.code if (secondKeyCode != null) { - mOnKeyboardActionListener!!.onPress(secondKeyCode) + mOnKeyboardActionListener?.onPress(secondKeyCode) } showPreview(NOT_A_KEY) @@ -992,7 +1043,7 @@ class MainKeyboard @JvmOverloads constructor( 0 } - mOnKeyboardActionListener!!.onPress(onPressKey) + mOnKeyboardActionListener?.onPress(onPressKey) var wasHandled = false if (mCurrentKey >= 0 && mKeys[mCurrentKey].repeatable) { @@ -1100,15 +1151,14 @@ class MainKeyboard @JvmOverloads constructor( // If we're not on a repeating key (which sends on a DOWN event) if (mRepeatKeyIndex == NOT_A_KEY && !mMiniKeyboardOnScreen && !mAbortKey) { detectAndSendKey(mCurrentKey, touchX, touchY, eventTime) - } - - if (mKeys.getOrNull(mCurrentKey)?.code == KEYCODE_SPACE && !mIsLongPressingSpace) { + } else if (mRepeatKeyIndex != NOT_A_KEY && mKeys.getOrNull(mCurrentKey)?.code == KEYCODE_SPACE && !mIsLongPressingSpace) { + // Space key was in repeat mode but user released before long-press activated detectAndSendKey(mCurrentKey, touchX, touchY, eventTime) } invalidateKey(keyIndex) mRepeatKeyIndex = NOT_A_KEY - mOnKeyboardActionListener!!.onActionUp() + mOnKeyboardActionListener?.onActionUp() mIsLongPressingSpace = false } diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_down.xml b/frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_down.xml new file mode 100644 index 0000000..50613c2 --- /dev/null +++ b/frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_down.xml @@ -0,0 +1,10 @@ + + + diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_up.xml b/frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_up.xml new file mode 100644 index 0000000..bd1add9 --- /dev/null +++ b/frogo-keyboard/src/main/res/drawable/ic_keyboard_arrow_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/frogo-keyboard/src/main/res/drawable/ic_keyboard_tab.xml b/frogo-keyboard/src/main/res/drawable/ic_keyboard_tab.xml new file mode 100644 index 0000000..a79155f --- /dev/null +++ b/frogo-keyboard/src/main/res/drawable/ic_keyboard_tab.xml @@ -0,0 +1,10 @@ + + + diff --git a/frogo-keyboard/src/main/res/raw/mechanical_cherry_blue.wav b/frogo-keyboard/src/main/res/raw/mechanical_cherry_blue.wav new file mode 100644 index 0000000..11354cf Binary files /dev/null and b/frogo-keyboard/src/main/res/raw/mechanical_cherry_blue.wav differ diff --git a/frogo-keyboard/src/main/res/raw/mechanical_cherry_brown.wav b/frogo-keyboard/src/main/res/raw/mechanical_cherry_brown.wav new file mode 100644 index 0000000..2f8a41d Binary files /dev/null and b/frogo-keyboard/src/main/res/raw/mechanical_cherry_brown.wav differ diff --git a/frogo-keyboard/src/main/res/raw/mechanical_cherry_red.wav b/frogo-keyboard/src/main/res/raw/mechanical_cherry_red.wav new file mode 100644 index 0000000..ba5b601 Binary files /dev/null and b/frogo-keyboard/src/main/res/raw/mechanical_cherry_red.wav differ diff --git a/frogo-keyboard/src/main/res/raw/mechanical_typewriter.wav b/frogo-keyboard/src/main/res/raw/mechanical_typewriter.wav new file mode 100644 index 0000000..c6a3d63 Binary files /dev/null and b/frogo-keyboard/src/main/res/raw/mechanical_typewriter.wav differ diff --git a/frogo-keyboard/src/main/res/xml/keys_letters_programmer.xml b/frogo-keyboard/src/main/res/xml/keys_letters_programmer.xml new file mode 100644 index 0000000..e7d4024 --- /dev/null +++ b/frogo-keyboard/src/main/res/xml/keys_letters_programmer.xml @@ -0,0 +1,253 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle.properties b/gradle.properties index b5b0a35..5655886 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,19 +6,23 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx4608m -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -org.gradle.daemon=false +org.gradle.jvmargs=-Xmx1280m -XX:+UseParallelGC -XX:MaxMetaspaceSize=320m -Dfile.encoding=UTF-8 +# Gradle Performance Flags +org.gradle.daemon=true org.gradle.parallel=false +org.gradle.workers.max=1 +org.gradle.caching=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true +# Automatically convert third-party libraries to use AndroidX (disabled because all libs are modern AndroidX) +android.enableJetifier=false # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -android.nonTransitiveRClass=false -android.nonFinalResIds=false +android.nonTransitiveRClass=true +ksp.incremental=false +kotlin.incremental=false + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 224b4be..1369c39 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,24 +1,41 @@ [versions] -agp = "8.13.0" -kotlin = "2.2.21" -ksp = "2.2.21-2.0.4" -liveCycle = "2.9.4" +agp = "9.4.1" +kotlin = "2.4.20" +ksp = "2.3.10" +liveCycle = "2.11.0" -roomKtx = "2.8.3" -work = "2.11.0" -glideCompiler = "5.0.5" +roomKtx = "2.8.5" +work = "2.11.2" +glideCompiler = "5.0.9" -hilt = "2.57.2" +hilt = "2.60.1" emoji2 = "1.6.0" -frogoAndroid = "2.3.6" -frogoconsumeapi = "2.6.1" +frogoAndroid = "3.0.10" +frogoconsumeapi = "3.0.0" + +composeBom = "2026.09.00" +activityCompose = "1.13.0" +junit = "4.13.2" [libraries] +junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } androidx-lifecycle-compiler = { group = "androidx.lifecycle", name = "lifecycle-compiler", version.ref = "liveCycle" } androidx-emoji2 = { group = "androidx.emoji2", name = "emoji2-bundled", version.ref = "emoji2"} +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "roomKtx" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "roomKtx" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomKtx" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "liveCycle" } + google-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } google-hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } @@ -31,7 +48,7 @@ frogo-consume-api = { group = "com.github.frogobox", name = "frogo-consume-api", [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp"} -jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin"} +jetbrains-kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp"} -hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 46fd8dc..f8db6e2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sun Nov 03 18:01:05 WIB 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists