diff --git a/app/schemas/com.frogobox.appkeyboard.data.local.db.AppDatabase/2.json b/app/schemas/com.frogobox.appkeyboard.data.local.db.AppDatabase/2.json new file mode 100644 index 0000000..30b2feb --- /dev/null +++ b/app/schemas/com.frogobox.appkeyboard.data.local.db.AppDatabase/2.json @@ -0,0 +1,177 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "d78bd34da282450408807fd0f4413a92", + "entities": [ + { + "tableName": "auto_text", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `label` TEXT NOT NULL, `date` TEXT NOT NULL, `updatedDate` TEXT NOT NULL, `updatedTime` TEXT NOT NULL, `body` TEXT NOT NULL, `isActive` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedDate", + "columnName": "updatedDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedTime", + "columnName": "updatedTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "product_remote", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteId` TEXT, `uploadTimestamp` TEXT, `productName` TEXT NOT NULL, `caption` TEXT NOT NULL, `originalFileName` TEXT, `fileSize` TEXT, `fileType` TEXT, `driveLink` TEXT, `driveFileId` TEXT, `thumbnailUrl` TEXT, `previewUrl` TEXT, `isVideo` INTEGER NOT NULL, `statusDownload` TEXT NOT NULL, `rowIndex` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadTimestamp", + "columnName": "uploadTimestamp", + "affinity": "TEXT" + }, + { + "fieldPath": "productName", + "columnName": "productName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caption", + "columnName": "caption", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalFileName", + "columnName": "originalFileName", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "TEXT" + }, + { + "fieldPath": "fileType", + "columnName": "fileType", + "affinity": "TEXT" + }, + { + "fieldPath": "driveLink", + "columnName": "driveLink", + "affinity": "TEXT" + }, + { + "fieldPath": "driveFileId", + "columnName": "driveFileId", + "affinity": "TEXT" + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "previewUrl", + "columnName": "previewUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "isVideo", + "columnName": "isVideo", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusDownload", + "columnName": "statusDownload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rowIndex", + "columnName": "rowIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd78bd34da282450408807fd0f4413a92')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.frogobox.appkeyboard.data.local.db.AppDatabase/3.json b/app/schemas/com.frogobox.appkeyboard.data.local.db.AppDatabase/3.json new file mode 100644 index 0000000..0220ef4 --- /dev/null +++ b/app/schemas/com.frogobox.appkeyboard.data.local.db.AppDatabase/3.json @@ -0,0 +1,188 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "21c90223ff4552fc0b3d720be76f8f35", + "entities": [ + { + "tableName": "auto_text", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `label` TEXT NOT NULL, `date` TEXT NOT NULL, `updatedDate` TEXT NOT NULL, `updatedTime` TEXT NOT NULL, `body` TEXT NOT NULL, `isActive` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedDate", + "columnName": "updatedDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedTime", + "columnName": "updatedTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "product_remote", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteId` TEXT, `uploadTimestamp` TEXT, `productName` TEXT NOT NULL, `caption` TEXT NOT NULL, `originalFileName` TEXT, `fileSize` TEXT, `fileType` TEXT, `driveLink` TEXT, `driveFileId` TEXT, `thumbnailUrl` TEXT, `previewUrl` TEXT, `isVideo` INTEGER NOT NULL, `statusDownload` TEXT NOT NULL, `rowIndex` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadTimestamp", + "columnName": "uploadTimestamp", + "affinity": "TEXT" + }, + { + "fieldPath": "productName", + "columnName": "productName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caption", + "columnName": "caption", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalFileName", + "columnName": "originalFileName", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "TEXT" + }, + { + "fieldPath": "fileType", + "columnName": "fileType", + "affinity": "TEXT" + }, + { + "fieldPath": "driveLink", + "columnName": "driveLink", + "affinity": "TEXT" + }, + { + "fieldPath": "driveFileId", + "columnName": "driveFileId", + "affinity": "TEXT" + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "previewUrl", + "columnName": "previewUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "isVideo", + "columnName": "isVideo", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusDownload", + "columnName": "statusDownload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rowIndex", + "columnName": "rowIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_product_remote_remoteId", + "unique": false, + "columnNames": [ + "remoteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_product_remote_remoteId` ON `${TABLE_NAME}` (`remoteId`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '21c90223ff4552fc0b3d720be76f8f35')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.frogobox.appkeyboard.data.local.db.AppDatabase/4.json b/app/schemas/com.frogobox.appkeyboard.data.local.db.AppDatabase/4.json new file mode 100644 index 0000000..3b83449 --- /dev/null +++ b/app/schemas/com.frogobox.appkeyboard.data.local.db.AppDatabase/4.json @@ -0,0 +1,241 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "9fb5eb31893b5cfe3a5efdd0dd4e349a", + "entities": [ + { + "tableName": "auto_text", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `label` TEXT NOT NULL, `date` TEXT NOT NULL, `updatedDate` TEXT NOT NULL, `updatedTime` TEXT NOT NULL, `body` TEXT NOT NULL, `isActive` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedDate", + "columnName": "updatedDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedTime", + "columnName": "updatedTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "product_remote", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteId` TEXT, `uploadTimestamp` TEXT, `productName` TEXT NOT NULL, `caption` TEXT NOT NULL, `originalFileName` TEXT, `fileSize` TEXT, `fileType` TEXT, `driveLink` TEXT, `driveFileId` TEXT, `thumbnailUrl` TEXT, `previewUrl` TEXT, `isVideo` INTEGER NOT NULL, `statusDownload` TEXT NOT NULL, `rowIndex` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadTimestamp", + "columnName": "uploadTimestamp", + "affinity": "TEXT" + }, + { + "fieldPath": "productName", + "columnName": "productName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caption", + "columnName": "caption", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalFileName", + "columnName": "originalFileName", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "TEXT" + }, + { + "fieldPath": "fileType", + "columnName": "fileType", + "affinity": "TEXT" + }, + { + "fieldPath": "driveLink", + "columnName": "driveLink", + "affinity": "TEXT" + }, + { + "fieldPath": "driveFileId", + "columnName": "driveFileId", + "affinity": "TEXT" + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "previewUrl", + "columnName": "previewUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "isVideo", + "columnName": "isVideo", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusDownload", + "columnName": "statusDownload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rowIndex", + "columnName": "rowIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_product_remote_remoteId", + "unique": false, + "columnNames": [ + "remoteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_product_remote_remoteId` ON `${TABLE_NAME}` (`remoteId`)" + } + ] + }, + { + "tableName": "template_text", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `category` TEXT NOT NULL, `text` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_template_text_category", + "unique": false, + "columnNames": [ + "category" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_template_text_category` ON `${TABLE_NAME}` (`category`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9fb5eb31893b5cfe3a5efdd0dd4e349a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.frogobox.appkeyboard.data.local.db.AppDatabase/5.json b/app/schemas/com.frogobox.appkeyboard.data.local.db.AppDatabase/5.json new file mode 100644 index 0000000..067dad8 --- /dev/null +++ b/app/schemas/com.frogobox.appkeyboard.data.local.db.AppDatabase/5.json @@ -0,0 +1,251 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "2ed9bce081789f9317699fd3f792dade", + "entities": [ + { + "tableName": "auto_text", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `label` TEXT NOT NULL, `date` TEXT NOT NULL, `updatedDate` TEXT NOT NULL, `updatedTime` TEXT NOT NULL, `body` TEXT NOT NULL, `isActive` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedDate", + "columnName": "updatedDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedTime", + "columnName": "updatedTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "product_remote", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteId` TEXT, `uploadTimestamp` TEXT, `productName` TEXT NOT NULL, `caption` TEXT NOT NULL, `hook` TEXT, `originalFileName` TEXT, `fileSize` TEXT, `fileType` TEXT, `driveLink` TEXT, `linkProduct` TEXT, `driveFileId` TEXT, `thumbnailUrl` TEXT, `previewUrl` TEXT, `isVideo` INTEGER NOT NULL, `statusDownload` TEXT NOT NULL, `rowIndex` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadTimestamp", + "columnName": "uploadTimestamp", + "affinity": "TEXT" + }, + { + "fieldPath": "productName", + "columnName": "productName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caption", + "columnName": "caption", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hook", + "columnName": "hook", + "affinity": "TEXT" + }, + { + "fieldPath": "originalFileName", + "columnName": "originalFileName", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "TEXT" + }, + { + "fieldPath": "fileType", + "columnName": "fileType", + "affinity": "TEXT" + }, + { + "fieldPath": "driveLink", + "columnName": "driveLink", + "affinity": "TEXT" + }, + { + "fieldPath": "linkProduct", + "columnName": "linkProduct", + "affinity": "TEXT" + }, + { + "fieldPath": "driveFileId", + "columnName": "driveFileId", + "affinity": "TEXT" + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "previewUrl", + "columnName": "previewUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "isVideo", + "columnName": "isVideo", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusDownload", + "columnName": "statusDownload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rowIndex", + "columnName": "rowIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_product_remote_remoteId", + "unique": false, + "columnNames": [ + "remoteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_product_remote_remoteId` ON `${TABLE_NAME}` (`remoteId`)" + } + ] + }, + { + "tableName": "template_text", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `category` TEXT NOT NULL, `text` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_template_text_category", + "unique": false, + "columnNames": [ + "category" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_template_text_category` ON `${TABLE_NAME}` (`category`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2ed9bce081789f9317699fd3f792dade')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index abf26e6..18a8827 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,6 +24,10 @@ android:name=".ui.test.TestActivity" android:exported="false" /> + + @@ -36,6 +40,10 @@ android:name=".ui.autotext.AutoTextDetailActivity" android:exported="false" /> + + diff --git a/app/src/main/assets/text/dictionary_id.txt b/app/src/main/assets/text/dictionary_id.txt new file mode 100644 index 0000000..8deb8be --- /dev/null +++ b/app/src/main/assets/text/dictionary_id.txt @@ -0,0 +1,949 @@ +yang +dan +di +ini +itu +dengan +untuk +dari +tidak +ada +akan +bisa +sudah +saya +kamu +kita +mereka +dia +kami +tapi +karena +juga +hanya +semua +banyak +lagi +bila +jika +kalau +saat +waktu +hari +besok +kemarin +nanti +sekarang +selamat +pagi +siang +sore +malam +terima +kasih +sama +tolong +mohon +maaf +permisi +silakan +baik +bagus +siap +oke +iya +ya +halo +hai +pesanan +barang +paket +produk +ongkir +ongkos +kirim +resi +ekspedisi +kurir +alamat +tujuan +penerima +pengirim +nomor +telepon +rekening +transfer +bayar +pembayaran +lunas +harga +diskon +promo +stok +tersedia +habis +warna +ukuran +jumlah +total +invoice +struk +garansi +retur +komplain +packing +aman +bubble +wrap +cepat +sampai +toko +jual +beli +belanja +order +keranjang +etalase +katalog +chat +pesan +hubungi +balas +admin +penjual +pembeli +pelanggan +konsumen +agen +reseller +dropship +grosir +eceran +diskonan +murah +berkualitas +asli +original +baru +bekas +second +mulus +kondisi +berat +gram +kilo +kilogram +meter +centimeter +liter +paketnya +barangnya +pesanannya +dikirim +diproses +diterima +diantar +menunggu +konfirmasi +batal +selesai +sukses +gagal +kendala +rusak +cacat +pecah +hilang +ganti +tukar +kembali +pengembalian +dana +uang +rupiah +tunai +kredit +debit +kartu +saldo +tagihan +pajak +biaya +tambahan +gratis +potongan +voucher +kupon +hadiah +bonus +cashback +poin +ulasan +bintang +penilaian +ulas +tulis +baca +lihat +cek +periksa +buka +tutup +bisa +boleh +harus +wajib +jangan +belum +masih +sudah +pernah +selalu +sering +kadang +jarang +pasti +tentu +mungkin +seperti +sebagai +tentang +terhadap +antara +melalui +secara +dalam +luar +atas +bawah +depan +belakang +samping +kanan +kiri +tengah +sini +situ +sana +mana +siapa +apa +kapan +bagaimana +mengapa +kenapa +berapa +satu +dua +tiga +empat +lima +enam +tujuh +delapan +sembilan +sepuluh +sebelas +duabelas +puluh +ratus +ribu +juta +miliar +bukan +tanpa +kecuali +selain +sampai +hingga +sejak +mulai +tersebut +masing +setiap +tiap +beberapa +seluruh +sebagian +sedikit +kurang +lebih +paling +sangat +amat +terlalu +cukup +agak +makin +semakin +benar +betul +salah +tepat +cocok +pas +sesuai +sama +beda +berbeda +lain +lainnya +sendiri +bersama +langsung +segera +cepat +lambat +pelan +lama +sebentar +sekejap +mudah +gampang +sulit +susah +berat +ringan +besar +kecil +panjang +pendek +tinggi +rendah +jauh +dekat +luas +sempit +tebal +tipis +keras +lembut +halus +kasar +kuat +lemah +bersih +kotor +rapi +acak +indah +cantik +bagus +jelek +buruk +enak +lezat +manis +asin +pahit +pedas +panas +dingin +hangat +sejuk +terang +gelap +jelas +samar +nyata +palsu +nyaman +tenang +ramai +sepi +senang +bahagia +gembira +sedih +susah +kecewa +marah +kesal +jengkel +takut +cemas +khawatir +bangga +puas +suka +cinta +sayang +benci +rindu +kangen +ingin +mau +butuh +perlu +mampu +sanggup +sempat +ingat +lupa +tahu +kenal +paham +mengerti +yakin +percaya +harap +duga +kira +pikir +rasa +dengar +lihat +pandang +tatap +pegang +sentuh +ambil +tarik +dorong +bawa +bawaan +antar +kirimkan +sampaikan +terima +dapat +peroleh +cari +temukan +pilih +beli +jual +tawar +tukar +pinjam +sewa +pakai +gunakan +manfaatkan +buat +bikin +ciptakan +bangun +susun +tata +atur +kelola +urus +rawat +jaga +pelihara +lindungi +simpan +taruh +letakkan +buang +hapus +hilangkan +hilang +musnah +hancur +rusak +perbaiki +benahi +renovasi +ubah +ganti +tambah +kurang +kali +bagi +hitung +ukur +timbang +taksir +uji +coba +tes +periksa +teliti +amati +pantau +awasi +kawal +tuntun +pandu +bimbing +ajar +didik +latih +asah +bina +pimpin +arahkan +perintah +suruh +minta +ajak +undang +imbau +seru +larang +cegah +hindar +tolak +tangkal +hadapi +lawan +tanding +saing +bantu +tolong +dukung +dorong +bela +sokong +sumbang +beri +kasih +sedia +siapkan +lengkapi +penuhi +layani +jamu +sambut +terima +lepas +tinggal +pergi +berangkat +datang +tiba +sampai +masuk +keluar +naik +turun +maju +mundur +putar +belok +singgah +mampir +lewat +lintas +tembus +susup +jalan +lari +lompat +loncat +terbang +renang +selam +duduk +berdiri +bangkit +rebah +tidur +bina +bangun +sadar +siuman +hidup +mati +tumbuh +berkembang +biak +mekar +kuncup +gugur +layu +kering +basah +lembab +cair +beku +leleh +padat +gas +uap +asap +debu +tanah +air +api +udara +angin +hujan +badai +petir +kilat +mendung +awan +pelangi +matahari +bulan +bintang +langit +bumi +laut +samudera +sungai +danau +kolam +gunung +bukit +lembah +pantai +pesisir +hutan +rimba +taman +kebun +sawah +ladang +kebun +desa +kampung +kota +daerah +wilayah +negeri +negara +provinsi +kabupaten +kecamatan +kelurahan +jalan +gang +lorong +blok +komplek +perumahan +gedung +kantor +pabrik +gudang +pasar +supermarket +minimarket +warung +kedai +resto +restoran +kafe +hotel +penginapan +rumah +hunian +apartemen +kos +kamar +ruang +dapur +toilet +halaman +teras +pagar +pintu +jendela +atap +lantai +dinding +meja +kursi +lemari +rak +tempat +wadah +kotak +kardus +bungkus +plastik +kertas +kayu +besi +baja +kaca +kain +benang +kulit +karet +logam +emas +perak +tembaga +perunggu +batu +pasir +semen +cat +lem +baut +paku +kawat +tali +rantai +alat +perkakas +mesin +kendaraan +mobil +motor +sepeda +truk +bus +kereta +pesawat +kapal +perahu +helm +jaket +sepatu +sandal +kaos +kemeja +celana +rok +baju +pakaian +busana +aksesoris +tas +dompet +sabuk +topi +kacamata +jam +arloji +kalung +gelang +cincin +anting +perhiasan +kosmetik +skincare +parfum +sabun +sampo +odol +sikat +handuk +selimut +sprei +bantal +guling +kasur +kasur busa +alat dapur +piring +gelas +cangkir +sendok +garpu +pisau +wajan +panci +kompor +blender +oven +microwave +kulkas +dispenser +rice cooker +setrika +mesin cuci +kipas +ac +televisi +radio +speaker +headset +earphone +handphone +smartphone +tablet +laptop +komputer +monitor +keyboard +mouse +printer +scanner +kamera +cctv +lampu +kabel +colokan +saklar +baterai +charger +powerbank +pulsa +kuota +internet +wifi +sinyal +jaringan +akun +aplikasi +sistem +program +fitur +layanan +informasi +berita +artikel +konten +video +foto +gambar +musik +audio +file +dokumen +surat +berkas +data +arsip +catatan +laporan +daftar +tabel +grafik +bagan +skema +peta +buku +majalah +koran +modul +panduan +kamus +tulisan +ketikan +pesan singkat +notifikasi +pemberitahuan +pengumuman +undangan +peringatan +himbauan +saran +masukan +kritik +pertanyaan +jawaban +alasan +penjelasan +keterangan +rincian +detail +deskripsi +spesifikasi +aturan +syarat +ketentuan +kebijakan +tata cara +langkah +tahap +proses +jadwal +rencana +target +tujuan +hasil +capaian +prestasi +masalah +solusi +alternatif +keputusan +kesepakatan +perjanjian +kontrak +transaksi +bisnis +usaha +perusahaan +organisasi +komunitas +grup +tim +kelompok +individu +pribadi +sosial +publik +umum +khusus +rahasia +pribadi +resmi +formal +santai +akrab +hangat +ramah +sopan +santun +hormat +jujur +amanah +terpercaya +kredibel +profesional +kompeten +ahli +mahir +andal +unggul +terbaik +terdepan +juara +favorit +populer +terkenal +viral +hits +keren +mantap +kece +jos +ciamik +paten +oke banget +recommended +terlaris +bestseller +diskon besar +promo menarik +murah meriah +harga bersahabat +kualitas terjamin +packing rapi +pengiriman cepat +respon cepat +pelayanan ramah +seller terpercaya +pembeli puas +langganan setia +pesan sekarang +order sekarang +jangan sampai kehabisan +stok terbatas +bisa cod +gratis ongkir +siap kirim +hari ini juga +resi otomatis +pasti pas +pas banget +cocok sekali +terima kasih banyak +semoga berkah +sukses selalu +sehat selalu +senang berbisnis dengan anda +sampai jumpa kembali +salam hangat +hormat kami +frogo keyboard +seller keyboard 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 2b8e0c5..f011656 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 @@ -4,9 +4,15 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import com.frogobox.appkeyboard.BuildConfig import com.frogobox.appkeyboard.data.local.autotext.AutoTextDao +import com.frogobox.appkeyboard.data.local.productremote.ProductRemoteDao +import com.frogobox.appkeyboard.data.local.templatetext.TemplateTextDao import com.frogobox.appkeyboard.model.AutoTextEntity +import com.frogobox.appkeyboard.model.ProductEntity +import com.frogobox.appkeyboard.model.TemplateTextEntity /** * Created by Faisal Amir on 06/01/23 @@ -21,29 +27,114 @@ import com.frogobox.appkeyboard.model.AutoTextEntity @Database( entities = [ - AutoTextEntity::class - ], version = 1, + AutoTextEntity::class, + ProductEntity::class, + TemplateTextEntity::class + ], version = 5, exportSchema = true ) abstract class AppDatabase : RoomDatabase() { abstract fun autoTextDao(): AutoTextDao + abstract fun productRemoteDao(): ProductRemoteDao + abstract fun templateTextDao(): TemplateTextDao companion object { private const val DATABASE_NAME = BuildConfig.DATABASE_NAME + @Volatile + private var INSTANCE: AppDatabase? = null + + val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `product_remote` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `remoteId` TEXT, + `uploadTimestamp` TEXT, + `productName` TEXT NOT NULL, + `caption` TEXT NOT NULL, + `originalFileName` TEXT, + `fileSize` TEXT, + `fileType` TEXT, + `driveLink` TEXT, + `driveFileId` TEXT, + `thumbnailUrl` TEXT, + `previewUrl` TEXT, + `isVideo` INTEGER NOT NULL, + `statusDownload` TEXT NOT NULL, + `rowIndex` INTEGER, + `createdAt` INTEGER NOT NULL, + `updatedAt` INTEGER NOT NULL + ) + """.trimIndent() + ) + } + } + + val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE INDEX IF NOT EXISTS `index_product_remote_remoteId` ON `product_remote` (`remoteId`) + """.trimIndent() + ) + } + } + + val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `template_text` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `category` TEXT NOT NULL, + `text` TEXT NOT NULL, + `createdAt` INTEGER NOT NULL, + `updatedAt` INTEGER NOT NULL + ) + """.trimIndent() + ) + db.execSQL( + """ + CREATE INDEX IF NOT EXISTS `index_template_text_category` ON `template_text` (`category`) + """.trimIndent() + ) + } + } + + val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + ALTER TABLE `product_remote` ADD COLUMN `hook` TEXT + """.trimIndent() + ) + db.execSQL( + """ + ALTER TABLE `product_remote` ADD COLUMN `linkProduct` TEXT + """.trimIndent() + ) + } + } + fun newInstance(context: Context): AppDatabase { - return buildDatabase(context) + return INSTANCE ?: synchronized(this) { + INSTANCE ?: buildDatabase(context).also { INSTANCE = it } + } } private fun buildDatabase(context: Context): AppDatabase { return if (BuildConfig.DEBUG) { Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, DATABASE_NAME) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) .fallbackToDestructiveMigration(dropAllTables = true) // FOR DEVELOPMENT ONLY !!!! .build() } else { Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, DATABASE_NAME) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) .build() } } diff --git a/app/src/main/java/com/frogobox/appkeyboard/data/local/productremote/ProductRemoteDao.kt b/app/src/main/java/com/frogobox/appkeyboard/data/local/productremote/ProductRemoteDao.kt new file mode 100644 index 0000000..92d9df2 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/data/local/productremote/ProductRemoteDao.kt @@ -0,0 +1,70 @@ +package com.frogobox.appkeyboard.data.local.productremote + +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.ProductEntity +import kotlinx.coroutines.flow.Flow + +/** + * Room Data Access Object for local Product Remote table. + * Supports CRUD, batch synchronization, reactive flows, and search filtering. + */ +@Dao +interface ProductRemoteDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(product: ProductEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(products: List) + + @Update + suspend fun update(product: ProductEntity) + + @Delete + suspend fun delete(product: ProductEntity) + + @Query("DELETE FROM product_remote WHERE id = :id") + suspend fun deleteById(id: Int) + + @Query("DELETE FROM product_remote WHERE remoteId = :remoteId") + suspend fun deleteByRemoteId(remoteId: String) + + @Query("DELETE FROM product_remote WHERE id IN (:idList)") + suspend fun delete(idList: List) + + @Query("DELETE FROM product_remote") + suspend fun nukeData() + + @Query("SELECT * FROM product_remote ORDER BY id DESC") + fun getAll(): Flow> + + @Query("SELECT * FROM product_remote ORDER BY id DESC") + suspend fun getAllSync(): List + + @Query("SELECT * FROM product_remote WHERE id = :id") + fun getById(id: Int): Flow + + @Query("SELECT * FROM product_remote WHERE remoteId = :remoteId LIMIT 1") + suspend fun getByRemoteId(remoteId: String): ProductEntity? + + @Query("SELECT remoteId FROM product_remote WHERE remoteId IS NOT NULL") + fun getSavedRemoteIds(): Flow> + + @Query("SELECT * FROM product_remote WHERE statusDownload = :status ORDER BY id DESC") + fun getByStatusDownload(status: String): Flow> + + @Query("SELECT * FROM product_remote WHERE (productName LIKE '%' || :query || '%' OR caption LIKE '%' || :query || '%' OR hook LIKE '%' || :query || '%' OR originalFileName LIKE '%' || :query || '%') ORDER BY id DESC") + fun search(query: String): Flow> + + @Query("UPDATE product_remote SET statusDownload = :status, updatedAt = :updatedAt WHERE remoteId = :remoteId") + suspend fun updateDownloadStatusByRemoteId(remoteId: String, status: String, updatedAt: Long = System.currentTimeMillis()) + + @Query("UPDATE product_remote SET statusDownload = :status, updatedAt = :updatedAt WHERE id = :id") + suspend fun updateDownloadStatusById(id: Int, status: String, updatedAt: Long = System.currentTimeMillis()) + +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/data/local/templatetext/TemplateTextDao.kt b/app/src/main/java/com/frogobox/appkeyboard/data/local/templatetext/TemplateTextDao.kt new file mode 100644 index 0000000..8d23850 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/data/local/templatetext/TemplateTextDao.kt @@ -0,0 +1,56 @@ +package com.frogobox.appkeyboard.data.local.templatetext + +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.TemplateTextEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface TemplateTextDao { + + @Query("SELECT * FROM template_text ORDER BY id DESC") + fun getAllFlow(): Flow> + + @Query("SELECT * FROM template_text WHERE category = :category ORDER BY id DESC") + fun getByCategoryFlow(category: String): Flow> + + @Query("SELECT * FROM template_text WHERE category = :category ORDER BY id DESC") + suspend fun getByCategory(category: String): List + + @Query("SELECT * FROM template_text ORDER BY id DESC") + suspend fun getAll(): List + + @Query("SELECT * FROM template_text WHERE id = :id LIMIT 1") + suspend fun getById(id: Int): TemplateTextEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(entity: TemplateTextEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: List): List + + @Update + suspend fun update(entity: TemplateTextEntity): Int + + @Delete + suspend fun delete(entity: TemplateTextEntity): Int + + @Query("DELETE FROM template_text WHERE id = :id") + suspend fun deleteById(id: Int): Int + + @Query("DELETE FROM template_text WHERE category = :category") + suspend fun deleteByCategory(category: String): Int + + @Query("DELETE FROM template_text") + suspend fun deleteAll(): Int + + @Query("SELECT COUNT(*) FROM template_text WHERE category = :category") + suspend fun countByCategory(category: String): Int + + @Query("SELECT COUNT(*) FROM template_text") + suspend fun countAll(): Int +} 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 5f92e83..d16ce0b 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 @@ -50,8 +50,10 @@ object ApiService { .addInterceptor(chuckInterceptor) .build() + val normalizedBaseUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/" + return Retrofit.Builder() - .baseUrl(baseUrl) + .baseUrl(normalizedBaseUrl) .addConverterFactory(GsonConverterFactory.create()) .client(client) .build() diff --git a/app/src/main/java/com/frogobox/appkeyboard/data/remote/VideoDownloadService.kt b/app/src/main/java/com/frogobox/appkeyboard/data/remote/VideoDownloadService.kt new file mode 100644 index 0000000..072641e --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/data/remote/VideoDownloadService.kt @@ -0,0 +1,18 @@ +package com.frogobox.appkeyboard.data.remote + +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Streaming +import retrofit2.http.Url + +/** + * Retrofit service for streaming file downloads (videos, media) without buffering into memory. + */ +interface VideoDownloadService { + + @Streaming + @GET + suspend fun downloadFile(@Url fileUrl: String): Response + +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/data/remote/model/DataApiResponse.kt b/app/src/main/java/com/frogobox/appkeyboard/data/remote/model/DataApiResponse.kt index 6275768..26da607 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/data/remote/model/DataApiResponse.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/data/remote/model/DataApiResponse.kt @@ -4,26 +4,33 @@ import androidx.annotation.Keep import com.google.gson.annotations.SerializedName /** - * Root data model for remote API response from /api/data.json. + * Root data model for remote API response from data.json. + * Strictly adheres to Google Sheets feed schema. * Annotated with @Keep and @SerializedName for safe serialization/deserialization. */ @Keep data class DataApiResponse( - @SerializedName("code") - val code: Int? = null, + @SerializedName("success") + val success: Boolean? = null, - @SerializedName("status") - val status: String? = null, + @SerializedName("lastUpdated") + val lastUpdated: String? = null, - @SerializedName("message") - val message: String? = null, + @SerializedName("lastUpdatedWib") + val lastUpdatedWib: String? = null, @SerializedName("total") val total: Int? = null, - @SerializedName("lastUpdated") - val lastUpdated: String? = null, + @SerializedName("sheetId") + val sheetId: String? = null, + + @SerializedName("source") + val source: String? = null, - @SerializedName("data") - val data: List? = null -) + @SerializedName("items") + val items: List? = null +) { + val allItems: List + get() = items ?: emptyList() +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/data/remote/model/DataItemResponse.kt b/app/src/main/java/com/frogobox/appkeyboard/data/remote/model/DataItemResponse.kt index 3db6536..9e576e5 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/data/remote/model/DataItemResponse.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/data/remote/model/DataItemResponse.kt @@ -4,75 +4,116 @@ import androidx.annotation.Keep import com.google.gson.annotations.SerializedName /** - * Data model for individual items returned from remote API. + * Data model for individual items returned from remote API matching data.json. + * Strictly adheres to Google Sheets feed schema. * Annotated with @Keep and @SerializedName to ensure ProGuard obfuscation safety. */ @Keep data class DataItemResponse( @SerializedName("id") - val id: Int? = null, + val id: String? = null, - @SerializedName("title") - val title: String? = null, - - @SerializedName("body") - val body: String? = null, - - @SerializedName("description") - val description: String? = null, - - @SerializedName("category") - val category: String? = null, + @SerializedName("uploadTimestamp") + val uploadTimestamp: String? = null, - @SerializedName("createdAt") - val createdAt: String? = null, + @SerializedName("productName") + val productName: String? = null, - @SerializedName("isActive") - val isActive: Boolean? = null, + @SerializedName("caption") + val caption: String? = null, - @SerializedName("rowIndex") - val rowIndex: Int? = null, + @SerializedName("hook") + val hook: String? = null, - @SerializedName("isVideo") - val isVideo: Boolean? = null, + @SerializedName("originalFileName") + val originalFileName: String? = null, @SerializedName("fileSize") val fileSize: String? = null, - @SerializedName("caption") - val caption: String? = null, - - @SerializedName("uploaderName") - val uploaderName: String? = null, - - @SerializedName("uploadTimestamp") - val uploadTimestamp: String? = null, + @SerializedName("fileType") + val fileType: String? = null, @SerializedName("driveLink") val driveLink: String? = null, + @SerializedName("linkProduct") + val linkProduct: String? = null, + + @SerializedName("driveFileId") + val driveFileId: String? = null, + @SerializedName("thumbnailUrl") - val thumbnailUrl: String? = null + val thumbnailUrl: String? = null, + + @SerializedName("previewUrl") + val previewUrl: String? = null, + + @SerializedName("isVideo") + val isVideo: Boolean? = null, + + @SerializedName("statusDownload") + val statusDownload: String? = null, + + @SerializedName("rowIndex") + val rowIndex: Int? = null ) { + constructor( + id: Int, + uploadTimestamp: String? = null, + productName: String? = null, + caption: String? = null, + hook: String? = null, + originalFileName: String? = null, + fileSize: String? = null, + fileType: String? = null, + driveLink: String? = null, + linkProduct: String? = null, + driveFileId: String? = null, + thumbnailUrl: String? = null, + previewUrl: String? = null, + isVideo: Boolean? = null, + statusDownload: String? = null, + rowIndex: Int? = null + ) : this( + id = id.toString(), + uploadTimestamp = uploadTimestamp, + productName = productName, + caption = caption, + hook = hook, + originalFileName = originalFileName, + fileSize = fileSize, + fileType = fileType, + driveLink = driveLink, + linkProduct = linkProduct, + driveFileId = driveFileId, + thumbnailUrl = thumbnailUrl, + previewUrl = previewUrl, + isVideo = isVideo, + statusDownload = statusDownload, + rowIndex = rowIndex ?: id + ) + val displayIndex: Int - get() = rowIndex ?: id ?: 0 + get() = rowIndex ?: id?.filter { it.isDigit() }?.toIntOrNull() ?: (id?.hashCode() ?: 0).let { if (it < 0) -it else it } + + val displayHook: String? + get() = hook?.takeIf { it.isNotBlank() } + + val displayProductLink: String? + get() = linkProduct?.takeIf { it.isNotBlank() } val displayTitle: String - get() = caption?.takeIf { it.isNotBlank() } - ?: title?.takeIf { it.isNotBlank() } - ?: uploaderName?.takeIf { it.isNotBlank() } + get() = productName?.takeIf { it.isNotBlank() } + ?: caption?.takeIf { it.isNotBlank() } ?: "Item #$displayIndex" - val displaySubtitle: String? - get() = uploaderName?.takeIf { it.isNotBlank() } - ?: category?.takeIf { it.isNotBlank() } - val displayBody: String? - get() = body?.takeIf { it.isNotBlank() } - ?: description?.takeIf { it.isNotBlank() } - ?: caption?.takeIf { it.isNotBlank() } + get() = caption?.takeIf { it.isNotBlank() && it != displayTitle } val displayTimestamp: String? get() = uploadTimestamp?.takeIf { it.isNotBlank() } - ?: createdAt?.takeIf { it.isNotBlank() } + + val isDownloaded: Boolean + get() = statusDownload.equals("Sudah", ignoreCase = true) } diff --git a/app/src/main/java/com/frogobox/appkeyboard/di/DatabaseModule.kt b/app/src/main/java/com/frogobox/appkeyboard/di/DatabaseModule.kt index 117a0ef..6a13e09 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/di/DatabaseModule.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/di/DatabaseModule.kt @@ -3,6 +3,8 @@ package com.frogobox.appkeyboard.di import android.content.Context import com.frogobox.appkeyboard.data.local.autotext.AutoTextDao import com.frogobox.appkeyboard.data.local.db.AppDatabase +import com.frogobox.appkeyboard.data.local.productremote.ProductRemoteDao +import com.frogobox.appkeyboard.data.local.templatetext.TemplateTextDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -36,4 +38,14 @@ object DatabaseModule { return database.autoTextDao() } + @Provides + fun provideProductRemoteDao(database: AppDatabase): ProductRemoteDao { + return database.productRemoteDao() + } + + @Provides + fun provideTemplateTextDao(database: AppDatabase): TemplateTextDao { + return database.templateTextDao() + } + } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/di/NetworkModule.kt b/app/src/main/java/com/frogobox/appkeyboard/di/NetworkModule.kt index ecda194..4d8369d 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/di/NetworkModule.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/di/NetworkModule.kt @@ -18,7 +18,7 @@ import javax.inject.Singleton class NetworkModule { companion object { - const val BASE_URL = "http://192.168.100.6:3000/" + const val BASE_URL = "http://192.168.100.6:7272/" } @Provides diff --git a/app/src/main/java/com/frogobox/appkeyboard/di/RepositoryModule.kt b/app/src/main/java/com/frogobox/appkeyboard/di/RepositoryModule.kt index af90583..162c208 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/di/RepositoryModule.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/di/RepositoryModule.kt @@ -2,8 +2,14 @@ package com.frogobox.appkeyboard.di import com.frogobox.appkeyboard.repository.autotext.AutoTextRepository import com.frogobox.appkeyboard.repository.autotext.AutoTextRepositoryImpl +import com.frogobox.appkeyboard.repository.clipboard.ClipboardRepository +import com.frogobox.appkeyboard.repository.clipboard.ClipboardRepositoryImpl import com.frogobox.appkeyboard.repository.data.DataApiRepository import com.frogobox.appkeyboard.repository.data.DataApiRepositoryImpl +import com.frogobox.appkeyboard.repository.productremote.ProductRemoteRepository +import com.frogobox.appkeyboard.repository.productremote.ProductRemoteRepositoryImpl +import com.frogobox.appkeyboard.repository.templatetext.TemplateTextRepository +import com.frogobox.appkeyboard.repository.templatetext.TemplateTextRepositoryImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -13,11 +19,7 @@ import javax.inject.Singleton /** * Hilt module binding repository implementations to their interfaces. */ -@Module(includes = [ - NetworkModule::class, - ServiceModule::class, - DatabaseModule::class, - UtilModule::class]) +@Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { @@ -28,4 +30,16 @@ abstract class RepositoryModule { @Singleton abstract fun bindDataApiRepository(repository: DataApiRepositoryImpl): DataApiRepository + @Binds + @Singleton + abstract fun bindProductRemoteRepository(repository: ProductRemoteRepositoryImpl): ProductRemoteRepository + + @Binds + @Singleton + abstract fun bindClipboardRepository(repository: ClipboardRepositoryImpl): ClipboardRepository + + @Binds + @Singleton + abstract fun bindTemplateTextRepository(repository: TemplateTextRepositoryImpl): TemplateTextRepository + } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/model/ClipboardItem.kt b/app/src/main/java/com/frogobox/appkeyboard/model/ClipboardItem.kt new file mode 100644 index 0000000..87cacc7 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/model/ClipboardItem.kt @@ -0,0 +1,11 @@ +package com.frogobox.appkeyboard.model + +import androidx.annotation.Keep + +@Keep +data class ClipboardItem( + val id: String, + val text: String, + val timestamp: Long, + val isPinned: Boolean = false +) 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 0cba63f..b7e2ebd 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardFeatureType.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardFeatureType.kt @@ -15,11 +15,14 @@ enum class KeyboardFeatureType(val id: String, val text: String, val icon: Int) WEB("menu_web", "Search Website", R.drawable.ic_menu_website), FORM("menu_form", "Form", R.drawable.ic_menu_form), AUTO_TEXT("menu_auto_text", "Auto Text", R.drawable.ic_menu_auto_text), + PRODUCT_REMOTE("menu_product_remote", "Product Remote", R.drawable.ic_menu_ps_sale), TEMPLATE_TEXT_APP("menu_play_store_app", "App Review", R.drawable.ic_menu_ps_app), TEMPLATE_TEXT_GAME("menu_play_store_game", "Game Review", R.drawable.ic_menu_ps_game), 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), + CLIPBOARD("menu_clipboard", "Clipboard", R.drawable.ic_menu_clipboard), + TEXT_EDIT("menu_text_edit", "Text Editing", R.drawable.ic_menu_text_edit), 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/KeyboardThemeModel.kt b/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardThemeModel.kt index d6f0f9f..b243c3e 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardThemeModel.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardThemeModel.kt @@ -15,4 +15,5 @@ data class KeyboardThemeModel( val description: String, val themType: ThemeType, val background: Int, + val isDark: Boolean = false ) \ No newline at end of file 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 8143679..8f79ab4 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardThemeType.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/model/KeyboardThemeType.kt @@ -1,5 +1,8 @@ package com.frogobox.appkeyboard.model +import android.content.Context +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils import com.frogobox.appkeyboard.R /** @@ -16,29 +19,31 @@ enum class ThemeType { } enum class KeyboardThemeType( - private val label: String, - private val desc: String, - private val themeType: ThemeType, - private val background: Int, + val label: String, + val desc: String, + val themeType: ThemeType, + val background: Int, + val isDark: Boolean = false, ) { - 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); + DEFAULT("Default", "Classic Adaptive", ThemeType.COLOR, R.color.color_bg_keyboard_default, isDark = false), + PURPLE("Frogo Purple", "Signature Brand", ThemeType.COLOR, R.color.color_bg_keyboard_purple, isDark = true), + DARK("Midnight AMOLED", "Deep OLED Black", ThemeType.COLOR, R.color.color_bg_keyboard_dark, isDark = true), + BLUE("Ocean Blue", "Calm & Focused", ThemeType.COLOR, R.color.color_bg_keyboard_blue, isDark = true), + GREEN("Forest Emerald", "Natural Harmony", ThemeType.COLOR, R.color.color_bg_keyboard_green, isDark = true), + RED("Crimson Sunset", "Vibrant Warmth", ThemeType.COLOR, R.color.color_bg_keyboard_red, isDark = true), + ORANGE("Sunset Orange", "Energetic Twilight", ThemeType.COLOR, R.color.color_bg_keyboard_orange, isDark = true), + CYAN("Nordic Cyan", "Fresh & Clean", ThemeType.COLOR, R.color.color_bg_keyboard_cyan, isDark = true), + PINK("Sakura Pink", "Aesthetic Pastel", ThemeType.COLOR, R.color.color_bg_keyboard_pink, isDark = true), + YELLOW("Amber Gold", "Golden Accent", ThemeType.COLOR, R.color.color_bg_keyboard_yellow, isDark = false), + IMAGE_BG_DARK("Wallpaper", "Sample Artwork", ThemeType.IMAGE, R.drawable.ic_wallpaper_dummy, isDark = true); fun mapToModel(): KeyboardThemeModel { return KeyboardThemeModel( this.label, this.desc, this.themeType, - this.background + this.background, + this.isDark ) } @@ -47,4 +52,17 @@ enum class KeyboardThemeType( entries.firstOrNull { it.name == value } ?: DEFAULT } +} + +/** + * Robust WCAG AA helper evaluating relative luminance to guarantee contrast ratio >= 4.5:1. + */ +fun isThemeDark(context: Context, theme: KeyboardThemeModel): Boolean { + if (theme.themType == ThemeType.IMAGE) return true + return try { + val colorInt = ContextCompat.getColor(context, theme.background) + ColorUtils.calculateLuminance(colorInt) < 0.45 + } catch (_: Exception) { + theme.isDark + } } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/model/ProductEntity.kt b/app/src/main/java/com/frogobox/appkeyboard/model/ProductEntity.kt new file mode 100644 index 0000000..7512689 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/model/ProductEntity.kt @@ -0,0 +1,134 @@ +package com.frogobox.appkeyboard.model + +import android.os.Parcelable +import androidx.annotation.Keep +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import com.frogobox.appkeyboard.data.remote.model.DataItemResponse +import kotlinx.parcelize.Parcelize + +/** + * Room Database entity representing Product Remote items cached locally for offline keyboard use. + * Complies with Room schema export, Parcelize, and ProGuard obfuscation safety. + */ +@Keep +@Entity( + tableName = "product_remote", + indices = [ + Index(value = ["remoteId"]) + ] +) +@Parcelize +data class ProductEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + var id: Int = 0, + + @ColumnInfo(name = "remoteId") + var remoteId: String? = null, + + @ColumnInfo(name = "uploadTimestamp") + var uploadTimestamp: String? = null, + + @ColumnInfo(name = "productName") + var productName: String = "", + + @ColumnInfo(name = "caption") + var caption: String = "", + + @ColumnInfo(name = "hook") + var hook: String? = null, + + @ColumnInfo(name = "originalFileName") + var originalFileName: String? = null, + + @ColumnInfo(name = "fileSize") + var fileSize: String? = null, + + @ColumnInfo(name = "fileType") + var fileType: String? = null, + + @ColumnInfo(name = "driveLink") + var driveLink: String? = null, + + @ColumnInfo(name = "linkProduct") + var linkProduct: String? = null, + + @ColumnInfo(name = "driveFileId") + var driveFileId: String? = null, + + @ColumnInfo(name = "thumbnailUrl") + var thumbnailUrl: String? = null, + + @ColumnInfo(name = "previewUrl") + var previewUrl: String? = null, + + @ColumnInfo(name = "isVideo") + var isVideo: Boolean = false, + + @ColumnInfo(name = "statusDownload") + var statusDownload: String = "Belum", + + @ColumnInfo(name = "rowIndex") + var rowIndex: Int? = null, + + @ColumnInfo(name = "createdAt") + var createdAt: Long = System.currentTimeMillis(), + + @ColumnInfo(name = "updatedAt") + var updatedAt: Long = System.currentTimeMillis() +) : Parcelable { + + val isDownloaded: Boolean + get() = statusDownload.equals("Sudah", ignoreCase = true) + + fun toDataItemResponse(): DataItemResponse { + return DataItemResponse( + id = remoteId ?: id.toString(), + uploadTimestamp = uploadTimestamp, + productName = productName, + caption = caption, + hook = hook, + originalFileName = originalFileName, + fileSize = fileSize, + fileType = fileType, + driveLink = driveLink, + linkProduct = linkProduct, + driveFileId = driveFileId, + thumbnailUrl = thumbnailUrl, + previewUrl = previewUrl, + isVideo = isVideo, + statusDownload = statusDownload, + rowIndex = rowIndex ?: id + ) + } + + companion object { + fun fromDataItemResponse(item: DataItemResponse, localId: Int = 0): ProductEntity { + return ProductEntity( + id = localId, + remoteId = item.id, + uploadTimestamp = item.uploadTimestamp, + productName = item.productName ?: item.displayTitle, + caption = item.caption ?: "", + hook = item.hook, + originalFileName = item.originalFileName, + fileSize = item.fileSize, + fileType = item.fileType, + driveLink = item.driveLink, + linkProduct = item.linkProduct, + driveFileId = item.driveFileId, + thumbnailUrl = item.thumbnailUrl, + previewUrl = item.previewUrl, + isVideo = item.isVideo ?: false, + statusDownload = item.statusDownload ?: "Belum", + rowIndex = item.rowIndex + ) + } + } +} + +fun DataItemResponse.toProductEntity(localId: Int = 0): ProductEntity = + ProductEntity.fromDataItemResponse(this, localId) diff --git a/app/src/main/java/com/frogobox/appkeyboard/model/TemplateCategoryType.kt b/app/src/main/java/com/frogobox/appkeyboard/model/TemplateCategoryType.kt new file mode 100644 index 0000000..d15605b --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/model/TemplateCategoryType.kt @@ -0,0 +1,36 @@ +package com.frogobox.appkeyboard.model + +import androidx.annotation.Keep + +/** + * Standardized category definition for keyboard template texts. + */ +@Keep +enum class TemplateCategoryType( + val key: String, + val title: String, + val icon: String, + val featureType: KeyboardFeatureType +) { + GAME("GAME", "Game", "๐ŸŽฎ", KeyboardFeatureType.TEMPLATE_TEXT_GAME), + APP("APP", "App", "๐Ÿ“ฑ", KeyboardFeatureType.TEMPLATE_TEXT_APP), + SALE("SALE", "Sale", "๐Ÿ’ฐ", KeyboardFeatureType.TEMPLATE_TEXT_SALE), + GREETING("GREETING", "Greeting", "๐Ÿ‘‹", KeyboardFeatureType.TEMPLATE_TEXT_GREETING), + LOVE("LOVE", "Love", "โค๏ธ", KeyboardFeatureType.TEMPLATE_TEXT_LOVE); + + companion object { + fun fromKey(key: String?): TemplateCategoryType { + if (key.isNullOrBlank()) return GAME + return entries.firstOrNull { + it.key.equals(key, ignoreCase = true) || + it.name.equals(key, ignoreCase = true) || + it.featureType.name.equals(key, ignoreCase = true) || + it.title.equals(key, ignoreCase = true) + } ?: GAME + } + + fun fromFeatureType(featureType: KeyboardFeatureType): TemplateCategoryType { + return entries.firstOrNull { it.featureType == featureType } ?: GAME + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/model/TemplateTextEntity.kt b/app/src/main/java/com/frogobox/appkeyboard/model/TemplateTextEntity.kt new file mode 100644 index 0000000..3fe07cd --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/model/TemplateTextEntity.kt @@ -0,0 +1,38 @@ +package com.frogobox.appkeyboard.model + +import android.os.Parcelable +import androidx.annotation.Keep +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import kotlinx.parcelize.Parcelize + +/** + * Room Entity representing a persistent template text item. + */ +@Keep +@Entity( + tableName = "template_text", + indices = [ + Index(value = ["category"]) + ] +) +@Parcelize +data class TemplateTextEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + var id: Int = 0, + + @ColumnInfo(name = "category") + var category: String = "", + + @ColumnInfo(name = "text") + var text: String = "", + + @ColumnInfo(name = "createdAt") + var createdAt: Long = System.currentTimeMillis(), + + @ColumnInfo(name = "updatedAt") + var updatedAt: Long = System.currentTimeMillis() +) : Parcelable diff --git a/app/src/main/java/com/frogobox/appkeyboard/repository/clipboard/ClipboardRepository.kt b/app/src/main/java/com/frogobox/appkeyboard/repository/clipboard/ClipboardRepository.kt new file mode 100644 index 0000000..996dd56 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/repository/clipboard/ClipboardRepository.kt @@ -0,0 +1,13 @@ +package com.frogobox.appkeyboard.repository.clipboard + +import com.frogobox.appkeyboard.model.ClipboardItem +import kotlinx.coroutines.flow.Flow + +interface ClipboardRepository { + fun getClipboardItems(): Flow> + fun getLatestClip(): Flow + suspend fun addClip(text: String) + suspend fun togglePin(id: String) + suspend fun deleteClip(id: String) + suspend fun clearHistory(keepPinned: Boolean = true) +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/repository/clipboard/ClipboardRepositoryImpl.kt b/app/src/main/java/com/frogobox/appkeyboard/repository/clipboard/ClipboardRepositoryImpl.kt new file mode 100644 index 0000000..fbdc518 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/repository/clipboard/ClipboardRepositoryImpl.kt @@ -0,0 +1,149 @@ +package com.frogobox.appkeyboard.repository.clipboard + +import com.frogobox.appkeyboard.model.ClipboardItem +import com.frogobox.sdk.delegate.preference.PreferenceDelegates +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ClipboardRepositoryImpl( + private val pref: PreferenceDelegates, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : ClipboardRepository { + + @Inject + constructor(pref: PreferenceDelegates) : this(pref, Dispatchers.IO) + + companion object { + const val PREF_KEY_CLIPBOARD_ITEMS = "PREF_KEY_CLIPBOARD_ITEMS" + const val MAX_UNPINNED_ITEMS = 40 + const val MAX_TEXT_LENGTH = 5000 + } + + private val gson = Gson() + private val mutex = Mutex() + private val _itemsFlow = MutableStateFlow>(emptyList()) + + init { + loadInitialItems() + } + + private fun loadInitialItems() { + val rawJson = pref.getPrefString(PREF_KEY_CLIPBOARD_ITEMS, "") + val items = deserializeList(rawJson) + _itemsFlow.value = sortItems(items) + } + + override fun getClipboardItems(): Flow> { + return _itemsFlow.asStateFlow() + } + + override fun getLatestClip(): Flow { + return _itemsFlow.map { list -> + list.maxByOrNull { it.timestamp } + } + } + + override suspend fun addClip(text: String): Unit = withContext(dispatcher) { + val trimmed = text.trim() + if (trimmed.isEmpty()) return@withContext + + val safeText = if (trimmed.length > MAX_TEXT_LENGTH) trimmed.take(MAX_TEXT_LENGTH) else trimmed + mutex.withLock { + val currentList = _itemsFlow.value.toMutableList() + + val existingIndex = currentList.indexOfFirst { it.text == safeText } + if (existingIndex != -1) { + val existing = currentList.removeAt(existingIndex) + val updated = existing.copy(timestamp = System.currentTimeMillis()) + currentList.add(0, updated) + } else { + val newItem = ClipboardItem( + id = UUID.randomUUID().toString(), + text = safeText, + timestamp = System.currentTimeMillis(), + isPinned = false + ) + currentList.add(0, newItem) + } + + val sorted = sortItems(enforceLimits(currentList)) + saveAndEmit(sorted) + } + } + + override suspend fun togglePin(id: String): Unit = withContext(dispatcher) { + mutex.withLock { + val currentList = _itemsFlow.value.map { item -> + if (item.id == id) { + item.copy(isPinned = !item.isPinned) + } else { + item + } + } + val sorted = sortItems(currentList) + saveAndEmit(sorted) + } + } + + override suspend fun deleteClip(id: String): Unit = withContext(dispatcher) { + mutex.withLock { + val currentList = _itemsFlow.value.filterNot { it.id == id } + saveAndEmit(currentList) + } + } + + override suspend fun clearHistory(keepPinned: Boolean): Unit = withContext(dispatcher) { + mutex.withLock { + val currentList = if (keepPinned) { + _itemsFlow.value.filter { it.isPinned } + } else { + emptyList() + } + saveAndEmit(currentList) + } + } + + private fun enforceLimits(items: List): List { + val pinned = items.filter { it.isPinned } + val unpinned = items.filterNot { it.isPinned }.take(MAX_UNPINNED_ITEMS) + return pinned + unpinned + } + + private fun sortItems(items: List): List { + val pinned = items.filter { it.isPinned }.sortedByDescending { it.timestamp } + val unpinned = items.filterNot { it.isPinned }.sortedByDescending { it.timestamp } + return pinned + unpinned + } + + private fun saveAndEmit(items: List) { + pref.savePrefString(PREF_KEY_CLIPBOARD_ITEMS, serializeList(items)) + _itemsFlow.value = items + } + + private fun serializeList(items: List): String { + return gson.toJson(items) + } + + private fun deserializeList(json: String): List { + if (json.isBlank()) return emptyList() + return try { + val type = object : TypeToken>() {}.type + gson.fromJson>(json, type) ?: emptyList() + } catch (_: Exception) { + emptyList() + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/repository/data/DataApiRepositoryImpl.kt b/app/src/main/java/com/frogobox/appkeyboard/repository/data/DataApiRepositoryImpl.kt index 3e8e5c3..79fe305 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/repository/data/DataApiRepositoryImpl.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/repository/data/DataApiRepositoryImpl.kt @@ -3,6 +3,7 @@ package com.frogobox.appkeyboard.repository.data import com.frogobox.appkeyboard.common.core.Resource import com.frogobox.appkeyboard.data.remote.DataApiService import com.frogobox.appkeyboard.data.remote.model.DataApiResponse +import com.frogobox.appkeyboard.di.NetworkModule import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -62,10 +63,12 @@ class DataApiRepositoryImpl( } private fun formatErrorMessage(throwable: Throwable): String { + val serverUrl = NetworkModule.BASE_URL.removeSuffix("/") + val host = NetworkModule.BASE_URL.removePrefix("http://").removePrefix("https://").substringBefore(":").substringBefore("/") return when (throwable) { - is ConnectException -> "Connection refused. Please ensure the local server is running at http://192.168.100.6:3000." + is ConnectException -> "Connection refused. Please ensure the local server is running at $serverUrl." is SocketTimeoutException -> "Connection timed out while communicating with the server." - is UnknownHostException -> "Unable to resolve host 192.168.100.6. Check your Wi-Fi network." + is UnknownHostException -> "Unable to resolve host $host. Check your Wi-Fi network." is IOException -> throwable.localizedMessage ?: "Network I/O error occurred." else -> throwable.localizedMessage ?: "Unexpected error occurred." } diff --git a/app/src/main/java/com/frogobox/appkeyboard/repository/productremote/ProductRemoteRepository.kt b/app/src/main/java/com/frogobox/appkeyboard/repository/productremote/ProductRemoteRepository.kt new file mode 100644 index 0000000..1a94491 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/repository/productremote/ProductRemoteRepository.kt @@ -0,0 +1,44 @@ +package com.frogobox.appkeyboard.repository.productremote + +import com.frogobox.appkeyboard.common.core.Resource +import com.frogobox.appkeyboard.data.remote.model.DataItemResponse +import com.frogobox.appkeyboard.model.ProductEntity +import kotlinx.coroutines.flow.Flow + +/** + * Repository interface for managing Product Remote data across local Room DB and Remote API. + * Provides offline-first reactivity, full CRUD operations, and sync orchestration. + */ +interface ProductRemoteRepository { + + fun getSavedProductsStream(): Flow> + + suspend fun getSavedProductsSync(): List + + fun getSavedRemoteIdsStream(): Flow> + + fun getProductById(id: Int): Flow + + suspend fun saveProduct(product: ProductEntity): Long + + suspend fun saveProducts(products: List) + + suspend fun updateProduct(product: ProductEntity) + + suspend fun deleteProduct(product: ProductEntity) + + suspend fun deleteProductById(id: Int) + + suspend fun deleteProductByRemoteId(remoteId: String) + + suspend fun nukeAllSavedProducts() + + fun syncAllFromRemote(): Flow> + + suspend fun toggleSaveRemoteProduct(item: DataItemResponse): Boolean + + suspend fun isProductSaved(remoteId: String): Boolean + + suspend fun updateDownloadStatus(remoteId: String?, localId: Int, status: String) + +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/repository/productremote/ProductRemoteRepositoryImpl.kt b/app/src/main/java/com/frogobox/appkeyboard/repository/productremote/ProductRemoteRepositoryImpl.kt new file mode 100644 index 0000000..4818827 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/repository/productremote/ProductRemoteRepositoryImpl.kt @@ -0,0 +1,138 @@ +package com.frogobox.appkeyboard.repository.productremote + +import com.frogobox.appkeyboard.common.core.Resource +import com.frogobox.appkeyboard.data.local.productremote.ProductRemoteDao +import com.frogobox.appkeyboard.data.remote.model.DataItemResponse +import com.frogobox.appkeyboard.model.ProductEntity +import com.frogobox.appkeyboard.model.toProductEntity +import com.frogobox.appkeyboard.repository.data.DataApiRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Implementation of [ProductRemoteRepository] coordinating Room database operations + * and network API synchronization. + */ +@Singleton +class ProductRemoteRepositoryImpl @Inject constructor( + private val productRemoteDao: ProductRemoteDao, + private val dataApiRepository: DataApiRepository +) : ProductRemoteRepository { + + override fun getSavedProductsStream(): Flow> { + return productRemoteDao.getAll() + } + + override suspend fun getSavedProductsSync(): List { + return productRemoteDao.getAllSync() + } + + override fun getSavedRemoteIdsStream(): Flow> { + return productRemoteDao.getSavedRemoteIds().map { it.toSet() } + } + + override fun getProductById(id: Int): Flow { + return productRemoteDao.getById(id) + } + + override suspend fun saveProduct(product: ProductEntity): Long { + return productRemoteDao.insert(product) + } + + override suspend fun saveProducts(products: List) { + productRemoteDao.insertAll(products) + } + + override suspend fun updateProduct(product: ProductEntity) { + val targetId = if (product.id > 0) { + product.id + } else if (product.remoteId != null) { + productRemoteDao.getByRemoteId(product.remoteId!!)?.id ?: 0 + } else { + 0 + } + if (targetId > 0) { + productRemoteDao.update(product.copy(id = targetId)) + } else { + productRemoteDao.insert(product) + } + } + + override suspend fun deleteProduct(product: ProductEntity) { + productRemoteDao.delete(product) + } + + override suspend fun deleteProductById(id: Int) { + productRemoteDao.deleteById(id) + } + + override suspend fun deleteProductByRemoteId(remoteId: String) { + productRemoteDao.deleteByRemoteId(remoteId) + } + + override suspend fun nukeAllSavedProducts() { + productRemoteDao.nukeData() + } + + override fun syncAllFromRemote(): Flow> = flow { + emit(Resource.Loading) + when (val result = dataApiRepository.fetchData()) { + is Resource.Success -> { + val remoteItems = result.data.items.orEmpty() + if (remoteItems.isEmpty()) { + emit(Resource.Success(0)) + return@flow + } + + // Batch-load existing mappings in ONE single query to eliminate N+1 table scans + val existingLocalMap = productRemoteDao.getAllSync() + .filter { it.remoteId != null } + .associateBy { it.remoteId!! } + + val entitiesToInsert = remoteItems.map { remoteItem -> + val existing = remoteItem.id?.let { existingLocalMap[it] } + remoteItem.toProductEntity(localId = existing?.id ?: 0) + } + + productRemoteDao.insertAll(entitiesToInsert) + emit(Resource.Success(entitiesToInsert.size)) + } + is Resource.Error -> { + emit(Resource.Error(result.message)) + } + is Resource.Loading -> { + emit(Resource.Loading) + } + } + } + + override suspend fun toggleSaveRemoteProduct(item: DataItemResponse): Boolean { + val remoteId = item.id + if (remoteId != null) { + val existing = productRemoteDao.getByRemoteId(remoteId) + if (existing != null) { + productRemoteDao.delete(existing) + return false // Now removed from saved list + } + } + val entity = item.toProductEntity() + productRemoteDao.insert(entity) + return true // Now saved in Room DB + } + + override suspend fun isProductSaved(remoteId: String): Boolean { + return productRemoteDao.getByRemoteId(remoteId) != null + } + + override suspend fun updateDownloadStatus(remoteId: String?, localId: Int, status: String) { + if (!remoteId.isNullOrBlank()) { + productRemoteDao.updateDownloadStatusByRemoteId(remoteId, status) + } else if (localId > 0) { + productRemoteDao.updateDownloadStatusById(localId, status) + } + } + +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/repository/templatetext/TemplateTextRepository.kt b/app/src/main/java/com/frogobox/appkeyboard/repository/templatetext/TemplateTextRepository.kt new file mode 100644 index 0000000..d91abae --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/repository/templatetext/TemplateTextRepository.kt @@ -0,0 +1,18 @@ +package com.frogobox.appkeyboard.repository.templatetext + +import com.frogobox.appkeyboard.model.TemplateTextEntity +import kotlinx.coroutines.flow.Flow + +interface TemplateTextRepository { + fun getAll(): Flow> + fun getByCategory(category: String): Flow> + suspend fun getByCategorySync(category: String): List + suspend fun getAllSync(): List + suspend fun insertTemplate(category: String, text: String): Long + suspend fun updateTemplate(id: Int, category: String, text: String): Boolean + suspend fun deleteTemplate(id: Int): Boolean + suspend fun deleteByCategory(category: String): Boolean + suspend fun seedDefaultsIfEmpty() + suspend fun resetCategoryToDefaults(category: String) + suspend fun resetAllToDefaults() +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/repository/templatetext/TemplateTextRepositoryImpl.kt b/app/src/main/java/com/frogobox/appkeyboard/repository/templatetext/TemplateTextRepositoryImpl.kt new file mode 100644 index 0000000..22d363f --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/repository/templatetext/TemplateTextRepositoryImpl.kt @@ -0,0 +1,144 @@ +package com.frogobox.appkeyboard.repository.templatetext + +import android.content.Context +import com.frogobox.appkeyboard.data.local.templatetext.TemplateTextDao +import com.frogobox.appkeyboard.model.TemplateCategoryType +import com.frogobox.appkeyboard.model.TemplateTextEntity +import com.frogobox.sdk.ext.getDataFromJsonAsset +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class TemplateTextRepositoryImpl @Inject constructor( + private val dao: TemplateTextDao, + @ApplicationContext private val context: Context +) : TemplateTextRepository { + + private fun getAssetFileNameForCategory(category: String): String { + return when (TemplateCategoryType.fromKey(category)) { + TemplateCategoryType.GAME -> "text/text_game.json" + TemplateCategoryType.APP -> "text/text_app.json" + TemplateCategoryType.SALE -> "text/text_sale.json" + TemplateCategoryType.GREETING -> "text/text_greeting.json" + TemplateCategoryType.LOVE -> "text/text_love.json" + } + } + + override fun getAll(): Flow> { + return dao.getAllFlow().flowOn(Dispatchers.IO) + } + + override fun getByCategory(category: String): Flow> { + val normalizedCategory = TemplateCategoryType.fromKey(category).key + return dao.getByCategoryFlow(normalizedCategory).flowOn(Dispatchers.IO) + } + + override suspend fun getByCategorySync(category: String): List { + return withContext(Dispatchers.IO) { + val normalizedCategory = TemplateCategoryType.fromKey(category).key + var items = dao.getByCategory(normalizedCategory) + if (items.isEmpty()) { + seedCategory(normalizedCategory) + items = dao.getByCategory(normalizedCategory) + } + items + } + } + + override suspend fun getAllSync(): List { + return withContext(Dispatchers.IO) { + seedDefaultsIfEmpty() + dao.getAll() + } + } + + override suspend fun insertTemplate(category: String, text: String): Long { + return withContext(Dispatchers.IO) { + val normalizedCategory = TemplateCategoryType.fromKey(category).key + val now = System.currentTimeMillis() + dao.insert( + TemplateTextEntity( + category = normalizedCategory, + text = text.trim(), + createdAt = now, + updatedAt = now + ) + ) + } + } + + override suspend fun updateTemplate(id: Int, category: String, text: String): Boolean { + return withContext(Dispatchers.IO) { + val existing = dao.getById(id) ?: return@withContext false + val normalizedCategory = TemplateCategoryType.fromKey(category).key + existing.category = normalizedCategory + existing.text = text.trim() + existing.updatedAt = System.currentTimeMillis() + dao.update(existing) > 0 + } + } + + override suspend fun deleteTemplate(id: Int): Boolean { + return withContext(Dispatchers.IO) { + dao.deleteById(id) > 0 + } + } + + override suspend fun deleteByCategory(category: String): Boolean { + return withContext(Dispatchers.IO) { + val normalizedCategory = TemplateCategoryType.fromKey(category).key + dao.deleteByCategory(normalizedCategory) > 0 + } + } + + private suspend fun seedCategory(category: String) { + val fileName = getAssetFileNameForCategory(category) + val defaultStrings: List = try { + context.getDataFromJsonAsset(fileName) + } catch (_: Exception) { + emptyList() + } + if (defaultStrings.isNotEmpty()) { + val now = System.currentTimeMillis() + val entities = defaultStrings.map { str -> + TemplateTextEntity( + category = category, + text = str, + createdAt = now, + updatedAt = now + ) + } + dao.insertAll(entities) + } + } + + override suspend fun seedDefaultsIfEmpty() { + withContext(Dispatchers.IO) { + TemplateCategoryType.entries.forEach { cat -> + if (dao.countByCategory(cat.key) == 0) { + seedCategory(cat.key) + } + } + } + } + + override suspend fun resetCategoryToDefaults(category: String) { + withContext(Dispatchers.IO) { + val normalizedCategory = TemplateCategoryType.fromKey(category).key + dao.deleteByCategory(normalizedCategory) + seedCategory(normalizedCategory) + } + } + + override suspend fun resetAllToDefaults() { + withContext(Dispatchers.IO) { + dao.deleteAll() + TemplateCategoryType.entries.forEach { cat -> + seedCategory(cat.key) + } + } + } +} 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 b1810f4..98026e0 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardIME.kt @@ -1,9 +1,14 @@ package com.frogobox.appkeyboard.services +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.content.Intent import android.graphics.Paint import android.graphics.Typeface +import android.view.KeyEvent import android.view.View +import android.view.inputmethod.ExtractedTextRequest import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager import android.widget.EditText @@ -11,6 +16,8 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils import androidx.emoji2.text.EmojiCompat import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeViewModelStoreOwner @@ -18,14 +25,22 @@ import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.frogobox.api.movie.ConsumeMovieApi import com.frogobox.api.news.ConsumeNewsApi import com.frogobox.appkeyboard.R +import com.frogobox.appkeyboard.common.core.Resource +import com.frogobox.appkeyboard.data.remote.model.DataItemResponse import com.frogobox.appkeyboard.model.AutoTextEntity +import com.frogobox.appkeyboard.model.ClipboardItem import com.frogobox.appkeyboard.model.KeyboardFeatureModel import com.frogobox.appkeyboard.model.KeyboardFeatureType import com.frogobox.appkeyboard.model.ThemeType +import com.frogobox.appkeyboard.repository.autotext.AutoTextRepository +import com.frogobox.appkeyboard.repository.clipboard.ClipboardRepository +import com.frogobox.appkeyboard.repository.data.DataApiRepository +import com.frogobox.appkeyboard.repository.productremote.ProductRemoteRepository import com.frogobox.appkeyboard.suggestion.SuggestionResult import com.frogobox.appkeyboard.suggestion.WordSuggestionEngine import com.frogobox.appkeyboard.ui.autotext.AutoTextActivity -import com.frogobox.appkeyboard.ui.keyboard.autotext.AutoTextKeyboardViewModel +import com.frogobox.appkeyboard.ui.keyboard.textedit.TextEditAction +import com.frogobox.appkeyboard.ui.productremote.ProductRemoteActivity import com.frogobox.appkeyboard.ui.keyboard.root.KeyboardImeRootScreen import com.frogobox.appkeyboard.ui.keyboard.root.KeyboardPanelState import com.frogobox.appkeyboard.ui.main.MainActivity @@ -52,12 +67,14 @@ import com.frogobox.sdk.delegate.preference.PreferenceDelegates import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject +import androidx.core.graphics.toColorInt /** * Modern 100% Full Pure Jetpack Compose InputMethodService. @@ -77,6 +94,18 @@ class KeyboardIME : BaseKeyboardIME() { @Inject lateinit var suggestionEngine: WordSuggestionEngine + @Inject + lateinit var dataApiRepository: DataApiRepository + + @Inject + lateinit var productRemoteRepository: ProductRemoteRepository + + @Inject + lateinit var autoTextRepository: AutoTextRepository + + @Inject + lateinit var clipboardRepository: ClipboardRepository + // Reactive State Holders private val activePanelStateFlow = MutableStateFlow(KeyboardPanelState.MAIN) private val themeTypeFlow = MutableStateFlow(ThemeType.COLOR) @@ -84,14 +113,28 @@ class KeyboardIME : BaseKeyboardIME() { private val featuresFlow = MutableStateFlow>(emptyList()) private val isSuggestionVisibleFlow = MutableStateFlow(false) private val suggestionResultFlow = MutableStateFlow(SuggestionResult.EMPTY) + private val alwaysShowFeatureFlow = MutableStateFlow(null) // Sub-Screen Reactive Data Flows private val autoTextListFlow = MutableStateFlow>(emptyList()) + private val productRemoteItemsFlow = MutableStateFlow>(emptyList()) + private val isProductRemoteLoadingFlow = MutableStateFlow(false) + private val productRemoteErrorFlow = MutableStateFlow(null) private val newsArticlesFlow = MutableStateFlow>(emptyList()) private val isNewsLoadingFlow = MutableStateFlow(false) private val movieListFlow = MutableStateFlow>(emptyList()) private val isMovieLoadingFlow = MutableStateFlow(false) + // Clipboard & Text Editing State Flows + private val clipboardItemsFlow = MutableStateFlow>(emptyList()) + private val recentClipFlow = MutableStateFlow(null) + private val isSelectionModeFlow = MutableStateFlow(false) + private var selectionAnchor = -1 + private var clipboardManager: ClipboardManager? = null + private val clipChangedListener = ClipboardManager.OnPrimaryClipChangedListener { + checkAndCaptureClipboard() + } + // Emoji State Flows private val emojisFlow = MutableStateFlow>(emptyList()) private val selectedEmojiCategoryFlow = MutableStateFlow(EmojiCategoryType.GENERAL) @@ -100,18 +143,23 @@ class KeyboardIME : BaseKeyboardIME() { private val systemFontPaint = Paint().apply { typeface = Typeface.DEFAULT } private val emojiCompatMetadataVersion = 0 - // Coroutine Scope & View Models + // Coroutine Scope & Managed Jobs private val serviceJob = SupervisorJob() private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob) - private val autoTextViewModel by lazy { AutoTextKeyboardViewModel(this) } + private var productRemoteJob: Job? = null + private var autoTextJob: Job? = null + private var suggestionJob: Job? = null + private var clipboardJob: Job? = null // Interop MainKeyboard Instance private var mainKeyboardInstance: MainKeyboard? = null private val currentKeyboardFlow = MutableStateFlow(null) + private var keyConsumer: ((Int, Boolean) -> Boolean)? = null override fun onCreate() { super.onCreate() imeLifecycleOwner.onCreate() + clipboardManager = getSystemService(CLIPBOARD_SERVICE) as? ClipboardManager } override fun onWindowShown() { @@ -122,17 +170,60 @@ class KeyboardIME : BaseKeyboardIME() { setupTheme() setupFeatureKeyboard() loadAutoText() - showMainKeyboard() + loadProductRemote() + + val isClipboardActive = keyboardUtil.isClipboardEnabled() + if (isClipboardActive) { + loadClipboardItems() + try { + clipboardManager?.addPrimaryClipChangedListener(clipChangedListener) + } catch (_: Exception) {} + checkAndCaptureClipboard() + } else { + recentClipFlow.value = null + clipboardItemsFlow.value = emptyList() + try { + clipboardManager?.removePrimaryClipChangedListener(clipChangedListener) + } catch (_: Exception) {} + } + + val alwaysShowId = keyboardUtil.getAlwaysShowFeature() + alwaysShowFeatureFlow.value = alwaysShowId + + val activeFeatures = keyboardUtil.menuKeyboard() + val defaultFeature = if (!alwaysShowId.isNullOrEmpty()) { + activeFeatures.firstOrNull { it.id == alwaysShowId } + } else null + + if (defaultFeature != null) { + handleFeatureClick(defaultFeature) + } else { + showMainKeyboard() + } } override fun onWindowHidden() { super.onWindowHidden() imeLifecycleOwner.onPause() imeLifecycleOwner.onStop() + productRemoteJob?.cancel() + autoTextJob?.cancel() + suggestionJob?.cancel() + clipboardJob?.cancel() + try { + clipboardManager?.removePrimaryClipChangedListener(clipChangedListener) + } catch (_: Exception) {} } override fun onDestroy() { super.onDestroy() + productRemoteJob?.cancel() + autoTextJob?.cancel() + suggestionJob?.cancel() + clipboardJob?.cancel() + try { + clipboardManager?.removePrimaryClipChangedListener(clipChangedListener) + } catch (_: Exception) {} serviceJob.cancelChildren() imeLifecycleOwner.onDestroy() mainKeyboardInstance = null @@ -161,6 +252,9 @@ class KeyboardIME : BaseKeyboardIME() { val currentKeyboard by currentKeyboardFlow.collectAsState() val autoTextList by autoTextListFlow.collectAsState() + val productRemoteItems by productRemoteItemsFlow.collectAsState() + val isProductRemoteLoading by isProductRemoteLoadingFlow.collectAsState() + val productRemoteError by productRemoteErrorFlow.collectAsState() val newsArticles by newsArticlesFlow.collectAsState() val isNewsLoading by isNewsLoadingFlow.collectAsState() val movieList by movieListFlow.collectAsState() @@ -170,6 +264,11 @@ class KeyboardIME : BaseKeyboardIME() { val selectedEmojiCategory by selectedEmojiCategoryFlow.collectAsState() val isEmojiLoading by isEmojiLoadingFlow.collectAsState() + val clipboardItems by clipboardItemsFlow.collectAsState() + val recentClip by recentClipFlow.collectAsState() + val isSelectionMode by isSelectionModeFlow.collectAsState() + val alwaysShowFeature by alwaysShowFeatureFlow.collectAsState() + KeyboardImeRootScreen( activePanelState = activePanel, themeType = themeType, @@ -185,6 +284,7 @@ class KeyboardIME : BaseKeyboardIME() { onCloseSuggestion = { suggestionResultFlow.value = SuggestionResult.EMPTY isSuggestionVisibleFlow.value = false + recentClipFlow.value = null }, currentKeyboard = currentKeyboard, onKeyboardActionListener = this@KeyboardIME, @@ -199,6 +299,18 @@ class KeyboardIME : BaseKeyboardIME() { } startActivity(intent) }, + productRemoteItems = productRemoteItems, + isProductRemoteLoading = isProductRemoteLoading, + productRemoteError = productRemoteError, + onRefreshProductRemote = { + fetchProductRemote() + }, + onManageProductRemote = { + val intent = Intent(this@KeyboardIME, ProductRemoteActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + startActivity(intent) + }, newsArticles = newsArticles, isNewsLoading = isNewsLoading, movieList = movieList, @@ -213,7 +325,7 @@ class KeyboardIME : BaseKeyboardIME() { }, onEmojiClicked = { emoji -> getActiveInputConnection()?.commitText(emoji, 1) - this@KeyboardIME.onText(emoji) + recordRecentEmoji(emoji) this@KeyboardIME.onActionUp() }, onCommitText = { text -> @@ -221,6 +333,35 @@ class KeyboardIME : BaseKeyboardIME() { }, onBackToMain = { showMainKeyboard() + }, + onRegisterKeyConsumer = { consumer -> + keyConsumer = consumer + }, + clipboardItems = clipboardItems, + onTogglePinClipboardItem = { id -> + serviceScope.launch { clipboardRepository.togglePin(id) } + }, + onDeleteClipboardItem = { id -> + serviceScope.launch { clipboardRepository.deleteClip(id) } + }, + onClearClipboardHistory = { + serviceScope.launch { clipboardRepository.clearHistory(keepPinned = true) } + }, + recentClip = recentClip, + onQuickPaste = { text -> + getActiveInputConnection()?.commitText(text, 1) + recentClipFlow.value = null + }, + isSelectionMode = isSelectionMode, + onTextEditAction = { action -> + handleTextEditAction(action) + }, + alwaysShowFeatureId = alwaysShowFeature, + onToggleAlwaysShowFeature = { featureId -> + handleToggleAlwaysShow(featureId) + }, + onDeleteEmoji = { + onKey(ItemMainKeyboard.KEYCODE_DELETE) } ) } @@ -249,6 +390,7 @@ class KeyboardIME : BaseKeyboardIME() { } override fun showMainKeyboard() { + keyConsumer = null activePanelStateFlow.value = KeyboardPanelState.MAIN isSuggestionVisibleFlow.value = false suggestionResultFlow.value = SuggestionResult.EMPTY @@ -288,6 +430,23 @@ class KeyboardIME : BaseKeyboardIME() { themeTypeFlow.value = backgroundType themeBackgroundResFlow.value = background + + val isDark = if (backgroundType == ThemeType.IMAGE) { + true + } else { + try { + val colorInt = ContextCompat.getColor(this, background) + ColorUtils.calculateLuminance(colorInt) < 0.45 + } catch (_: Exception) { + false + } + } + val textColor = if (isDark) android.graphics.Color.WHITE else "#0F172A".toColorInt() + mainKeyboardInstance?.setKeyboardTheme( + textColor = textColor, + actionTextColor = textColor, + isDark = isDark + ) } override fun setupFeatureKeyboard() { @@ -298,6 +457,15 @@ class KeyboardIME : BaseKeyboardIME() { setupTheme() setupFeatureKeyboard() loadAutoText() + loadProductRemote() + if (!keyboardUtil.isClipboardEnabled()) { + recentClipFlow.value = null + clipboardItemsFlow.value = emptyList() + try { + clipboardManager?.removePrimaryClipChangedListener(clipChangedListener) + } catch (_: Exception) {} + } + alwaysShowFeatureFlow.value = keyboardUtil.getAlwaysShowFeature() } override fun initView() { @@ -305,20 +473,107 @@ class KeyboardIME : BaseKeyboardIME() { setupTheme() setupFeatureKeyboard() loadAutoText() + loadProductRemote() openEmojiPalette() } + // Auto-Correction State & Single-Tap Undo (Gboard Parity) + private data class AutoCorrectionRecord( + val original: String, + val replacement: String, + val timestamp: Long + ) + private var lastAutoCorrection: AutoCorrectionRecord? = null + override fun onKey(code: Int) { + val kb = keyboard + val isShifted = kb != null && kb.mShiftState > ItemMainKeyboard.SHIFT_OFF + if (keyConsumer?.invoke(code, isShifted) == true) { + if (kb?.mShiftState == ItemMainKeyboard.SHIFT_ON_ONE_CHAR) { + kb.mShiftState = ItemMainKeyboard.SHIFT_OFF + invalidateAllKeys() + } + return + } + val ic = getActiveInputConnection() ?: return + + // 1. Single-Tap Backspace Undo for Auto-Correction (Gboard Parity) + if (code == ItemMainKeyboard.KEYCODE_DELETE) { + val record = lastAutoCorrection + if (record != null) { + val expectedSuffix = "${record.replacement} " + val textBefore = ic.getTextBeforeCursor(expectedSuffix.length, 0)?.toString() + if (textBefore == expectedSuffix) { + ic.deleteSurroundingText(expectedSuffix.length, 0) + ic.commitText(record.original, 1) + lastAutoCorrection = null + if (keyboardUtil.isSuggestionEnabled()) { + suggestionJob?.cancel() + suggestionJob = serviceScope.launch(Dispatchers.Default) { + val suggestions = suggestionEngine.getSuggestions(record.original) + withContext(Dispatchers.Main) { + suggestionResultFlow.value = suggestions + isSuggestionVisibleFlow.value = true + } + } + } + return + } + } + lastAutoCorrection = null + } + + // 2. Spacebar Auto-Correction (Gboard Parity) + if (code == ItemMainKeyboard.KEYCODE_SPACE) { + val editorInfo = currentInputEditorInfo + val inputType = editorInfo?.inputType ?: 0 + val inputClass = inputType and android.text.InputType.TYPE_MASK_CLASS + val variation = inputType and android.text.InputType.TYPE_MASK_VARIATION + val isSensitiveOrNumeric = (inputClass == android.text.InputType.TYPE_CLASS_NUMBER) || + (inputClass == android.text.InputType.TYPE_CLASS_PHONE) || + (inputClass == android.text.InputType.TYPE_CLASS_DATETIME) || + (inputClass == android.text.InputType.TYPE_CLASS_TEXT && ( + variation == android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD || + variation == android.text.InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD || + variation == android.text.InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD || + variation == android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS || + variation == android.text.InputType.TYPE_TEXT_VARIATION_URI + )) + + val wordBefore = getWordBeforeCursor(ic) + if (keyboardUtil.isSuggestionEnabled() && !isSensitiveOrNumeric && wordBefore.length >= 2) { + val replacement = suggestionEngine.getAutoCorrectReplacement(wordBefore) + if (replacement != null && !replacement.equals(wordBefore, ignoreCase = true)) { + ic.deleteSurroundingText(wordBefore.length, 0) + ic.commitText("$replacement ", 1) + lastAutoCorrection = AutoCorrectionRecord(wordBefore, replacement, System.currentTimeMillis()) + suggestionResultFlow.value = SuggestionResult.EMPTY + isSuggestionVisibleFlow.value = false + updateShiftKeyState() + return + } + } + lastAutoCorrection = null + } else if (code != ItemMainKeyboard.KEYCODE_DELETE) { + lastAutoCorrection = null + } + onKeyExt(code, ic) if (keyboardUtil.isSuggestionEnabled()) { val word = getWordBeforeCursor(ic) if (word.isNotEmpty()) { - val suggestions = suggestionEngine.getSuggestions(word) - suggestionResultFlow.value = suggestions - isSuggestionVisibleFlow.value = true + suggestionJob?.cancel() + suggestionJob = serviceScope.launch(Dispatchers.Default) { + val suggestions = suggestionEngine.getSuggestions(word) + withContext(Dispatchers.Main) { + suggestionResultFlow.value = suggestions + isSuggestionVisibleFlow.value = true + } + } } else { + suggestionJob?.cancel() suggestionResultFlow.value = SuggestionResult.EMPTY if (code == ItemMainKeyboard.KEYCODE_SPACE || code == ItemMainKeyboard.KEYCODE_ENTER || @@ -408,11 +663,17 @@ class KeyboardIME : BaseKeyboardIME() { activePanelStateFlow.value = KeyboardPanelState.AUTO_TEXT } + KeyboardFeatureType.PRODUCT_REMOTE -> { + loadProductRemote() + activePanelStateFlow.value = KeyboardPanelState.PRODUCT_REMOTE + } + KeyboardFeatureType.TEMPLATE_TEXT_GAME, KeyboardFeatureType.TEMPLATE_TEXT_APP, KeyboardFeatureType.TEMPLATE_TEXT_SALE, KeyboardFeatureType.TEMPLATE_TEXT_LOVE, - KeyboardFeatureType.TEMPLATE_TEXT_GREETING -> { + KeyboardFeatureType.TEMPLATE_TEXT_GREETING, + -> { KeyboardPanelState.fromFeature(featureType)?.let { panel -> activePanelStateFlow.value = panel } @@ -436,6 +697,14 @@ class KeyboardIME : BaseKeyboardIME() { activePanelStateFlow.value = KeyboardPanelState.FORM } + KeyboardFeatureType.CLIPBOARD -> { + activePanelStateFlow.value = KeyboardPanelState.CLIPBOARD + } + + KeyboardFeatureType.TEXT_EDIT -> { + activePanelStateFlow.value = KeyboardPanelState.TEXT_EDIT + } + KeyboardFeatureType.SUGGESTION -> { isSuggestionVisibleFlow.value = !isSuggestionVisibleFlow.value } @@ -453,8 +722,43 @@ class KeyboardIME : BaseKeyboardIME() { } private fun loadAutoText() { - autoTextViewModel.getAutoText { items -> - autoTextListFlow.value = items + autoTextJob?.cancel() + autoTextJob = serviceScope.launch { + autoTextRepository.getAutoText().collect { items -> + autoTextListFlow.value = items + } + } + } + + private fun loadProductRemote() { + productRemoteJob?.cancel() + productRemoteJob = serviceScope.launch { + productRemoteRepository.getSavedProductsStream().collect { savedList -> + productRemoteItemsFlow.value = savedList.map { it.toDataItemResponse() } + isProductRemoteLoadingFlow.value = false + } + } + } + + private fun fetchProductRemote() { + isProductRemoteLoadingFlow.value = true + productRemoteErrorFlow.value = null + serviceScope.launch { + productRemoteRepository.syncAllFromRemote().collect { resource -> + when (resource) { + is Resource.Loading -> { + isProductRemoteLoadingFlow.value = true + } + is Resource.Success -> { + isProductRemoteLoadingFlow.value = false + productRemoteErrorFlow.value = null + } + is Resource.Error -> { + isProductRemoteLoadingFlow.value = false + productRemoteErrorFlow.value = resource.message + } + } + } } } @@ -520,12 +824,223 @@ class KeyboardIME : BaseKeyboardIME() { }) } + companion object { + const val PREF_RECENT_EMOJIS = "PREF_RECENT_EMOJIS" + } + + private fun getRecentEmojis(): List { + val raw = pref.getPrefString(PREF_RECENT_EMOJIS, "") + if (raw.isBlank()) return emptyList() + return raw.split(",").filter { it.isNotBlank() } + } + + private fun recordRecentEmoji(emoji: String) { + val current = getRecentEmojis().toMutableList() + current.remove(emoji) + current.add(0, emoji) + val limited = current.take(30) + pref.savePrefString(PREF_RECENT_EMOJIS, limited.joinToString(",")) + if (selectedEmojiCategoryFlow.value == EmojiCategoryType.RECENT) { + emojisFlow.value = limited + } + } + + private fun checkAndCaptureClipboard() { + if (!keyboardUtil.isClipboardEnabled()) { + recentClipFlow.value = null + return + } + val cm = clipboardManager ?: return + val clip = cm.primaryClip + if (clip != null && clip.itemCount > 0) { + val text = clip.getItemAt(0)?.text?.toString()?.trim() + if (!text.isNullOrEmpty()) { + recentClipFlow.value = text + serviceScope.launch { + clipboardRepository.addClip(text) + } + } + } + } + + private fun handleToggleAlwaysShow(featureId: String) { + val current = keyboardUtil.getAlwaysShowFeature() + val next = if (current == featureId) null else featureId + keyboardUtil.setAlwaysShowFeature(next) + alwaysShowFeatureFlow.value = next + } + + private fun loadClipboardItems() { + clipboardJob?.cancel() + clipboardJob = serviceScope.launch { + clipboardRepository.getClipboardItems().collect { items -> + clipboardItemsFlow.value = items + } + } + } + + private fun handleTextEditAction(action: TextEditAction) { + val ic = getActiveInputConnection() ?: return + when (action) { + TextEditAction.MOVE_LEFT -> { + if (isSelectionModeFlow.value) { + val extracted = ic.getExtractedText(ExtractedTextRequest(), 0) + val end = extracted?.selectionEnd ?: 0 + if (selectionAnchor == -1) selectionAnchor = extracted?.selectionStart ?: end + val newEnd = (end - 1).coerceAtLeast(0) + ic.setSelection(selectionAnchor, newEnd) + } else { + moveCursor(false) + } + } + + TextEditAction.MOVE_RIGHT -> { + if (isSelectionModeFlow.value) { + val extracted = ic.getExtractedText(ExtractedTextRequest(), 0) + val end = extracted?.selectionEnd ?: 0 + val totalLen = extracted?.text?.length ?: (end + 1) + if (selectionAnchor == -1) selectionAnchor = extracted?.selectionStart ?: end + val newEnd = (end + 1).coerceAtMost(totalLen) + ic.setSelection(selectionAnchor, newEnd) + } else { + moveCursor(true) + } + } + + TextEditAction.MOVE_UP -> { + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP)) + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_UP)) + } + + TextEditAction.MOVE_DOWN -> { + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN)) + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_DOWN)) + } + + TextEditAction.MOVE_HOME -> { + val textBefore = ic.getTextBeforeCursor(1000, 0)?.toString() ?: "" + val lastNewline = textBefore.lastIndexOf('\n') + val distToStart = if (lastNewline == -1) textBefore.length else textBefore.length - (lastNewline + 1) + val extracted = ic.getExtractedText(ExtractedTextRequest(), 0) + val currentPos = extracted?.selectionStart ?: 0 + val newPos = (currentPos - distToStart).coerceAtLeast(0) + if (isSelectionModeFlow.value) { + if (selectionAnchor == -1) selectionAnchor = currentPos + ic.setSelection(selectionAnchor, newPos) + } else { + ic.setSelection(newPos, newPos) + } + } + + TextEditAction.MOVE_END -> { + val textAfter = ic.getTextAfterCursor(1000, 0)?.toString() ?: "" + val nextNewline = textAfter.indexOf('\n') + val distToEnd = if (nextNewline == -1) textAfter.length else nextNewline + val extracted = ic.getExtractedText(ExtractedTextRequest(), 0) + val currentPos = extracted?.selectionEnd ?: 0 + val newPos = currentPos + distToEnd + if (isSelectionModeFlow.value) { + if (selectionAnchor == -1) selectionAnchor = extracted?.selectionStart ?: currentPos + ic.setSelection(selectionAnchor, newPos) + } else { + ic.setSelection(newPos, newPos) + } + } + + TextEditAction.TOGGLE_SELECT -> { + val newMode = !isSelectionModeFlow.value + isSelectionModeFlow.value = newMode + if (newMode) { + val extracted = ic.getExtractedText(ExtractedTextRequest(), 0) + selectionAnchor = extracted?.selectionStart ?: 0 + } else { + selectionAnchor = -1 + } + } + + TextEditAction.SELECT_ALL -> { + val extracted = ic.getExtractedText(ExtractedTextRequest(), 0) + val totalLength = extracted?.text?.length ?: 0 + if (totalLength > 0) { + ic.setSelection(0, totalLength) + isSelectionModeFlow.value = true + selectionAnchor = 0 + } else { + ic.performContextMenuAction(android.R.id.selectAll) + } + } + + TextEditAction.CUT -> { + val selectedText = ic.getSelectedText(0)?.toString() ?: "" + if (selectedText.isNotEmpty()) { + try { + clipboardManager?.setPrimaryClip(ClipData.newPlainText("Cut", selectedText)) + } catch (_: Exception) {} + ic.commitText("", 1) + serviceScope.launch { clipboardRepository.addClip(selectedText) } + isSelectionModeFlow.value = false + selectionAnchor = -1 + } + } + + TextEditAction.COPY -> { + val selectedText = ic.getSelectedText(0)?.toString() ?: "" + if (selectedText.isNotEmpty()) { + try { + clipboardManager?.setPrimaryClip(ClipData.newPlainText("Copy", selectedText)) + } catch (_: Exception) {} + serviceScope.launch { clipboardRepository.addClip(selectedText) } + } + } + + TextEditAction.PASTE -> { + try { + val clipData = clipboardManager?.primaryClip + if (clipData != null && clipData.itemCount > 0) { + val clip = clipData.getItemAt(0)?.text?.toString() ?: "" + if (clip.isNotEmpty()) { + ic.commitText(clip, 1) + } + } + } catch (_: Exception) {} + } + + TextEditAction.DELETE -> { + val selected = ic.getSelectedText(0) + if (!selected.isNullOrEmpty()) { + ic.commitText("", 1) + isSelectionModeFlow.value = false + selectionAnchor = -1 + } else { + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)) + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL)) + } + } + + TextEditAction.ENTER -> { + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)) + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)) + } + } + } + private fun openEmojiPalette() { - selectedEmojiCategoryFlow.value = EmojiCategoryType.GENERAL - loadEmojis(EmojiCategoryType.GENERAL.path) + val recents = getRecentEmojis() + if (recents.isNotEmpty()) { + selectedEmojiCategoryFlow.value = EmojiCategoryType.RECENT + loadEmojis(EmojiCategoryType.RECENT.path) + } else { + selectedEmojiCategoryFlow.value = EmojiCategoryType.GENERAL + loadEmojis(EmojiCategoryType.GENERAL.path) + } } private fun loadEmojis(path: String) { + if (path == EmojiCategoryType.RECENT.path) { + emojisFlow.value = getRecentEmojis() + isEmojiLoadingFlow.value = false + return + } val cached = emojiCache[path] if (cached != null) { emojisFlow.value = cached 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 62f2a3e..a98c660 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardUtil.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/services/KeyboardUtil.kt @@ -23,9 +23,25 @@ class KeyboardUtil @Inject constructor( const val KEYBOARD_COLOR = "KEYBOARD_COLOR" const val KEYBOARD_COLOR_TYPE = "KEYBOARD_COLOR_TYPE" const val KEYBOARD_SUGGESTION_ENABLED = "KEYBOARD_SUGGESTION_ENABLED" + const val KEY_ALWAYS_SHOW_FEATURE = "KEY_ALWAYS_SHOW_FEATURE" + const val KEY_FEATURE_ORDER = "KEY_FEATURE_ORDER" + + val DEFAULT_FEATURE_TYPES = listOf( + KeyboardFeatureType.SUGGESTION, + KeyboardFeatureType.AUTO_TEXT, + KeyboardFeatureType.PRODUCT_REMOTE, + KeyboardFeatureType.CLIPBOARD, + KeyboardFeatureType.TEXT_EDIT, + KeyboardFeatureType.NEWS, + KeyboardFeatureType.MOVIE, + KeyboardFeatureType.WEB, + KeyboardFeatureType.FORM, + KeyboardFeatureType.CHANGE_KEYBOARD, + KeyboardFeatureType.SETTING + ) } - private fun getStateToggle(key: String) : Boolean { + fun getStateToggle(key: String): Boolean { return pref.getPrefBoolean(key, true) } @@ -33,21 +49,49 @@ class KeyboardUtil @Inject constructor( return getStateToggle(KeyboardFeatureType.SUGGESTION.id) } + fun isClipboardEnabled(): Boolean { + return getStateToggle(KeyboardFeatureType.CLIPBOARD.id) + } + + fun getAlwaysShowFeature(): String? { + val featureId = pref.getPrefString(KEY_ALWAYS_SHOW_FEATURE, "") + return if (featureId.isBlank()) null else featureId + } + + fun setAlwaysShowFeature(featureId: String?) { + pref.savePrefString(KEY_ALWAYS_SHOW_FEATURE, featureId ?: "") + } + + fun isAlwaysShowFeature(featureId: String): Boolean { + return getAlwaysShowFeature() == featureId + } + + fun getFeatureOrder(): List { + val raw = pref.getPrefString(KEY_FEATURE_ORDER, "") + val defaultIds = DEFAULT_FEATURE_TYPES.map { it.id } + if (raw.isBlank()) return defaultIds + val saved = raw.split(",").filter { it.isNotBlank() } + val validSaved = saved.filter { id -> defaultIds.contains(id) }.toMutableList() + defaultIds.forEach { id -> + if (!validSaved.contains(id)) { + validSaved.add(id) + } + } + return validSaved + } + + fun saveFeatureOrder(order: List) { + pref.savePrefString(KEY_FEATURE_ORDER, order.joinToString(",")) + } + + fun resetFeatureOrder() { + pref.savePrefString(KEY_FEATURE_ORDER, "") + } + fun menuToggle(): List { - return listOf( - KeyboardFeatureType.SUGGESTION.mapToModel(), - KeyboardFeatureType.AUTO_TEXT.mapToModel(), - KeyboardFeatureType.TEMPLATE_TEXT_APP.mapToModel(), - KeyboardFeatureType.TEMPLATE_TEXT_GAME.mapToModel(), - KeyboardFeatureType.TEMPLATE_TEXT_LOVE.mapToModel(), - KeyboardFeatureType.TEMPLATE_TEXT_GREETING.mapToModel(), - KeyboardFeatureType.NEWS.mapToModel(), - KeyboardFeatureType.MOVIE.mapToModel(), - KeyboardFeatureType.WEB.mapToModel(), - KeyboardFeatureType.FORM.mapToModel(), - KeyboardFeatureType.CHANGE_KEYBOARD.mapToModel(), - KeyboardFeatureType.SETTING.mapToModel(), - ).sortedBy { getStateToggle(it.id) } + val order = getFeatureOrder() + val map = DEFAULT_FEATURE_TYPES.associateBy { it.id } + return order.mapNotNull { id -> map[id]?.mapToModel() } } fun menuKeyboard(): List { diff --git a/app/src/main/java/com/frogobox/appkeyboard/suggestion/WordSuggestionEngine.kt b/app/src/main/java/com/frogobox/appkeyboard/suggestion/WordSuggestionEngine.kt index 85d7dde..85f815d 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/suggestion/WordSuggestionEngine.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/suggestion/WordSuggestionEngine.kt @@ -20,8 +20,9 @@ import kotlin.math.min @Singleton class WordSuggestionEngine @Inject constructor() { - // Common instant typo and shorthand substitution mapping + // Common instant typo and shorthand substitution mapping (English & Indonesian) private val commonTypoMap = mapOf( + // English common typos & shorthands "teh" to "the", "wht" to "what", "adn" to "and", @@ -31,7 +32,7 @@ class WordSuggestionEngine @Inject constructor() { "im" to "I'm", "ive" to "I've", "youre" to "you're", - "theyre" to "they're", + "theyre" to "theyre", "alot" to "a lot", "helo" to "hello", "hw" to "how", @@ -50,7 +51,45 @@ class WordSuggestionEngine @Inject constructor() { "occured" to "occurred", "untill" to "until", "beleive" to "believe", - "frogo" to "Frogo" + "frogo" to "Frogo", + + // Indonesian common typos, conversational shorthands & seller slang + "yg" to "yang", + "dgn" to "dengan", + "sy" to "saya", + "km" to "kamu", + "gak" to "tidak", + "gk" to "tidak", + "ga" to "tidak", + "ngga" to "nggak", + "sdh" to "sudah", + "udh" to "sudah", + "blm" to "belum", + "bgt" to "banget", + "trs" to "terus", + "tdk" to "tidak", + "kpn" to "kapan", + "gmn" to "gimana", + "bs" to "bisa", + "bkn" to "bukan", + "tlg" to "tolong", + "mhn" to "mohon", + "makasih" to "terima kasih", + "mksh" to "terima kasih", + "makasi" to "terima kasih", + "tf" to "transfer", + "rek" to "rekening", + "brg" to "barang", + "ongkir" to "ongkir", + "cod" to "COD", + "resi" to "resi", + "bca" to "BCA", + "bri" to "BRI", + "bni" to "BNI", + "mandiri" to "Mandiri", + "jnt" to "J&T", + "jne" to "JNE", + "sicepat" to "SiCepat" ) // In-memory frequency-ranked dictionary @@ -82,7 +121,12 @@ class WordSuggestionEngine @Inject constructor() { "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" + "help", "home", "call", "start", "stop", "test", "ready", "super", "cool", "fine", + // Core Indonesian Fallback + "yang", "dan", "di", "ini", "itu", "dengan", "untuk", "dari", "tidak", "ada", + "akan", "bisa", "sudah", "saya", "kamu", "kita", "mereka", "dia", "kami", "tapi", + "karena", "juga", "hanya", "semua", "banyak", "lagi", "bila", "jika", "kalau", "saat", + "pesanan", "barang", "paket", "produk", "ongkir", "resi", "rekening", "transfer", "lunas" ) for (w in fallbackWords) { addWordInternal(w) @@ -90,27 +134,34 @@ class WordSuggestionEngine @Inject constructor() { } /** - * Loads extended dictionary from assets/text/dictionary_en.txt + * Loads extended bilingual dictionary from assets (dictionary_en.txt and dictionary_id.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) + val assetManager = context.assets + val dictionaryFiles = listOf("text/dictionary_en.txt", "text/dictionary_id.txt") + val batch = ArrayList() + + for (fileName in dictionaryFiles) { + try { + val inputStream = assetManager.open(fileName) + BufferedReader(InputStreamReader(inputStream)).use { reader -> + var line: String? = reader.readLine() + while (line != null) { + val word = line.trim().lowercase() + if (word.isNotEmpty() && !dictionarySet.contains(word)) { + dictionarySet.add(word) + batch.add(word) + } + line = reader.readLine() } - line = reader.readLine() } + } catch (_: Exception) { + // Gracefully continue to next file } - isDictionaryLoaded = true - } catch (_: Exception) { - // Gracefully keep fallback vocabulary if asset read fails } + dictionaryList.addAll(batch) + isDictionaryLoaded = true } private fun addWordInternal(word: String) { @@ -285,10 +336,49 @@ class WordSuggestionEngine @Inject constructor() { source.first().isUpperCase() -> { candidate.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } + candidate.any { it.isUpperCase() } -> { + candidate + } else -> { candidate.lowercase() } } } + /** + * Evaluates whether [input] should be auto-corrected when spacebar is pressed. + * Returns the replacement word if: + * 1. A direct typo or shorthand substitution exists (e.g., "teh" -> "the", "yg" -> "yang"). + * 2. The word is not in dictionary and a high-confidence correction is available. + * Returns null if the word is already valid or no auto-correction is warranted. + */ + fun getAutoCorrectReplacement(input: String): String? { + val trimmed = input.trim() + if (trimmed.length < 2) return null + val lower = trimmed.lowercase() + + // 1. Direct typo or shorthand substitution (highest confidence) + val typoMatch = commonTypoMap[lower] + if (typoMatch != null) { + return matchCasing(trimmed, typoMatch) + } + + // 2. If the word is already recognized in dictionary, do not auto-correct + if (dictionarySet.contains(lower)) { + return null + } + + // 3. For unrecognized words, look for close correction (Levenshtein distance <= 1 or <= 2) + val suggestion = findClosestCorrection(lower) + if (suggestion != null && !suggestion.equals(lower, ignoreCase = true)) { + val dist = calculateLevenshteinDistance(lower, suggestion) + if (trimmed.length <= 3 && dist == 1) { + return matchCasing(trimmed, suggestion) + } else if (trimmed.length > 3 && dist <= 2 && suggestion.first() == lower.first()) { + return matchCasing(trimmed, suggestion) + } + } + return null + } + } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorScreen.kt index 508586b..de0e38a 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextEditorScreen.kt @@ -15,7 +15,6 @@ 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 diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextScreen.kt index f171d83..1f66d04 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/autotext/AutoTextScreen.kt @@ -22,7 +22,6 @@ 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 diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextCategory.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextCategory.kt new file mode 100644 index 0000000..0042460 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextCategory.kt @@ -0,0 +1,42 @@ +package com.frogobox.appkeyboard.ui.keyboard.autotext + +import com.frogobox.appkeyboard.model.KeyboardFeatureType + +/** + * Sub-menu categories for consolidated AutoText keyboard panel. + * Combines user custom snippets with built-in template categories. + */ +enum class AutoTextCategory(val id: String, val title: String, val icon: String) { + MY_CUSTOM("cat_my_custom", "My Custom", "โœ๏ธ"), + GAME("cat_game", "Game", "๐ŸŽฎ"), + APP("cat_app", "App", "๐Ÿ“ฑ"), + SALE("cat_sale", "Sale", "๐Ÿ’ฐ"), + GREETING("cat_greeting", "Greeting", "๐Ÿ‘‹"), + LOVE("cat_love", "Love", "โค๏ธ"); + + val isCustom: Boolean + get() = this == MY_CUSTOM + + val templateFeatureType: KeyboardFeatureType? + get() = when (this) { + GAME -> KeyboardFeatureType.TEMPLATE_TEXT_GAME + APP -> KeyboardFeatureType.TEMPLATE_TEXT_APP + SALE -> KeyboardFeatureType.TEMPLATE_TEXT_SALE + GREETING -> KeyboardFeatureType.TEMPLATE_TEXT_GREETING + LOVE -> KeyboardFeatureType.TEMPLATE_TEXT_LOVE + MY_CUSTOM -> null + } + + companion object { + fun fromFeatureType(featureType: KeyboardFeatureType): AutoTextCategory { + return when (featureType) { + KeyboardFeatureType.TEMPLATE_TEXT_GAME -> GAME + KeyboardFeatureType.TEMPLATE_TEXT_APP -> APP + KeyboardFeatureType.TEMPLATE_TEXT_SALE -> SALE + KeyboardFeatureType.TEMPLATE_TEXT_GREETING -> GREETING + KeyboardFeatureType.TEMPLATE_TEXT_LOVE -> LOVE + else -> MY_CUSTOM + } + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardScreen.kt index cb32df5..e95aeb2 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardScreen.kt @@ -1,5 +1,7 @@ package com.frogobox.appkeyboard.ui.keyboard.autotext +import android.content.Intent +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -13,25 +15,40 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.frogobox.appkeyboard.model.AutoTextEntity +import com.frogobox.appkeyboard.model.KeyboardFeatureType +import com.frogobox.appkeyboard.model.TemplateText import com.frogobox.appkeyboard.ui.keyboard.common.KeyboardFeatureToolbar +import com.frogobox.appkeyboard.ui.keyboard.templatetext.TemplateTextUtils +import com.frogobox.appkeyboard.ui.templatetext.TemplateTextActivity +import com.frogobox.appkeyboard.ui.theme.compose.FrogoEmptyView @Composable fun AutoTextKeyboardScreen( @@ -39,8 +56,52 @@ fun AutoTextKeyboardScreen( onCommitText: (String) -> Unit, onBackClick: () -> Unit, onManageClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + initialCategory: AutoTextCategory = AutoTextCategory.MY_CUSTOM, + isAlwaysShow: Boolean = false, + onToggleAlwaysShow: (() -> Unit)? = null ) { + val context = LocalContext.current + var selectedCategory by remember(initialCategory) { mutableStateOf(initialCategory) } + + val currentSubtitle = when (selectedCategory) { + AutoTextCategory.MY_CUSTOM -> "Tap any snippet to paste into chat" + AutoTextCategory.GAME -> "Tap any game review template to paste into chat" + AutoTextCategory.APP -> "Tap any app review template to paste into chat" + AutoTextCategory.SALE -> "Tap any sale admin template to paste into chat" + AutoTextCategory.GREETING -> "Tap any greeting template to paste into chat" + AutoTextCategory.LOVE -> "Tap any love template to paste into chat" + } + + var templateList by remember(selectedCategory) { + mutableStateOf( + when (selectedCategory) { + AutoTextCategory.GAME -> TemplateTextUtils.getTextGame(context) + AutoTextCategory.APP -> TemplateTextUtils.getTextApp(context) + AutoTextCategory.SALE -> TemplateTextUtils.getTextSale(context) + AutoTextCategory.GREETING -> TemplateTextUtils.getTextGreeting(context) + AutoTextCategory.LOVE -> TemplateTextUtils.getTextLove(context) + AutoTextCategory.MY_CUSTOM -> emptyList() + } + ) + } + + LaunchedEffect(selectedCategory) { + if (selectedCategory != AutoTextCategory.MY_CUSTOM) { + val featureType = when (selectedCategory) { + AutoTextCategory.GAME -> KeyboardFeatureType.TEMPLATE_TEXT_GAME + AutoTextCategory.APP -> KeyboardFeatureType.TEMPLATE_TEXT_APP + AutoTextCategory.SALE -> KeyboardFeatureType.TEMPLATE_TEXT_SALE + AutoTextCategory.GREETING -> KeyboardFeatureType.TEMPLATE_TEXT_GREETING + AutoTextCategory.LOVE -> KeyboardFeatureType.TEMPLATE_TEXT_LOVE + AutoTextCategory.MY_CUSTOM -> null + } + if (featureType != null) { + templateList = TemplateTextUtils.getTemplatesForTypeSuspend(context, featureType) + } + } + } + Column( modifier = modifier .fillMaxSize() @@ -48,15 +109,26 @@ fun AutoTextKeyboardScreen( ) { KeyboardFeatureToolbar( title = "Auto Text", - subtitle = "Tap to insert shortcut into chat", + subtitle = currentSubtitle, onBackClick = onBackClick, + isAlwaysShow = isAlwaysShow, + onToggleAlwaysShow = onToggleAlwaysShow, action = { Box( modifier = Modifier .size(34.dp) .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .clickable { onManageClick() } + .clickable { + if (selectedCategory == AutoTextCategory.MY_CUSTOM) { + onManageClick() + } else { + context.startActivity(Intent(context, TemplateTextActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(TemplateTextActivity.EXTRA_INITIAL_CATEGORY, selectedCategory.name) + }) + } + } .padding(6.dp), contentAlignment = Alignment.Center ) { @@ -70,18 +142,110 @@ fun AutoTextKeyboardScreen( } ) - if (autoTextList.isEmpty()) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - contentAlignment = Alignment.Center - ) { - com.frogobox.appkeyboard.ui.theme.compose.FrogoEmptyView( - title = "No Auto Text Found", - subtitle = "Create reusable snippets to save time while typing" + // Sub-menu horizontal chip row + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + contentPadding = PaddingValues(horizontal = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + items(AutoTextCategory.entries) { category -> + val isSelected = category == selectedCategory + FilterChip( + selected = isSelected, + onClick = { selectedCategory = category }, + label = { + Text( + text = "${category.icon} ${category.title}", + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + }, + shape = RoundedCornerShape(8.dp), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = isSelected, + borderColor = MaterialTheme.colorScheme.outlineVariant, + selectedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + ) ) } + } + + HorizontalDivider( + thickness = 0.8.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + ) + + // Content Area + if (selectedCategory == AutoTextCategory.MY_CUSTOM) { + if (autoTextList.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + FrogoEmptyView( + title = "No Auto Text Found", + subtitle = "Create reusable snippets to save time while typing" + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + items( + items = autoTextList, + key = { it.id } + ) { item -> + Card( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable { onCommitText(item.body) }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + ), + shape = RoundedCornerShape(10.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = item.title, + style = MaterialTheme.typography.titleSmall.copy( + fontSize = 13.5.sp, + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = item.body, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.5.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } } else { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -89,45 +253,28 @@ fun AutoTextKeyboardScreen( verticalArrangement = Arrangement.spacedBy(6.dp) ) { items( - items = autoTextList, - key = { it.id } - ) { item -> + items = templateList, + key = { "${selectedCategory.id}_${it.id}" } + ) { template -> Card( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .clickable { onCommitText(item.body) }, + .clip(RoundedCornerShape(8.dp)) + .clickable { onCommitText(template.text) }, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) ), - shape = RoundedCornerShape(10.dp) + shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - ) { - Text( - text = item.title, - style = MaterialTheme.typography.titleSmall.copy( - fontSize = 13.5.sp, - fontWeight = FontWeight.Bold - ), - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = item.body, - style = MaterialTheme.typography.bodySmall.copy( - fontSize = 11.5.sp - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } + Text( + text = template.text, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 12.5.sp + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp) + ) } } } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/autotext/AutoTextKeyboardViewModel.kt index c9c6758..6f1856e 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 @@ -18,18 +18,18 @@ import kotlinx.coroutines.withContext class AutoTextKeyboardViewModel(val context: Context) { - private fun getRepository(): AutoTextRepository { - return AutoTextRepositoryImpl(AppDatabase.newInstance(context).autoTextDao()) + private val repository: AutoTextRepository by lazy { + AutoTextRepositoryImpl(AppDatabase.newInstance(context).autoTextDao()) } fun getAutoTextFlow(): Flow> { - return getRepository().getAutoText() + return repository.getAutoText() } fun getAutoText(onSuccessData: (List) -> Unit) { CoroutineScope(Dispatchers.IO).launch { try { - getRepository().getAutoText().collect { data -> + repository.getAutoText().collect { data -> withContext(Dispatchers.Main) { onSuccessData(data) } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/clipboard/ClipboardKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/clipboard/ClipboardKeyboardScreen.kt new file mode 100644 index 0000000..eb8a759 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/clipboard/ClipboardKeyboardScreen.kt @@ -0,0 +1,337 @@ +package com.frogobox.appkeyboard.ui.keyboard.clipboard + +import androidx.compose.foundation.BorderStroke +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.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.R +import com.frogobox.appkeyboard.model.ClipboardItem +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun ClipboardKeyboardScreen( + items: List, + onCommitText: (String) -> Unit, + onTogglePin: (String) -> Unit, + onDeleteClip: (String) -> Unit, + onClearHistory: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + isAlwaysShow: Boolean = false, + onToggleAlwaysShow: (() -> Unit)? = null +) { + Surface( + modifier = modifier + .fillMaxWidth() + .height(270.dp), + color = MaterialTheme.colorScheme.surface + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Header Bar + Row( + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onBackClick, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Kembali ke Keyboard", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + Text( + text = "Papan Klip", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + + if (onToggleAlwaysShow != null) { + com.frogobox.appkeyboard.ui.keyboard.common.PinFeatureButton( + isPinned = isAlwaysShow, + onTogglePin = onToggleAlwaysShow + ) + Spacer(modifier = Modifier.width(6.dp)) + } + + if (items.any { !it.isPinned }) { + Surface( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClearHistory), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Hapus Riwayat", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(13.dp) + ) + Text( + text = "Hapus Riwayat", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.error + ) + } + } + Spacer(modifier = Modifier.width(6.dp)) + } + } + + HorizontalDivider( + thickness = 0.8.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) + ) + + // Content Area + if (items.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_menu_clipboard), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + modifier = Modifier.size(36.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Belum Ada Teks di Papan Klip", + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Teks yang Anda salin dari aplikasi mana pun akan muncul di sini untuk ditempel secara instan.", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp) + ) + } + } + } else { + val pinnedList = remember(items) { items.filter { it.isPinned } } + val unpinnedList = remember(items) { items.filterNot { it.isPinned } } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (pinnedList.isNotEmpty()) { + item(key = "header_pinned") { + Text( + text = "DISEMATKAN", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFFD97706), + letterSpacing = 0.8.sp, + modifier = Modifier.padding(start = 4.dp, top = 2.dp, bottom = 2.dp) + ) + } + + items(pinnedList, key = { it.id }) { item -> + ClipboardCard( + item = item, + onClick = { onCommitText(item.text) }, + onTogglePin = { onTogglePin(item.id) }, + onDelete = { onDeleteClip(item.id) } + ) + } + } + + if (unpinnedList.isNotEmpty()) { + item(key = "header_recent") { + Text( + text = "TERBARU", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + letterSpacing = 0.8.sp, + modifier = Modifier.padding(start = 4.dp, top = 4.dp, bottom = 2.dp) + ) + } + + items(unpinnedList, key = { it.id }) { item -> + ClipboardCard( + item = item, + onClick = { onCommitText(item.text) }, + onTogglePin = { onTogglePin(item.id) }, + onDelete = { onDeleteClip(item.id) } + ) + } + } + } + } + } + } +} + +@Composable +private fun ClipboardCard( + item: ClipboardItem, + onClick: () -> Unit, + onTogglePin: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier +) { + val timeFormatted = remember(item.timestamp) { + val now = System.currentTimeMillis() + val diff = now - item.timestamp + when { + diff < 60_000 -> "Baru saja" + diff < 3600_000 -> "${diff / 60_000} mnt lalu" + diff < 86400_000 -> "${diff / 3600_000} jam lalu" + else -> SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()).format(Date(item.timestamp)) + } + } + + Card( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors( + containerColor = if (item.isPinned) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + ), + border = if (item.isPinned) { + BorderStroke(1.dp, Color(0xFFD97706).copy(alpha = 0.45f)) + } else { + BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 8.dp) + ) { + Text( + text = item.text, + fontSize = 12.5.sp, + lineHeight = 16.5.sp, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = timeFormatted, + fontSize = 9.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.weight(1f)) + + // Pin / Unpin button + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .clickable(onClick = onTogglePin), + contentAlignment = Alignment.Center + ) { + Text( + text = if (item.isPinned) "๐Ÿ“Œ" else "๐Ÿ“", + fontSize = 12.sp + ) + } + + Spacer(modifier = Modifier.width(6.dp)) + + // Delete single clip button + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .clickable(onClick = onDelete), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Hapus Klip", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(13.dp) + ) + } + } + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/common/AsyncGlideImage.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/common/AsyncGlideImage.kt index fde8485..3ee95a8 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/common/AsyncGlideImage.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/common/AsyncGlideImage.kt @@ -30,13 +30,15 @@ fun AsyncGlideImage( url: String?, contentDescription: String?, modifier: Modifier = Modifier, - contentScale: ContentScale = ContentScale.Crop + contentScale: ContentScale = ContentScale.Crop, + overrideWidth: Int = 320, + overrideHeight: Int = 320 ) { val context = LocalContext.current var bitmap by remember(url) { mutableStateOf(null) } var isLoading by remember(url) { mutableStateOf(!url.isNullOrBlank()) } - DisposableEffect(url) { + DisposableEffect(url, overrideWidth, overrideHeight) { if (url.isNullOrBlank()) { isLoading = false return@DisposableEffect onDispose {} @@ -61,6 +63,8 @@ fun AsyncGlideImage( Glide.with(context) .asBitmap() .load(url) + .override(overrideWidth, overrideHeight) + .centerCrop() .into(target) onDispose { diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/common/KeyboardFeatureToolbar.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/common/KeyboardFeatureToolbar.kt index 075e00b..6b2b3eb 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/common/KeyboardFeatureToolbar.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/common/KeyboardFeatureToolbar.kt @@ -1,7 +1,9 @@ package com.frogobox.appkeyboard.ui.keyboard.common +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -17,15 +19,18 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.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.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.R @Composable fun KeyboardFeatureToolbar( @@ -33,6 +38,8 @@ fun KeyboardFeatureToolbar( onBackClick: () -> Unit, modifier: Modifier = Modifier, subtitle: String? = null, + isAlwaysShow: Boolean = false, + onToggleAlwaysShow: (() -> Unit)? = null, action: (@Composable () -> Unit)? = null ) { Column(modifier = modifier.fillMaxWidth()) { @@ -91,8 +98,15 @@ fun KeyboardFeatureToolbar( } } + if (onToggleAlwaysShow != null) { + PinFeatureButton( + isPinned = isAlwaysShow, + onTogglePin = onToggleAlwaysShow + ) + Spacer(modifier = Modifier.width(6.dp)) + } + if (action != null) { - Spacer(modifier = Modifier.width(8.dp)) action() } } @@ -103,3 +117,47 @@ fun KeyboardFeatureToolbar( ) } } + +@Composable +fun PinFeatureButton( + isPinned: Boolean, + onTogglePin: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier + .height(32.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onTogglePin), + shape = RoundedCornerShape(8.dp), + color = if (isPinned) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + }, + border = if (isPinned) { + BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)) + } else null + ) { + Row( + modifier = Modifier + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_feature_pin), + contentDescription = if (isPinned) "Nonaktifkan Buka Otomatis" else "Aktifkan Buka Otomatis", + tint = if (isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = if (isPinned) "Default" else "Pin", + fontSize = 11.sp, + fontWeight = if (isPinned) FontWeight.Bold else FontWeight.Medium, + color = if (isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/form/FormKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/form/FormKeyboardScreen.kt index c45447a..7ce492b 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/form/FormKeyboardScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/form/FormKeyboardScreen.kt @@ -2,6 +2,8 @@ package com.frogobox.appkeyboard.ui.keyboard.form import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -12,44 +14,131 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Clear import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.frogobox.appkeyboard.ui.keyboard.common.KeyboardFeatureToolbar +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard + +/** + * Structured form fields supported by [FormKeyboardScreen]. + */ +enum class FormField { + SUBJECT, + DETAILS, + REF_NUMBER +} @Composable fun FormKeyboardScreen( onCommitText: (String) -> Unit, onBackClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onRegisterKeyHandler: (((Int, Boolean) -> Boolean)?) -> Unit = {} ) { var subject by remember { mutableStateOf("") } var details by remember { mutableStateOf("") } var refNumber by remember { mutableStateOf("") } + var activeField by remember { mutableStateOf(FormField.SUBJECT) } - val focusManager = LocalFocusManager.current val scrollState = rememberScrollState() + val handleKeyPress: (Int, Boolean) -> Boolean = { code, isShifted -> + when (code) { + ItemMainKeyboard.KEYCODE_DELETE -> { + when (activeField) { + FormField.SUBJECT -> if (subject.isNotEmpty()) subject = subject.dropLast(1) + FormField.DETAILS -> if (details.isNotEmpty()) details = details.dropLast(1) + FormField.REF_NUMBER -> if (refNumber.isNotEmpty()) refNumber = refNumber.dropLast(1) + } + true + } + ItemMainKeyboard.KEYCODE_ENTER -> { + when (activeField) { + FormField.SUBJECT -> activeField = FormField.DETAILS + FormField.DETAILS -> { + details += "\n" + } + FormField.REF_NUMBER -> { + val sb = StringBuilder() + if (subject.isNotBlank()) sb.append("Subject: ${subject.trim()}\n") + if (details.isNotBlank()) sb.append("Details: ${details.trim()}\n") + if (refNumber.isNotBlank()) sb.append("Ref/No: ${refNumber.trim()}\n") + + if (sb.isNotEmpty()) { + onCommitText(sb.toString()) + onBackClick() + } + } + } + true + } + ItemMainKeyboard.KEYCODE_TAB -> { + activeField = when (activeField) { + FormField.SUBJECT -> FormField.DETAILS + FormField.DETAILS -> FormField.REF_NUMBER + FormField.REF_NUMBER -> FormField.SUBJECT + } + true + } + ItemMainKeyboard.KEYCODE_SPACE -> { + when (activeField) { + FormField.SUBJECT -> subject += " " + FormField.DETAILS -> details += " " + FormField.REF_NUMBER -> refNumber += " " + } + true + } + ItemMainKeyboard.KEYCODE_SHIFT, + ItemMainKeyboard.KEYCODE_MODE_CHANGE, + ItemMainKeyboard.KEYCODE_EMOJI -> { + false + } + else -> { + if (code > 0) { + var ch = code.toChar() + if (ch.isLetter() && isShifted) { + ch = ch.uppercaseChar() + } + when (activeField) { + FormField.SUBJECT -> subject += ch + FormField.DETAILS -> details += ch + FormField.REF_NUMBER -> refNumber += ch + } + true + } else { + false + } + } + } + } + + DisposableEffect(handleKeyPress) { + onRegisterKeyHandler(handleKeyPress) + onDispose { + onRegisterKeyHandler(null) + } + } + Column( modifier = modifier .fillMaxSize() @@ -57,7 +146,7 @@ fun FormKeyboardScreen( ) { KeyboardFeatureToolbar( title = "Quick Form Snippet", - subtitle = "Fill and insert structured message", + subtitle = "Ketik via keyboard lalu klik Insert to Chat", onBackClick = onBackClick ) @@ -65,95 +154,70 @@ fun FormKeyboardScreen( modifier = Modifier .fillMaxSize() .verticalScroll(scrollState) - .padding(horizontal = 12.dp, vertical = 8.dp) + .padding(horizontal = 10.dp, vertical = 6.dp) ) { - // Subject Field - OutlinedTextField( + // 1. Subject Field Box + FormFieldItem( + label = "Title / Subject", value = subject, - onValueChange = { subject = it }, - label = { Text("Title / Subject", fontSize = 12.sp) }, - singleLine = true, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Next - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Down) } - ), - shape = RoundedCornerShape(8.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, - focusedLabelColor = MaterialTheme.colorScheme.primary - ), + placeholder = "Tap to type title / subject", + isActive = activeField == FormField.SUBJECT, + onClick = { activeField = FormField.SUBJECT }, modifier = Modifier.fillMaxWidth() ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(6.dp)) - // Details Field - OutlinedTextField( + // 2. Details Field Box + FormFieldItem( + label = "Details / Description", value = details, - onValueChange = { details = it }, - label = { Text("Details / Full Description", fontSize = 12.sp) }, - maxLines = 3, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Next - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Down) } - ), - shape = RoundedCornerShape(8.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, - focusedLabelColor = MaterialTheme.colorScheme.primary - ), + placeholder = "Tap to type full details", + isActive = activeField == FormField.DETAILS, + minHeight = 56.dp, + onClick = { activeField = FormField.DETAILS }, modifier = Modifier.fillMaxWidth() ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(6.dp)) - // Ref Number Field - OutlinedTextField( + // 3. Ref Number Field Box + FormFieldItem( + label = "Number / Amount / Code", value = refNumber, - onValueChange = { refNumber = it }, - label = { Text("Number / Amount / Code", fontSize = 12.sp) }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { focusManager.clearFocus() } - ), - shape = RoundedCornerShape(8.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, - focusedLabelColor = MaterialTheme.colorScheme.primary - ), + placeholder = "Tap to type number or code", + isActive = activeField == FormField.REF_NUMBER, + onClick = { activeField = FormField.REF_NUMBER }, modifier = Modifier.fillMaxWidth() ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(10.dp)) - // Action Buttons Row + // 4. Action Buttons Row Row( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { OutlinedButton( onClick = { subject = "" details = "" refNumber = "" + activeField = FormField.SUBJECT }, modifier = Modifier .weight(1f) - .height(44.dp), + .height(38.dp), shape = RoundedCornerShape(8.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { - Text("Clear", fontSize = 12.sp) + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear", + modifier = Modifier.height(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Clear", fontSize = 11.5.sp) } Spacer(modifier = Modifier.width(8.dp)) @@ -167,25 +231,110 @@ fun FormKeyboardScreen( if (sb.isNotEmpty()) { onCommitText(sb.toString()) + onBackClick() } }, modifier = Modifier .weight(2f) - .height(44.dp), + .height(38.dp), shape = RoundedCornerShape(8.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary ) ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Insert", + modifier = Modifier.height(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) Text( "Insert to Chat", - fontSize = 12.sp, - fontWeight = FontWeight.Bold + fontSize = 11.5.sp, + fontWeight = FontWeight.SemiBold ) } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@Composable +private fun FormFieldItem( + label: String, + value: String, + placeholder: String, + isActive: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + minHeight: androidx.compose.ui.unit.Dp = 42.dp +) { + val borderColor = if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + } + + val borderWidth = if (isActive) 1.5.dp else 1.dp + + val containerColor = if (isActive) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.05f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f) + } + + Column( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(containerColor) + .clickable { onClick() } + .padding(horizontal = 10.dp, vertical = 6.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + fontSize = 10.5.sp, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Medium, + color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + if (isActive) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "[Aktif Mengetik]", + fontSize = 9.5.sp, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.primary + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(minHeight), + contentAlignment = Alignment.CenterStart + ) { + if (value.isEmpty()) { + Text( + text = placeholder, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } else { + Text( + text = if (isActive) "$value|" else value, + fontSize = 12.5.sp, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface + ) + } } } } 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 54d65d7..18dae98 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 @@ -9,11 +9,11 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import com.frogobox.api.movie.ConsumeMovieApi +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme import com.frogobox.coresdk.response.FrogoDataResponse import com.frogobox.coreutil.movie.MovieUrl import com.frogobox.coreutil.movie.model.TrendingMovie import com.frogobox.coreutil.movie.response.Trending -import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme import kotlinx.coroutines.flow.MutableStateFlow /** diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/movie/MovieKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/movie/MovieKeyboardScreen.kt index 18498db..4a8d80e 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/movie/MovieKeyboardScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/movie/MovieKeyboardScreen.kt @@ -1,6 +1,5 @@ package com.frogobox.appkeyboard.ui.keyboard.movie -import java.util.Locale import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -19,6 +18,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star @@ -38,10 +38,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.frogobox.coreutil.movie.MovieUrl -import com.frogobox.coreutil.movie.model.TrendingMovie import com.frogobox.appkeyboard.ui.keyboard.common.AsyncGlideImage import com.frogobox.appkeyboard.ui.keyboard.common.KeyboardFeatureToolbar +import com.frogobox.coreutil.movie.MovieUrl +import com.frogobox.coreutil.movie.model.TrendingMovie +import java.util.Locale @Composable fun MovieKeyboardScreen( @@ -49,7 +50,9 @@ fun MovieKeyboardScreen( isLoading: Boolean, onCommitText: (String) -> Unit, onBackClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + isAlwaysShow: Boolean = false, + onToggleAlwaysShow: (() -> Unit)? = null ) { Column( modifier = modifier @@ -58,8 +61,10 @@ fun MovieKeyboardScreen( ) { KeyboardFeatureToolbar( title = "Trending Movies", - subtitle = "Tap to share movie recommendation", - onBackClick = onBackClick + subtitle = "Tap to share movie synopsis & rating", + onBackClick = onBackClick, + isAlwaysShow = isAlwaysShow, + onToggleAlwaysShow = onToggleAlwaysShow ) if (isLoading) { @@ -82,10 +87,10 @@ fun MovieKeyboardScreen( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - items( + itemsIndexed( items = movieList, - key = { it.id ?: it.hashCode() } - ) { movie -> + key = { index, movie -> "${movie.id ?: movie.title ?: "movie"}_$index" } + ) { _, movie -> val posterUrl = "${MovieUrl.BASE_URL_IMAGE_ORIGINAL}${movie.poster_path ?: ""}" Card( diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/news/NewsKeyboard.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/news/NewsKeyboard.kt index c563c42..228ba71 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/news/NewsKeyboard.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/news/NewsKeyboard.kt @@ -9,13 +9,13 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import com.frogobox.api.news.ConsumeNewsApi +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme import com.frogobox.coreapi.ConsumeApiResponse import com.frogobox.coreutil.news.NewsConstant.CATEGORY_HEALTH import com.frogobox.coreutil.news.NewsConstant.COUNTRY_ID import com.frogobox.coreutil.news.NewsUrl import com.frogobox.coreutil.news.model.Article import com.frogobox.coreutil.news.response.ArticleResponse -import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme import kotlinx.coroutines.flow.MutableStateFlow /** diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/news/NewsKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/news/NewsKeyboardScreen.kt index bdfec54..67d18ff 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/news/NewsKeyboardScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/news/NewsKeyboardScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -32,9 +33,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.frogobox.coreutil.news.model.Article import com.frogobox.appkeyboard.ui.keyboard.common.AsyncGlideImage import com.frogobox.appkeyboard.ui.keyboard.common.KeyboardFeatureToolbar +import com.frogobox.coreutil.news.model.Article @Composable fun NewsKeyboardScreen( @@ -42,7 +43,9 @@ fun NewsKeyboardScreen( isLoading: Boolean, onCommitText: (String) -> Unit, onBackClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + isAlwaysShow: Boolean = false, + onToggleAlwaysShow: (() -> Unit)? = null ) { Column( modifier = modifier @@ -52,7 +55,9 @@ fun NewsKeyboardScreen( KeyboardFeatureToolbar( title = "Top Headlines", subtitle = "Tap to share article & source link", - onBackClick = onBackClick + onBackClick = onBackClick, + isAlwaysShow = isAlwaysShow, + onToggleAlwaysShow = onToggleAlwaysShow ) if (isLoading) { @@ -73,10 +78,10 @@ fun NewsKeyboardScreen( contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - items( + itemsIndexed( items = articles, - key = { it.url ?: it.title ?: "" } - ) { article -> + key = { index, article -> "${article.url ?: article.title ?: "news"}_$index" } + ) { _, article -> Card( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/productremote/ProductRemoteKeyboard.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/productremote/ProductRemoteKeyboard.kt new file mode 100644 index 0000000..a60c325 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/productremote/ProductRemoteKeyboard.kt @@ -0,0 +1,115 @@ +package com.frogobox.appkeyboard.ui.keyboard.productremote + +import android.content.Context +import android.util.AttributeSet +import android.view.inputmethod.InputConnection +import android.widget.FrameLayout +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.frogobox.appkeyboard.common.core.Resource +import com.frogobox.appkeyboard.data.remote.ApiService +import com.frogobox.appkeyboard.data.remote.model.DataItemResponse +import com.frogobox.appkeyboard.di.NetworkModule +import com.frogobox.appkeyboard.repository.data.DataApiRepository +import com.frogobox.appkeyboard.repository.data.DataApiRepositoryImpl +import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +/** + * Standalone FrameLayout custom view for Product Remote Keyboard panel. + * Fetches from DataApiRepository / DataApiService (http://192.168.100.6:7272/api/data.json), + * embeds ProductRemoteKeyboardScreen via ComposeView with FrogoKeyboardTheme. + */ +class ProductRemoteKeyboard @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + private val repository: DataApiRepository = DataApiRepositoryImpl( + ApiService.create(context, NetworkModule.BASE_URL) + ) +) : FrameLayout(context, attrs, defStyleAttr) { + + private val itemsState = MutableStateFlow>(emptyList()) + private val isLoadingState = MutableStateFlow(false) + private val errorMessageState = MutableStateFlow(null) + + private val viewJob = SupervisorJob() + private val viewScope = CoroutineScope(Dispatchers.Main + viewJob) + + var currentInputConnection: InputConnection? = null + var onBackClick: (() -> Unit)? = null + + private val composeView = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool) + setContent { + FrogoKeyboardTheme { + val items by itemsState.collectAsState() + val isLoading by isLoadingState.collectAsState() + val errorMessage by errorMessageState.collectAsState() + + ProductRemoteKeyboardScreen( + items = items, + isLoading = isLoading, + errorMessage = errorMessage, + onCommitText = { text -> + currentInputConnection?.commitText(text, 1) + }, + onBackClick = { + onBackClick?.invoke() + }, + onRefresh = { + fetchData() + } + ) + } + } + } + + init { + addView(composeView) + fetchData() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + viewJob.cancelChildren() + } + + fun setInputConnection(inputConnection: InputConnection?) { + currentInputConnection = inputConnection + } + + fun setOnBackClickListener(listener: () -> Unit) { + onBackClick = listener + } + + fun fetchData() { + viewScope.launch { + repository.fetchDataStream().collect { resource -> + when (resource) { + is Resource.Loading -> { + isLoadingState.value = true + } + is Resource.Success -> { + isLoadingState.value = false + errorMessageState.value = null + val activeItems = resource.data.items.orEmpty() + itemsState.value = activeItems + } + is Resource.Error -> { + isLoadingState.value = false + errorMessageState.value = resource.message + } + } + } + } + } + +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/productremote/ProductRemoteKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/productremote/ProductRemoteKeyboardScreen.kt new file mode 100644 index 0000000..8d0f8a7 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/productremote/ProductRemoteKeyboardScreen.kt @@ -0,0 +1,938 @@ +package com.frogobox.appkeyboard.ui.keyboard.productremote + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +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.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.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed +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.filled.Close +import androidx.compose.material.icons.filled.Inventory2 +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +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.layout.ContentScale +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.data.remote.model.DataItemResponse +import com.frogobox.appkeyboard.ui.keyboard.common.AsyncGlideImage +import com.frogobox.appkeyboard.ui.keyboard.common.KeyboardFeatureToolbar +import com.frogobox.appkeyboard.ui.theme.compose.FrogoEmptyView +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusFailed +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard + +/** + * Modern Jetpack Compose screen for Product Remote keyboard panel. + * Complies with Material 3, Anti-Slop principles, and 540.dp (2x enlarged) IME height constraints. + */ +@Composable +fun ProductRemoteKeyboardScreen( + items: List, + isLoading: Boolean, + errorMessage: String?, + onCommitText: (String) -> Unit, + onBackClick: () -> Unit, + onRefresh: () -> Unit, + onManageClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, + isAlwaysShow: Boolean = false, + onToggleAlwaysShow: (() -> Unit)? = null, + isSearchActive: Boolean = false, + onSearchActiveChange: (Boolean) -> Unit = {}, + onRegisterKeyHandler: (((Int, Boolean) -> Boolean)?) -> Unit = {} +) { + var searchQuery by rememberSaveable { mutableStateOf("") } + + LaunchedEffect(isSearchActive) { + if (!isSearchActive) { + searchQuery = "" + } + } + + val handleKeyPress: (Int, Boolean) -> Boolean = { code, isShifted -> + if (!isSearchActive) { + false + } else { + when (code) { + ItemMainKeyboard.KEYCODE_DELETE -> { + if (searchQuery.isNotEmpty()) { + searchQuery = searchQuery.dropLast(1) + } + true + } + ItemMainKeyboard.KEYCODE_SPACE -> { + searchQuery += " " + true + } + ItemMainKeyboard.KEYCODE_ENTER -> { + true + } + ItemMainKeyboard.KEYCODE_SHIFT, + ItemMainKeyboard.KEYCODE_MODE_CHANGE, + ItemMainKeyboard.KEYCODE_EMOJI -> { + false + } + else -> { + if (code > 0) { + var ch = code.toChar() + if (ch.isLetter() && isShifted) { + ch = ch.uppercaseChar() + } + searchQuery += ch + true + } else { + false + } + } + } + } + } + + DisposableEffect(isSearchActive, handleKeyPress) { + if (isSearchActive) { + onRegisterKeyHandler(handleKeyPress) + } + onDispose { + onRegisterKeyHandler(null) + } + } + + val filteredItems = remember(items, searchQuery) { + if (searchQuery.isBlank()) items + else items.filter { it.matchesSearchQuery(searchQuery) } + } + + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + ) { + if (isSearchActive) { + // Search Active Header (50.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Back / Close Search Button + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .clickable { + searchQuery = "" + onSearchActiveChange(false) + } + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Tutup Pencarian", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + // Interactive Search Input Box + Surface( + modifier = Modifier + .weight(1f) + .height(36.dp), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Box(modifier = Modifier.weight(1f)) { + if (searchQuery.isEmpty()) { + Text( + text = "Ketik di keyboard QWERTY...", + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 12.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } else { + Text( + text = searchQuery, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 12.5.sp, + fontWeight = FontWeight.Medium + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + if (searchQuery.isNotEmpty()) { + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable { searchQuery = "" }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Hapus kata kunci", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp) + ) + } + } + } + } + } + } else { + // 1. Fixed Height Toolbar Header (50.dp) + KeyboardFeatureToolbar( + title = "Product Remote", + subtitle = "Katalog produk tersimpan di Room DB", + onBackClick = onBackClick, + isAlwaysShow = isAlwaysShow, + onToggleAlwaysShow = onToggleAlwaysShow, + action = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Search Button (Triggers QWERTY Keyboard from bottom) + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .clickable { onSearchActiveChange(true) } + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Cari Produk", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + if (onManageClick != null) { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .clickable { onManageClick() } + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Inventory2, + contentDescription = "Buka Katalog", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .clickable(enabled = !isLoading) { onRefresh() } + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } else { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Refresh product remote data", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + ) + } + + // 2. Animated Content Viewport (Filtered by search query if present) + Crossfade( + targetState = when { + isLoading && items.isEmpty() -> ProductKeyboardState.LOADING + errorMessage != null && items.isEmpty() -> ProductKeyboardState.ERROR + filteredItems.isEmpty() -> ProductKeyboardState.EMPTY + else -> ProductKeyboardState.SUCCESS + }, + animationSpec = tween(durationMillis = 180), + label = "ProductRemoteKeyboardCrossfade" + ) { state -> + when (state) { + ProductKeyboardState.LOADING -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp + ) + } + } + + ProductKeyboardState.ERROR -> { + ProductRemoteErrorView( + errorMessage = errorMessage ?: "Koneksi bermasalah", + onRetry = onRefresh, + modifier = Modifier.fillMaxSize() + ) + } + + ProductKeyboardState.EMPTY -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + if (searchQuery.isNotBlank() && items.isNotEmpty()) { + FrogoEmptyView( + title = "Produk Tidak Ditemukan", + subtitle = "Tidak ada produk yang cocok dengan \"$searchQuery\". Coba kata kunci lain.", + actionButtonText = "Hapus Pencarian", + onActionClick = { searchQuery = "" } + ) + } else { + FrogoEmptyView( + title = "Belum Ada Produk Tersimpan", + subtitle = "Simpan produk di katalog remote agar muncul di keyboard.", + actionButtonText = if (onManageClick != null) "Buka Katalog" else "Segarkan", + onActionClick = { onManageClick?.invoke() ?: onRefresh() } + ) + } + } + } + + ProductKeyboardState.SUCCESS -> { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed( + items = filteredItems, + key = { index, item -> "${item.id ?: "item"}_${item.displayIndex}_$index" } + ) { _, item -> + ProductRemoteCard( + item = item, + onClick = { onCommitText(item.toProductCaptionCommitText()) }, + onTitleClick = { onCommitText(item.toProductTitleCommitText()) }, + onCaptionClick = { onCommitText(item.toProductCaptionCommitText()) }, + onHookClick = { onCommitText(item.toProductHookCommitText()) }, + onLinkClick = { onCommitText(item.toProductLinkCommitText()) } + ) + } + } + } + } + } + } +} + +private enum class ProductKeyboardState { + LOADING, + ERROR, + EMPTY, + SUCCESS +} + +/** + * Tactile segmented mode selector for switching between Caption and Judul output. + */ +@Composable +fun ProductRemoteModeSelector( + selectedMode: ProductRemoteOutputMode, + onModeSelected: (ProductRemoteOutputMode) -> Unit, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier.height(34.dp), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(2.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + ProductRemoteOutputMode.entries.forEach { mode -> + val isSelected = mode == selectedMode + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(6.dp)) + .background( + if (isSelected) MaterialTheme.colorScheme.primaryContainer + else Color.Transparent + ) + .clickable { onModeSelected(mode) } + .padding(vertical = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = mode.displayName, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 11.5.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium + ), + color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +/** + * Tactile Product Remote Card inside keyboard 2-column grid. + */ +@Composable +fun ProductRemoteCard( + item: DataItemResponse, + onClick: () -> Unit, + modifier: Modifier = Modifier, + currentMode: ProductRemoteOutputMode? = null, + onTitleClick: () -> Unit = onClick, + onCaptionClick: () -> Unit = onClick, + onHookClick: () -> Unit = onClick, + onLinkClick: () -> Unit = onClick +) { + val hasThumbnail = !item.thumbnailUrl.isNullOrBlank() + + Card( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + ), + border = BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f) + ) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + if (hasThumbnail) { + // Media Thumbnail Header (9:16 Vertical Ratio) + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(9f / 16f) + ) { + AsyncGlideImage( + url = item.thumbnailUrl, + contentDescription = item.displayTitle, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + overrideWidth = 360, + overrideHeight = 640 + ) + + // Video / File Size Badge Overlay + if (item.isVideo == true) { + Surface( + shape = RoundedCornerShape(4.dp), + color = Color.Black.copy(alpha = 0.68f), + modifier = Modifier + .align(Alignment.BottomStart) + .padding(6.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = "Video product", + tint = Color.White, + modifier = Modifier.size(11.dp) + ) + if (!item.fileSize.isNullOrBlank()) { + Spacer(modifier = Modifier.width(3.dp)) + Text( + text = item.fileSize, + fontSize = 9.5.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + } + } + } + } + } + } + + // Card Details + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + // Download Status Chip (if no thumbnail) + if (!hasThumbnail && !item.statusDownload.isNullOrBlank()) { + Surface( + shape = RoundedCornerShape(4.dp), + color = if (item.isDownloaded) Color(0xFFE8F5E9) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.padding(bottom = 4.dp) + ) { + Text( + text = if (item.isDownloaded) "โœ“ Terunduh" else "Belum", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + color = if (item.isDownloaded) Color(0xFF1B5E20) else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp) + ) + } + } + + // Title + Text( + text = item.displayTitle, + style = MaterialTheme.typography.titleSmall.copy( + fontSize = 12.5.sp, + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // Hook snippet (if present) + val hookSnippet = item.hook + if (!hookSnippet.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "๐Ÿช $hookSnippet", + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 10.5.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.Medium + ), + color = MaterialTheme.colorScheme.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.height(2.dp)) + + // Body snippet + val bodyText = item.displayBody + if (!bodyText.isNullOrBlank() && bodyText != hookSnippet) { + Text( + text = bodyText, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp, + lineHeight = 14.5.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // File Name Tag Row + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.size(10.dp) + ) + Spacer(modifier = Modifier.width(3.dp)) + Text( + text = item.originalFileName ?: "File #${item.displayIndex}", + fontSize = 9.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + + // 4 Direct Quick Actions: Judul, Caption, Hook, Link (Uniform styling, no color distinction) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + // Row 1: Judul & Caption + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + border = BorderStroke( + 0.5.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ), + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(6.dp)) + .clickable(onClick = onTitleClick) + ) { + Text( + text = "Judul", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + border = BorderStroke( + 0.5.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ), + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(6.dp)) + .clickable(onClick = onCaptionClick) + ) { + Text( + text = "Caption", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + } + + // Row 2: Hook & Link + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + border = BorderStroke( + 0.5.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ), + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(6.dp)) + .clickable(onClick = onHookClick) + ) { + Text( + text = "๐Ÿช Hook", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + border = BorderStroke( + 0.5.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ), + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(6.dp)) + .clickable(onClick = onLinkClick) + ) { + Text( + text = "๐Ÿ”— Link", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + } + } + } + } + } +} + +/** + * Ergonomic Error View for Product Remote keyboard panel. + */ +@Composable +fun ProductRemoteErrorView( + errorMessage: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.padding(16.dp), + contentAlignment = Alignment.Center + ) { + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.WifiOff, + contentDescription = "Koneksi bermasalah", + tint = FrogoStatusFailed, + modifier = Modifier.size(32.dp) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Gagal Memuat Produk", + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = onRetry, + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text( + text = "Coba Lagi", + style = MaterialTheme.typography.labelLarge.copy(fontSize = 12.sp) + ) + } + } + } + } +} + +/** + * Output mode options for Product Remote keyboard snippets: + * 1. CAPTION: Commits product promotional caption / description + * 2. TITLE: Commits product title + * 3. HOOK: Commits promotional hook / headline + * 4. LINK: Commits product purchase link (Shopee, etc.) + */ +enum class ProductRemoteOutputMode(val displayName: String, val shortLabel: String) { + CAPTION("๐Ÿ“ Caption", "Caption"), + TITLE("๐Ÿท๏ธ Judul", "Judul"), + HOOK("๐Ÿช Hook", "Hook"), + LINK("๐Ÿ”— Link", "Link") +} + +/** + * Text formatting contract for inserting product caption into chat input. + * Prioritizes caption, fallback to productName, or item index. + */ +fun DataItemResponse.toProductCaptionCommitText(): String { + return caption?.takeIf { it.isNotBlank() } + ?: productName?.takeIf { it.isNotBlank() } + ?: "Item #$displayIndex" +} + +/** + * Text formatting contract for inserting product title into chat input. + * Prioritizes productName, fallback to caption, or item index. + */ +fun DataItemResponse.toProductTitleCommitText(): String { + return productName?.takeIf { it.isNotBlank() } + ?: caption?.takeIf { it.isNotBlank() } + ?: "Produk #$displayIndex" +} + +/** + * Text formatting contract for inserting product hook into chat input. + * Prioritizes hook, fallback to caption, productName, or item index. + */ +fun DataItemResponse.toProductHookCommitText(): String { + return hook?.takeIf { it.isNotBlank() } + ?: caption?.takeIf { it.isNotBlank() } + ?: productName?.takeIf { it.isNotBlank() } + ?: "Hook #$displayIndex" +} + +/** + * Text formatting contract for inserting product link into chat input. + * Prioritizes linkProduct, fallback to driveLink, or empty string. + */ +fun DataItemResponse.toProductLinkCommitText(): String { + return linkProduct?.takeIf { it.isNotBlank() } + ?: driveLink?.takeIf { it.isNotBlank() } + ?: "" +} + +/** + * Maps selected mode to the formatted commit text. + */ +fun DataItemResponse.toCommitTextByMode(mode: ProductRemoteOutputMode): String { + return when (mode) { + ProductRemoteOutputMode.CAPTION -> toProductCaptionCommitText() + ProductRemoteOutputMode.TITLE -> toProductTitleCommitText() + ProductRemoteOutputMode.HOOK -> toProductHookCommitText() + ProductRemoteOutputMode.LINK -> toProductLinkCommitText() + } +} + +/** + * Full formatting contract for inserting product snippets into chat input. + */ +fun DataItemResponse.toFormattedCommitText(): String { + val headline = displayTitle + val content = displayBody.orEmpty() + val drive = driveLink?.takeIf { it.isNotBlank() } + val file = originalFileName?.takeIf { it.isNotBlank() } + + return buildString { + append(headline) + if (content.isNotBlank() && content != headline) { + append("\n") + append(content) + } + if (!drive.isNullOrBlank()) { + append("\n\nLink: ") + append(drive) + } + if (!file.isNullOrBlank()) { + append("\nFile: ") + append(file) + } + } +} + +/** + * Case-insensitive search query matcher for Product Remote items. + * Checks product title, caption/body, hook, link, and filename. + */ +fun DataItemResponse.matchesSearchQuery(query: String): Boolean { + if (query.isBlank()) return true + val q = query.trim().lowercase() + return displayTitle.lowercase().contains(q) || + (displayBody?.lowercase()?.contains(q) == true) || + (hook?.lowercase()?.contains(q) == true) || + (linkProduct?.lowercase()?.contains(q) == true) || + (originalFileName?.lowercase()?.contains(q) == true) +} + diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardImeRootScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardImeRootScreen.kt index 37e2a9f..eee9d50 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardImeRootScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardImeRootScreen.kt @@ -1,21 +1,31 @@ package com.frogobox.appkeyboard.ui.keyboard.root import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale @@ -23,16 +33,21 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import com.frogobox.appkeyboard.data.remote.model.DataItemResponse import com.frogobox.appkeyboard.model.AutoTextEntity +import com.frogobox.appkeyboard.model.ClipboardItem import com.frogobox.appkeyboard.model.KeyboardFeatureModel -import com.frogobox.appkeyboard.model.KeyboardFeatureType import com.frogobox.appkeyboard.model.ThemeType import com.frogobox.appkeyboard.suggestion.SuggestionResult import com.frogobox.appkeyboard.ui.keyboard.autotext.AutoTextKeyboardScreen +import com.frogobox.appkeyboard.ui.keyboard.clipboard.ClipboardKeyboardScreen import com.frogobox.appkeyboard.ui.keyboard.form.FormKeyboardScreen import com.frogobox.appkeyboard.ui.keyboard.movie.MovieKeyboardScreen import com.frogobox.appkeyboard.ui.keyboard.news.NewsKeyboardScreen -import com.frogobox.appkeyboard.ui.keyboard.templatetext.TemplateTextKeyboardScreen +import com.frogobox.appkeyboard.ui.keyboard.productremote.ProductRemoteKeyboardScreen +import com.frogobox.appkeyboard.ui.keyboard.textedit.TextEditAction +import com.frogobox.appkeyboard.ui.keyboard.textedit.TextEditKeyboardScreen import com.frogobox.appkeyboard.ui.keyboard.webview.WebviewKeyboardScreen import com.frogobox.coreutil.movie.model.TrendingMovie import com.frogobox.coreutil.news.model.Article @@ -67,6 +82,11 @@ fun KeyboardImeRootScreen( onMainKeyboardInit: (MainKeyboard) -> Unit, autoTextList: List, onManageAutoText: () -> Unit, + productRemoteItems: List = emptyList(), + isProductRemoteLoading: Boolean = false, + productRemoteError: String? = null, + onRefreshProductRemote: () -> Unit = {}, + onManageProductRemote: () -> Unit = {}, newsArticles: List
, isNewsLoading: Boolean, movieList: List, @@ -79,8 +99,45 @@ fun KeyboardImeRootScreen( onEmojiClicked: (String) -> Unit, onCommitText: (String) -> Unit, onBackToMain: () -> Unit, + onRegisterKeyConsumer: (((Int, Boolean) -> Boolean)?) -> Unit = {}, + clipboardItems: List = emptyList(), + onTogglePinClipboardItem: (String) -> Unit = {}, + onDeleteClipboardItem: (String) -> Unit = {}, + onClearClipboardHistory: () -> Unit = {}, + recentClip: String? = null, + onQuickPaste: (String) -> Unit = {}, + isSelectionMode: Boolean = false, + onTextEditAction: (TextEditAction) -> Unit = {}, + alwaysShowFeatureId: String? = null, + onToggleAlwaysShowFeature: ((String) -> Unit)? = null, + onDeleteEmoji: (() -> Unit)? = null, modifier: Modifier = Modifier ) { + val context = LocalContext.current + val isDarkTheme = remember(themeType, themeBackgroundRes) { + if (themeType == ThemeType.IMAGE) { + true + } else { + try { + val colorInt = ContextCompat.getColor(context, themeBackgroundRes) + ColorUtils.calculateLuminance(colorInt) < 0.45 + } catch (_: Exception) { + false + } + } + } + val themeTextColor = remember(isDarkTheme) { + if (isDarkTheme) android.graphics.Color.WHITE else android.graphics.Color.parseColor("#0F172A") + } + + var isProductRemoteSearchActive by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(activePanelState) { + if (activePanelState != KeyboardPanelState.PRODUCT_REMOTE) { + isProductRemoteSearchActive = false + } + } + Box( modifier = modifier .fillMaxWidth() @@ -100,7 +157,6 @@ fun KeyboardImeRootScreen( .background(Color.Black.copy(alpha = 0.25f)) ) } else { - val context = LocalContext.current val backgroundColor = remember(themeBackgroundRes) { try { Color(ContextCompat.getColor(context, themeBackgroundRes)) @@ -129,8 +185,12 @@ fun KeyboardImeRootScreen( .fillMaxWidth() .height(56.dp) ) { + val isClipboardActive = features.any { it.id == com.frogobox.appkeyboard.model.KeyboardFeatureType.CLIPBOARD.id } + val shouldShowRecentClip = !recentClip.isNullOrBlank() && isClipboardActive + val showSuggestionsOrClip = (isSuggestionVisible && suggestionResult.hasSuggestions()) || shouldShowRecentClip + AnimatedContent( - targetState = isSuggestionVisible && suggestionResult.hasSuggestions(), + targetState = showSuggestionsOrClip, transitionSpec = { fadeIn( animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing) @@ -139,13 +199,15 @@ fun KeyboardImeRootScreen( ) }, label = "TopBarTransition" - ) { showSuggestions -> - if (showSuggestions) { + ) { shouldShow -> + if (shouldShow) { KeyboardSuggestionBar( result = suggestionResult, onCandidateSelected = onCandidateSelected, onSwitchMenu = onSwitchSuggestionMenu, - onClose = onCloseSuggestion + onClose = onCloseSuggestion, + recentClip = if (isClipboardActive) recentClip else null, + onQuickPaste = onQuickPaste ) } else if (features.isNotEmpty()) { KeyboardFeatureHeader( @@ -168,6 +230,9 @@ fun KeyboardImeRootScreen( MainKeyboardView( keyboard = currentKeyboard, onActionListener = onKeyboardActionListener, + textColor = themeTextColor, + actionTextColor = themeTextColor, + isDarkTheme = isDarkTheme, onInit = onMainKeyboardInit ) } @@ -180,7 +245,8 @@ fun KeyboardImeRootScreen( onCategorySelected = onSelectEmojiCategory, onEmojiClicked = onEmojiClicked, onBackClicked = onBackToMain, - isLoading = isEmojiLoading + isLoading = isEmojiLoading, + onDeleteClicked = onDeleteEmoji ?: {} ) } @@ -190,21 +256,92 @@ fun KeyboardImeRootScreen( autoTextList = autoTextList, onCommitText = onCommitText, onBackClick = onBackToMain, - onManageClick = onManageAutoText + onManageClick = onManageAutoText, + isAlwaysShow = (alwaysShowFeatureId == com.frogobox.appkeyboard.model.KeyboardFeatureType.AUTO_TEXT.id), + onToggleAlwaysShow = onToggleAlwaysShowFeature?.let { cb -> { cb(com.frogobox.appkeyboard.model.KeyboardFeatureType.AUTO_TEXT.id) } } ) } } + KeyboardPanelState.PRODUCT_REMOTE -> { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + val panelHeight = if (isProductRemoteSearchActive) 260.dp else 540.dp + val animatedHeight by animateDpAsState( + targetValue = panelHeight, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing), + label = "ProductRemoteHeight" + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(animatedHeight) + ) { + ProductRemoteKeyboardScreen( + items = productRemoteItems, + isLoading = isProductRemoteLoading, + errorMessage = productRemoteError, + onCommitText = onCommitText, + onBackClick = { + if (isProductRemoteSearchActive) { + isProductRemoteSearchActive = false + } else { + onBackToMain() + } + }, + onRefresh = onRefreshProductRemote, + onManageClick = onManageProductRemote, + isAlwaysShow = (alwaysShowFeatureId == com.frogobox.appkeyboard.model.KeyboardFeatureType.PRODUCT_REMOTE.id), + onToggleAlwaysShow = onToggleAlwaysShowFeature?.let { cb -> { cb(com.frogobox.appkeyboard.model.KeyboardFeatureType.PRODUCT_REMOTE.id) } }, + isSearchActive = isProductRemoteSearchActive, + onSearchActiveChange = { isProductRemoteSearchActive = it }, + onRegisterKeyHandler = onRegisterKeyConsumer, + modifier = Modifier.fillMaxSize() + ) + } + + AnimatedVisibility( + visible = isProductRemoteSearchActive, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing) + ) + fadeIn(animationSpec = tween(150)), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing) + ) + fadeOut(animationSpec = tween(150)) + ) { + MainKeyboardView( + keyboard = currentKeyboard, + onActionListener = onKeyboardActionListener, + textColor = themeTextColor, + actionTextColor = themeTextColor, + isDarkTheme = isDarkTheme, + onInit = onMainKeyboardInit + ) + } + } + } + KeyboardPanelState.TEMPLATE_TEXT_GAME, KeyboardPanelState.TEMPLATE_TEXT_APP, KeyboardPanelState.TEMPLATE_TEXT_SALE, KeyboardPanelState.TEMPLATE_TEXT_LOVE, KeyboardPanelState.TEMPLATE_TEXT_GREETING -> { + val category = panel.templateFeatureType?.let { + com.frogobox.appkeyboard.ui.keyboard.autotext.AutoTextCategory.fromFeatureType(it) + } ?: com.frogobox.appkeyboard.ui.keyboard.autotext.AutoTextCategory.GAME Box(modifier = Modifier.fillMaxWidth().height(270.dp)) { - TemplateTextKeyboardScreen( - initialType = panel.templateFeatureType ?: KeyboardFeatureType.TEMPLATE_TEXT_GAME, + AutoTextKeyboardScreen( + autoTextList = autoTextList, onCommitText = onCommitText, - onBackClick = onBackToMain + onBackClick = onBackToMain, + onManageClick = onManageAutoText, + initialCategory = category ) } } @@ -215,7 +352,9 @@ fun KeyboardImeRootScreen( articles = newsArticles, isLoading = isNewsLoading, onCommitText = onCommitText, - onBackClick = onBackToMain + onBackClick = onBackToMain, + isAlwaysShow = (alwaysShowFeatureId == com.frogobox.appkeyboard.model.KeyboardFeatureType.NEWS.id), + onToggleAlwaysShow = onToggleAlwaysShowFeature?.let { cb -> { cb(com.frogobox.appkeyboard.model.KeyboardFeatureType.NEWS.id) } } ) } } @@ -226,26 +365,87 @@ fun KeyboardImeRootScreen( movieList = movieList, isLoading = isMovieLoading, onCommitText = onCommitText, - onBackClick = onBackToMain + onBackClick = onBackToMain, + isAlwaysShow = (alwaysShowFeatureId == com.frogobox.appkeyboard.model.KeyboardFeatureType.MOVIE.id), + onToggleAlwaysShow = onToggleAlwaysShowFeature?.let { cb -> { cb(com.frogobox.appkeyboard.model.KeyboardFeatureType.MOVIE.id) } } ) } } KeyboardPanelState.WEBVIEW -> { - WebviewKeyboardScreen( - onCommitText = onCommitText, - onBackClick = onBackToMain - ) + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(260.dp) + ) { + WebviewKeyboardScreen( + onCommitText = onCommitText, + onBackClick = onBackToMain, + onRegisterKeyHandler = onRegisterKeyConsumer, + modifier = Modifier.fillMaxSize() + ) + } + MainKeyboardView( + keyboard = currentKeyboard, + onActionListener = onKeyboardActionListener, + onInit = onMainKeyboardInit + ) + } } KeyboardPanelState.FORM -> { - Box(modifier = Modifier.fillMaxWidth().height(270.dp)) { - FormKeyboardScreen( - onCommitText = onCommitText, - onBackClick = onBackToMain + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(240.dp) + ) { + FormKeyboardScreen( + onCommitText = onCommitText, + onBackClick = onBackToMain, + onRegisterKeyHandler = onRegisterKeyConsumer, + modifier = Modifier.fillMaxSize() + ) + } + MainKeyboardView( + keyboard = currentKeyboard, + onActionListener = onKeyboardActionListener, + onInit = onMainKeyboardInit ) } } + + KeyboardPanelState.CLIPBOARD -> { + ClipboardKeyboardScreen( + items = clipboardItems, + onCommitText = onCommitText, + onTogglePin = onTogglePinClipboardItem, + onDeleteClip = onDeleteClipboardItem, + onClearHistory = onClearClipboardHistory, + onBackClick = onBackToMain, + isAlwaysShow = (alwaysShowFeatureId == com.frogobox.appkeyboard.model.KeyboardFeatureType.CLIPBOARD.id), + onToggleAlwaysShow = onToggleAlwaysShowFeature?.let { cb -> { cb(com.frogobox.appkeyboard.model.KeyboardFeatureType.CLIPBOARD.id) } } + ) + } + + KeyboardPanelState.TEXT_EDIT -> { + TextEditKeyboardScreen( + isSelectionMode = isSelectionMode, + onAction = onTextEditAction, + onBackClick = onBackToMain, + isAlwaysShow = (alwaysShowFeatureId == com.frogobox.appkeyboard.model.KeyboardFeatureType.TEXT_EDIT.id), + onToggleAlwaysShow = onToggleAlwaysShowFeature?.let { cb -> { cb(com.frogobox.appkeyboard.model.KeyboardFeatureType.TEXT_EDIT.id) } } + ) + } } } } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardPanelState.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardPanelState.kt index 6585042..869a361 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardPanelState.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardPanelState.kt @@ -9,6 +9,7 @@ enum class KeyboardPanelState { MAIN, EMOJI, AUTO_TEXT, + PRODUCT_REMOTE, TEMPLATE_TEXT_GAME, TEMPLATE_TEXT_APP, TEMPLATE_TEXT_SALE, @@ -17,7 +18,9 @@ enum class KeyboardPanelState { NEWS, MOVIE, WEBVIEW, - FORM; + FORM, + CLIPBOARD, + TEXT_EDIT; val isTemplate: Boolean get() = this in TEMPLATE_STATES @@ -44,11 +47,14 @@ enum class KeyboardPanelState { fun fromFeature(featureType: KeyboardFeatureType): KeyboardPanelState? { return when (featureType) { KeyboardFeatureType.AUTO_TEXT -> AUTO_TEXT + KeyboardFeatureType.PRODUCT_REMOTE -> PRODUCT_REMOTE KeyboardFeatureType.TEMPLATE_TEXT_GAME -> TEMPLATE_TEXT_GAME KeyboardFeatureType.TEMPLATE_TEXT_APP -> TEMPLATE_TEXT_APP KeyboardFeatureType.TEMPLATE_TEXT_SALE -> TEMPLATE_TEXT_SALE KeyboardFeatureType.TEMPLATE_TEXT_LOVE -> TEMPLATE_TEXT_LOVE KeyboardFeatureType.TEMPLATE_TEXT_GREETING -> TEMPLATE_TEXT_GREETING + KeyboardFeatureType.CLIPBOARD -> CLIPBOARD + KeyboardFeatureType.TEXT_EDIT -> TEXT_EDIT KeyboardFeatureType.NEWS -> NEWS KeyboardFeatureType.MOVIE -> MOVIE KeyboardFeatureType.WEB -> WEBVIEW diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardSuggestionBar.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardSuggestionBar.kt index 9c3b6a0..fe25aa6 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardSuggestionBar.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/root/KeyboardSuggestionBar.kt @@ -27,7 +27,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -58,7 +57,9 @@ fun KeyboardSuggestionBar( onCandidateSelected: (String, CandidateType) -> Unit, onSwitchMenu: () -> Unit, onClose: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + recentClip: String? = null, + onQuickPaste: ((String) -> Unit)? = null ) { Column( modifier = modifier @@ -93,31 +94,68 @@ fun KeyboardSuggestionBar( Spacer(modifier = Modifier.width(2.dp)) - // Section 1: User's Word (Literal Raw Input) - val hasUserWord = result.userWord.isNotEmpty() - if (hasUserWord) { - Box( + if (!recentClip.isNullOrBlank() && !result.hasSuggestions()) { + // Gboard-Style Quick Paste Chip + Surface( modifier = Modifier - .weight(1.0f) - .fillMaxHeight() - .clip(RoundedCornerShape(8.dp)) - .clickable { onCandidateSelected(result.userWord, CandidateType.USER_WORD) } - .padding(horizontal = 4.dp), - contentAlignment = Alignment.Center + .weight(1f) + .height(36.dp) + .clip(RoundedCornerShape(18.dp)) + .clickable { onQuickPaste?.invoke(recentClip) }, + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.85f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)) ) { - Text( - text = "โ€œ${result.userWord}โ€", - fontSize = 13.sp, - fontWeight = FontWeight.Normal, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center - ) + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_menu_clipboard), + contentDescription = "Tempel dari Papan Klip", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Tempel: ${recentClip.take(28)}${if (recentClip.length > 28) "..." else ""}", + fontSize = 12.5.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } } else { - Spacer(modifier = Modifier.weight(1.0f)) - } + // Section 1: User's Word (Literal Raw Input) + val hasUserWord = result.userWord.isNotEmpty() + if (hasUserWord) { + Box( + modifier = Modifier + .weight(1.0f) + .fillMaxHeight() + .clip(RoundedCornerShape(8.dp)) + .clickable { onCandidateSelected(result.userWord, CandidateType.USER_WORD) } + .padding(horizontal = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "โ€œ${result.userWord}โ€", + fontSize = 13.sp, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + } else { + Spacer(modifier = Modifier.weight(1.0f)) + } // Divider 1 if (hasUserWord && result.predictedWord.isNotEmpty()) { @@ -202,6 +240,7 @@ fun KeyboardSuggestionBar( } else { Spacer(modifier = Modifier.weight(1.0f)) } + } Spacer(modifier = Modifier.width(2.dp)) diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextKeyboardScreen.kt index fe4c9d0..56b5f9f 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextKeyboardScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextKeyboardScreen.kt @@ -1,23 +1,29 @@ package com.frogobox.appkeyboard.ui.keyboard.templatetext +import android.content.Intent 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -25,6 +31,7 @@ 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.platform.LocalContext @@ -38,6 +45,7 @@ import com.frogobox.appkeyboard.model.KeyboardFeatureType.TEMPLATE_TEXT_GREETING import com.frogobox.appkeyboard.model.KeyboardFeatureType.TEMPLATE_TEXT_LOVE import com.frogobox.appkeyboard.model.KeyboardFeatureType.TEMPLATE_TEXT_SALE import com.frogobox.appkeyboard.ui.keyboard.common.KeyboardFeatureToolbar +import com.frogobox.appkeyboard.ui.templatetext.TemplateTextActivity data class TemplateCategory( val type: KeyboardFeatureType, @@ -73,14 +81,7 @@ fun TemplateTextKeyboardScreen( } val templateList = remember(selectedType) { - when (selectedType) { - TEMPLATE_TEXT_GAME -> TemplateTextUtils.getTextGame(context) - TEMPLATE_TEXT_APP -> TemplateTextUtils.getTextApp(context) - TEMPLATE_TEXT_SALE -> TemplateTextUtils.getTextSale(context) - TEMPLATE_TEXT_GREETING -> TemplateTextUtils.getTextGreeting(context) - TEMPLATE_TEXT_LOVE -> TemplateTextUtils.getTextLove(context) - else -> emptyList() - } + TemplateTextUtils.getTemplatesForType(context, selectedType) } Column( @@ -91,7 +92,30 @@ fun TemplateTextKeyboardScreen( KeyboardFeatureToolbar( title = currentCategoryTitle, subtitle = "Tap any template to paste into chat", - onBackClick = onBackClick + onBackClick = onBackClick, + action = { + Box( + modifier = Modifier + .size(34.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .clickable { + context.startActivity(Intent(context, TemplateTextActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(TemplateTextActivity.EXTRA_INITIAL_CATEGORY, selectedType.name) + }) + } + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Kelola Template", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } ) // Categories Chip Row diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextUtils.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextUtils.kt index bd41991..e48997b 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextUtils.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/templatetext/TemplateTextUtils.kt @@ -1,9 +1,15 @@ package com.frogobox.appkeyboard.ui.keyboard.templatetext import android.content.Context +import com.frogobox.appkeyboard.data.local.db.AppDatabase import com.frogobox.appkeyboard.model.KeyboardFeatureType +import com.frogobox.appkeyboard.model.TemplateCategoryType import com.frogobox.appkeyboard.model.TemplateText +import com.frogobox.appkeyboard.model.TemplateTextEntity import com.frogobox.sdk.ext.getDataFromJsonAsset +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext /** * Created by Faisal Amir on 24/10/22 @@ -52,4 +58,74 @@ object TemplateTextUtils { }.shuffled() } + suspend fun getTemplatesForTypeSuspend(context: Context, type: KeyboardFeatureType): List { + val categoryKey = when (type) { + KeyboardFeatureType.TEMPLATE_TEXT_GAME -> TemplateCategoryType.GAME.key + KeyboardFeatureType.TEMPLATE_TEXT_APP -> TemplateCategoryType.APP.key + KeyboardFeatureType.TEMPLATE_TEXT_SALE -> TemplateCategoryType.SALE.key + KeyboardFeatureType.TEMPLATE_TEXT_GREETING -> TemplateCategoryType.GREETING.key + KeyboardFeatureType.TEMPLATE_TEXT_LOVE -> TemplateCategoryType.LOVE.key + else -> return emptyList() + } + + return try { + val db = AppDatabase.newInstance(context) + val dao = db.templateTextDao() + withContext(Dispatchers.IO) { + var items = dao.getByCategory(categoryKey) + if (items.isEmpty()) { + val catType = TemplateCategoryType.fromFeatureType(type) + val assetFile = when (catType) { + TemplateCategoryType.GAME -> "text/text_game.json" + TemplateCategoryType.APP -> "text/text_app.json" + TemplateCategoryType.SALE -> "text/text_sale.json" + TemplateCategoryType.GREETING -> "text/text_greeting.json" + TemplateCategoryType.LOVE -> "text/text_love.json" + } + if (assetFile.isNotEmpty()) { + val strings = getDataAsset(context, assetFile) + val now = System.currentTimeMillis() + val list = strings.map { str -> + TemplateTextEntity( + category = categoryKey, + text = str, + createdAt = now, + updatedAt = now + ) + } + dao.insertAll(list) + items = dao.getByCategory(categoryKey) + } + } + + if (items.isNotEmpty()) { + items.map { entity -> + TemplateText(entity.id, entity.text, type) + } + } else { + getDefaultFallback(context, type) + } + } + } catch (_: Exception) { + getDefaultFallback(context, type) + } + } + + fun getTemplatesForType(context: Context, type: KeyboardFeatureType): List { + return runBlocking(Dispatchers.IO) { + getTemplatesForTypeSuspend(context, type) + } + } + + private fun getDefaultFallback(context: Context, type: KeyboardFeatureType): List { + return when (type) { + KeyboardFeatureType.TEMPLATE_TEXT_GAME -> getTextGame(context) + KeyboardFeatureType.TEMPLATE_TEXT_APP -> getTextApp(context) + KeyboardFeatureType.TEMPLATE_TEXT_SALE -> getTextSale(context) + KeyboardFeatureType.TEMPLATE_TEXT_GREETING -> getTextGreeting(context) + KeyboardFeatureType.TEMPLATE_TEXT_LOVE -> getTextLove(context) + else -> emptyList() + } + } + } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/textedit/TextEditKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/textedit/TextEditKeyboardScreen.kt new file mode 100644 index 0000000..926df5d --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/textedit/TextEditKeyboardScreen.kt @@ -0,0 +1,346 @@ +package com.frogobox.appkeyboard.ui.keyboard.textedit + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.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.unit.dp +import androidx.compose.ui.unit.sp + +enum class TextEditAction { + MOVE_LEFT, + MOVE_RIGHT, + MOVE_UP, + MOVE_DOWN, + MOVE_HOME, + MOVE_END, + TOGGLE_SELECT, + SELECT_ALL, + CUT, + COPY, + PASTE, + DELETE, + ENTER +} + +@Composable +fun TextEditKeyboardScreen( + isSelectionMode: Boolean, + onAction: (TextEditAction) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + isAlwaysShow: Boolean = false, + onToggleAlwaysShow: (() -> Unit)? = null +) { + Surface( + modifier = modifier + .fillMaxWidth() + .height(270.dp), + color = MaterialTheme.colorScheme.surface + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Header Bar + Row( + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onBackClick, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Kembali ke Keyboard", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + Text( + text = "Edit Teks", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + + if (onToggleAlwaysShow != null) { + com.frogobox.appkeyboard.ui.keyboard.common.PinFeatureButton( + isPinned = isAlwaysShow, + onTogglePin = onToggleAlwaysShow + ) + Spacer(modifier = Modifier.width(6.dp)) + } + + // Selection Mode Status Badge + Surface( + shape = RoundedCornerShape(12.dp), + color = if (isSelectionMode) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + }, + border = BorderStroke( + 0.8.dp, + if (isSelectionMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant + ) + ) { + Text( + text = if (isSelectionMode) "Mode Seleksi: AKTIF" else "Mode Seleksi: Nonaktif", + fontSize = 10.sp, + fontWeight = if (isSelectionMode) FontWeight.Bold else FontWeight.Medium, + color = if (isSelectionMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + } + + HorizontalDivider( + thickness = 0.8.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) + ) + + // 3-Column Ergonomic Controller Area + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Column 1: Selection & Clipboard Controls + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + ControllerButton( + label = if (isSelectionMode) "โœ“ Memilih" else "Pilih", + onClick = { onAction(TextEditAction.TOGGLE_SELECT) }, + isActive = isSelectionMode, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + ControllerButton( + label = "Pilih Semua", + onClick = { onAction(TextEditAction.SELECT_ALL) }, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + ControllerButton( + label = "Potong", + onClick = { onAction(TextEditAction.CUT) }, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + ControllerButton( + label = "Salin", + onClick = { onAction(TextEditAction.COPY) }, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + } + + // Column 2: Navigation D-Pad & Line Jump Controls + Column( + modifier = Modifier + .weight(1.3f) + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(5.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Top: Home / Jump to line start + ControllerButton( + label = "โ†– Awal Kalimat", + onClick = { onAction(TextEditAction.MOVE_HOME) }, + modifier = Modifier + .fillMaxWidth() + .weight(0.9f) + ) + + // D-Pad Up + ControllerButton( + label = "โ–ฒ", + onClick = { onAction(TextEditAction.MOVE_UP) }, + isArrow = true, + modifier = Modifier + .fillMaxWidth(0.6f) + .weight(1f) + ) + + // D-Pad Left / Center / Right Row + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + ControllerButton( + label = "โ—€", + onClick = { onAction(TextEditAction.MOVE_LEFT) }, + isArrow = true, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) + Box( + modifier = Modifier + .weight(0.6f) + .fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier.size(10.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) {} + } + ControllerButton( + label = "โ–ถ", + onClick = { onAction(TextEditAction.MOVE_RIGHT) }, + isArrow = true, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) + } + + // D-Pad Down + ControllerButton( + label = "โ–ผ", + onClick = { onAction(TextEditAction.MOVE_DOWN) }, + isArrow = true, + modifier = Modifier + .fillMaxWidth(0.6f) + .weight(1f) + ) + + // Bottom: End / Jump to line end + ControllerButton( + label = "โ†˜ Akhir Kalimat", + onClick = { onAction(TextEditAction.MOVE_END) }, + modifier = Modifier + .fillMaxWidth() + .weight(0.9f) + ) + } + + // Column 3: Insertion & Deletion Controls + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + ControllerButton( + label = "Tempel", + onClick = { onAction(TextEditAction.PASTE) }, + isActive = false, + modifier = Modifier + .fillMaxWidth() + .weight(1.3f) + ) + ControllerButton( + label = "โŒซ Hapus", + onClick = { onAction(TextEditAction.DELETE) }, + modifier = Modifier + .fillMaxWidth() + .weight(1.3f) + ) + ControllerButton( + label = "โ†ต Enter", + onClick = { onAction(TextEditAction.ENTER) }, + modifier = Modifier + .fillMaxWidth() + .weight(1.4f) + ) + } + } + } + } +} + +@Composable +private fun ControllerButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isActive: Boolean = false, + isArrow: Boolean = false +) { + Surface( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(8.dp), + color = if (isActive) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + }, + border = BorderStroke( + 1.dp, + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + } + ), + shadowElevation = if (isActive) 2.dp else 1.dp + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + fontSize = if (isArrow) 18.sp else 11.5.sp, + fontWeight = if (isActive || isArrow) FontWeight.Bold else FontWeight.SemiBold, + color = if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/webview/WebviewKeyboardScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/webview/WebviewKeyboardScreen.kt index a5e6283..5235252 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/webview/WebviewKeyboardScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/keyboard/webview/WebviewKeyboardScreen.kt @@ -12,6 +12,7 @@ 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 @@ -23,6 +24,7 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search @@ -32,6 +34,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -47,6 +50,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard import java.net.URLEncoder @SuppressLint("SetJavaScriptEnabled") @@ -54,7 +58,8 @@ import java.net.URLEncoder fun WebviewKeyboardScreen( onCommitText: (String) -> Unit, onBackClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onRegisterKeyHandler: (((Int, Boolean) -> Boolean)?) -> Unit = {} ) { var urlInput by remember { mutableStateOf("https://www.google.com") } var currentWebUrl by remember { mutableStateOf("https://www.google.com") } @@ -76,24 +81,66 @@ fun WebviewKeyboardScreen( focusManager.clearFocus() } + val handleKeyPress: (Int, Boolean) -> Boolean = { code, isShifted -> + when (code) { + ItemMainKeyboard.KEYCODE_DELETE -> { + if (urlInput.isNotEmpty()) { + urlInput = urlInput.dropLast(1) + } + true + } + ItemMainKeyboard.KEYCODE_ENTER -> { + loadUrlOrSearch() + true + } + ItemMainKeyboard.KEYCODE_SPACE -> { + urlInput += " " + true + } + ItemMainKeyboard.KEYCODE_SHIFT, + ItemMainKeyboard.KEYCODE_MODE_CHANGE, + ItemMainKeyboard.KEYCODE_EMOJI -> { + false + } + else -> { + if (code > 0) { + var ch = code.toChar() + if (ch.isLetter() && isShifted) { + ch = ch.uppercaseChar() + } + urlInput += ch + true + } else { + false + } + } + } + } + + DisposableEffect(handleKeyPress) { + onRegisterKeyHandler(handleKeyPress) + onDispose { + onRegisterKeyHandler(null) + } + } + Column( modifier = modifier - .fillMaxWidth() - .height(270.dp) + .fillMaxSize() .background(MaterialTheme.colorScheme.surface) ) { // Web Navigation Bar Row( modifier = Modifier .fillMaxWidth() - .height(50.dp) + .height(46.dp) .padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically ) { // Back Button Box( modifier = Modifier - .size(34.dp) + .size(32.dp) .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) .clickable { onBackClick() } @@ -103,7 +150,7 @@ fun WebviewKeyboardScreen( Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", - modifier = Modifier.size(18.dp), + modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurface ) } @@ -114,7 +161,7 @@ fun WebviewKeyboardScreen( Row( modifier = Modifier .weight(1f) - .height(34.dp) + .height(32.dp) .clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)) .padding(horizontal = 8.dp), @@ -124,7 +171,7 @@ fun WebviewKeyboardScreen( imageVector = Icons.Default.Search, contentDescription = "Search", tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(15.dp) ) Spacer(modifier = Modifier.width(4.dp)) @@ -142,6 +189,24 @@ fun WebviewKeyboardScreen( keyboardActions = KeyboardActions(onSearch = { loadUrlOrSearch() }), modifier = Modifier.weight(1f) ) + + if (urlInput.isNotEmpty()) { + Box( + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(10.dp)) + .clickable { urlInput = "" } + .padding(2.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp) + ) + } + } } Spacer(modifier = Modifier.width(6.dp)) @@ -149,7 +214,7 @@ fun WebviewKeyboardScreen( // Reload Button Box( modifier = Modifier - .size(34.dp) + .size(32.dp) .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) .clickable { webViewInstance?.reload() } @@ -159,7 +224,7 @@ fun WebviewKeyboardScreen( Icon( imageVector = Icons.Default.Refresh, contentDescription = "Reload", - modifier = Modifier.size(18.dp), + modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurface ) } @@ -169,13 +234,14 @@ fun WebviewKeyboardScreen( // Paste URL Pill Row( modifier = Modifier - .height(30.dp) - .clip(RoundedCornerShape(15.dp)) + .height(28.dp) + .clip(RoundedCornerShape(14.dp)) .background(MaterialTheme.colorScheme.primary) .clickable { val activeUrl = webViewInstance?.url ?: currentWebUrl if (activeUrl.isNotBlank()) { onCommitText(activeUrl) + onBackClick() } } .padding(horizontal = 8.dp), @@ -190,7 +256,7 @@ fun WebviewKeyboardScreen( Spacer(modifier = Modifier.width(3.dp)) Text( text = "Paste", - fontSize = 11.sp, + fontSize = 10.5.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimary ) @@ -203,13 +269,13 @@ fun WebviewKeyboardScreen( progress = { webProgress / 100f }, modifier = Modifier .fillMaxWidth() - .height(2.5.dp), + .height(2.dp), color = MaterialTheme.colorScheme.primary ) } else { HorizontalDivider( thickness = 0.8.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f) ) } @@ -253,7 +319,13 @@ fun WebviewKeyboardScreen( webViewInstance = this } }, - update = { webViewInstance = it } + update = { webViewInstance = it }, + onRelease = { view -> + try { + view.stopLoading() + view.destroy() + } catch (_: Exception) {} + } ) } } 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 41ad94b..df3e268 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainActivity.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainActivity.kt @@ -12,7 +12,9 @@ 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.productremote.ProductRemoteActivity import com.frogobox.appkeyboard.ui.sound.SoundActivity +import com.frogobox.appkeyboard.ui.templatetext.TemplateTextActivity import com.frogobox.appkeyboard.ui.test.TestActivity import com.frogobox.appkeyboard.ui.theme.ThemeActivity import com.frogobox.appkeyboard.ui.toggle.ToggleActivity @@ -87,6 +89,15 @@ class MainActivity : BaseComposeMainActivity() { mState = PICKING }, onNavigateAutoText = { startActivityExt() }, + onNavigateTemplateText = { category -> + val intent = Intent(this@MainActivity, TemplateTextActivity::class.java).apply { + if (!category.isNullOrBlank()) { + putExtra(TemplateTextActivity.EXTRA_INITIAL_CATEGORY, category) + } + } + startActivity(intent) + }, + onNavigateProductRemote = { startActivityExt() }, onNavigateToggle = { startActivityExt() }, onNavigateLanguage = { startActivityExt() }, onNavigateTheme = { startActivityExt() }, diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainScreen.kt index 84c56d7..8cce513 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/main/MainScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -88,6 +87,8 @@ fun MainScreen( onGoToSettings: () -> Unit, onChangeKeyboard: () -> Unit, onNavigateAutoText: () -> Unit, + onNavigateTemplateText: (String?) -> Unit, + onNavigateProductRemote: () -> Unit, onNavigateToggle: () -> Unit, onNavigateLanguage: () -> Unit, onNavigateTheme: () -> Unit, @@ -247,6 +248,13 @@ fun MainScreen( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp) ) { + MainMenuItemCard( + title = "Product Remote", + subtitle = "Katalog data produk remote dari API server", + iconRes = R.drawable.ic_menu_ps_sale, + onClick = onNavigateProductRemote + ) + MainMenuItemCard( title = "Auto Text", subtitle = "Template teks cepat dan pintasan ketikan berulang", @@ -254,6 +262,14 @@ fun MainScreen( onClick = onNavigateAutoText ) + TemplateTextMenuItemCard( + title = "Template Teks", + subtitle = "Kelola template cepat Game, App, Sale, Greeting, dan Love", + iconRes = R.drawable.ic_menu_ps_game, + onClick = { onNavigateTemplateText(null) }, + onCategoryClick = { cat -> onNavigateTemplateText(cat) } + ) + MainMenuItemCard( title = "Toggle Fitur", subtitle = "Kustomisasi tombol pintas dan toolbar keyboard", @@ -442,6 +458,116 @@ private fun MainMenuItemCard( } } +@Composable +private fun TemplateTextMenuItemCard( + title: String, + subtitle: String, + iconRes: Int, + onClick: () -> Unit, + onCategoryClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = iconRes), + contentDescription = title, + modifier = Modifier.size(32.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, + lineHeight = 16.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) + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + // Quick Category Chips + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + listOf( + Pair("๐ŸŽฎ", "Game"), + Pair("๐Ÿ“ฑ", "App"), + Pair("๐Ÿ’ฐ", "Sale"), + Pair("๐Ÿ‘‹", "Greeting"), + Pair("โค๏ธ", "Love") + ).forEach { (icon, catName) -> + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = BorderStroke(0.8.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .clickable { onCategoryClick(catName.uppercase()) } + ) { + Text( + text = "$icon $catName", + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Medium + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp) + ) + } + } + } + } + } +} + @Preview(name = "Light Mode - Active", showBackground = true) @Composable fun MainScreenActivePreview() { @@ -451,6 +577,8 @@ fun MainScreenActivePreview() { onGoToSettings = {}, onChangeKeyboard = {}, onNavigateAutoText = {}, + onNavigateTemplateText = {}, + onNavigateProductRemote = {}, onNavigateToggle = {}, onNavigateLanguage = {}, onNavigateTheme = {}, @@ -469,6 +597,8 @@ fun MainScreenInactiveDarkPreview() { onGoToSettings = {}, onChangeKeyboard = {}, onNavigateAutoText = {}, + onNavigateTemplateText = {}, + onNavigateProductRemote = {}, onNavigateToggle = {}, onNavigateLanguage = {}, onNavigateTheme = {}, diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/productremote/ProductRemoteActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/productremote/ProductRemoteActivity.kt new file mode 100644 index 0000000..7a35006 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/productremote/ProductRemoteActivity.kt @@ -0,0 +1,55 @@ +package com.frogobox.appkeyboard.ui.productremote + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.frogobox.appkeyboard.common.base.BaseComposeActivity +import dagger.hilt.android.AndroidEntryPoint + +/** + * Dedicated In-App Activity for inspecting and browsing remote product catalog data. + * Annotated with @AndroidEntryPoint and declared with android:exported="false" in AndroidManifest.xml. + */ +@AndroidEntryPoint +class ProductRemoteActivity : BaseComposeActivity() { + + private val viewModel: ProductRemoteViewModel by viewModels() + + companion object { + const val EXTRA_INITIAL_FILTER = "extra_initial_filter" + } + + override fun onCreateExt(savedInstanceState: Bundle?) { + super.onCreateExt(savedInstanceState) + val initialFilterName = intent.getStringExtra(EXTRA_INITIAL_FILTER) + if (initialFilterName != null) { + try { + val filter = DownloadStatusFilter.valueOf(initialFilterName) + viewModel.setInitialFilter(filter) + } catch (_: Exception) {} + } + requestNotificationPermissionIfNeeded() + } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 101) + } + } + } + + @Composable + override fun Content() { + ProductRemoteScreen( + viewModel = viewModel, + onBackClick = { finish() } + ) + } + +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/productremote/ProductRemoteScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/productremote/ProductRemoteScreen.kt new file mode 100644 index 0000000..45087cd --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/productremote/ProductRemoteScreen.kt @@ -0,0 +1,1936 @@ +package com.frogobox.appkeyboard.ui.productremote + +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.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +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.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.BookmarkBorder +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.CalendarToday +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material.icons.filled.Inventory2 +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SearchOff +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.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.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.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +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 androidx.compose.ui.window.Dialog +import com.frogobox.appkeyboard.data.remote.model.DataItemResponse +import com.frogobox.appkeyboard.model.ProductEntity +import com.frogobox.appkeyboard.ui.keyboard.common.AsyncGlideImage +import com.frogobox.appkeyboard.ui.keyboard.productremote.toFormattedCommitText +import com.frogobox.appkeyboard.ui.keyboard.productremote.toProductCaptionCommitText +import com.frogobox.appkeyboard.ui.keyboard.productremote.toProductHookCommitText +import com.frogobox.appkeyboard.ui.keyboard.productremote.toProductLinkCommitText +import com.frogobox.appkeyboard.ui.keyboard.productremote.toProductTitleCommitText +import com.frogobox.appkeyboard.di.NetworkModule +import com.frogobox.appkeyboard.ui.theme.compose.FrogoEmptyView +import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary +import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusFailed +import com.frogobox.appkeyboard.ui.theme.compose.FrogoTopAppBar + +/** + * Stateful entry point collecting flows from [ProductRemoteViewModel]. + */ +@Composable +fun ProductRemoteScreen( + viewModel: ProductRemoteViewModel, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val products by viewModel.products.collectAsState() + val filteredProducts by viewModel.filteredProducts.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() + val statusFilter by viewModel.downloadStatusFilter.collectAsState() + val savedRemoteIds by viewModel.savedRemoteIds.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val isSyncing by viewModel.isSyncing.collectAsState() + val syncMessage by viewModel.syncMessage.collectAsState() + val errorMessage by viewModel.errorMessage.collectAsState() + val downloadStates by viewModel.downloadStates.collectAsState() + + LaunchedEffect(syncMessage) { + syncMessage?.let { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + viewModel.clearSyncMessage() + } + } + + ProductRemoteScreen( + products = products, + filteredProducts = filteredProducts, + searchQuery = searchQuery, + statusFilter = statusFilter, + savedRemoteIds = savedRemoteIds, + downloadStates = downloadStates, + isLoading = isLoading, + isSyncing = isSyncing, + errorMessage = errorMessage, + onSearchQueryChange = viewModel::onSearchQueryChanged, + onStatusFilterChange = viewModel::onDownloadStatusFilterChanged, + onSyncAllToRoomDb = viewModel::syncAllToRoomDb, + onToggleSave = viewModel::toggleSaveProduct, + onCreateProduct = viewModel::createProduct, + onUpdateProduct = viewModel::updateProduct, + onDeleteProduct = viewModel::deleteProduct, + onNukeAllFavorites = viewModel::nukeAllFavoriteProducts, + onDownloadVideo = { item -> viewModel.downloadVideo(context, item) }, + onRefresh = viewModel::fetchProducts, + onBackClick = onBackClick, + modifier = modifier + ) +} + +/** + * Dedicated In-App screen for inspecting, searching, and managing Product Remote items. + * Includes Download Status Filter (Sudah/Belum), Room DB Sync, and full CRUD. + */ +@Composable +fun ProductRemoteScreen( + products: List, + filteredProducts: List, + searchQuery: String, + statusFilter: DownloadStatusFilter, + savedRemoteIds: Set, + downloadStates: Map = emptyMap(), + isLoading: Boolean, + isSyncing: Boolean, + errorMessage: String?, + onSearchQueryChange: (String) -> Unit, + onStatusFilterChange: (DownloadStatusFilter) -> Unit, + onSyncAllToRoomDb: () -> Unit, + onToggleSave: (DataItemResponse) -> Unit, + onCreateProduct: (ProductEntity) -> Unit, + onUpdateProduct: (ProductEntity) -> Unit, + onDeleteProduct: (DataItemResponse) -> Unit, + onNukeAllFavorites: () -> Unit = {}, + onDownloadVideo: (DataItemResponse) -> Unit = {}, + onRefresh: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + var editingItem by remember { mutableStateOf(null) } + var itemToDelete by remember { mutableStateOf(null) } + var showNukeDialog by remember { mutableStateOf(false) } + + val onCopyCaption: (DataItemResponse) -> Unit = { item -> + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Caption Produk", item.toProductCaptionCommitText()) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Caption produk berhasil disalin", Toast.LENGTH_SHORT).show() + } + + val onCopyTitle: (DataItemResponse) -> Unit = { item -> + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Judul Produk", item.toProductTitleCommitText()) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Judul produk berhasil disalin", Toast.LENGTH_SHORT).show() + } + + val onCopySnippet: (DataItemResponse) -> Unit = { item -> + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(item.displayTitle, item.toFormattedCommitText()) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Format chat berhasil disalin", Toast.LENGTH_SHORT).show() + } + + val onCopyHook: (DataItemResponse) -> Unit = { item -> + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Hook Produk", item.toProductHookCommitText()) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Hook produk berhasil disalin", Toast.LENGTH_SHORT).show() + } + + val onCopyDriveLink: (String) -> Unit = { link -> + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Drive Link", link) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Link Drive berhasil disalin", Toast.LENGTH_SHORT).show() + } + + val onCopyProductLink: (String) -> Unit = { link -> + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Link Produk", link) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Link Produk berhasil disalin", Toast.LENGTH_SHORT).show() + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = if (statusFilter == DownloadStatusFilter.FAVORITE) "Produk Favorit (DB)" else "Katalog Product Remote", + titleStyle = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 17.5.sp + ), + onBackClick = onBackClick, + actions = { + if (statusFilter == DownloadStatusFilter.FAVORITE) { + IconButton( + onClick = { + if (savedRemoteIds.isEmpty()) { + Toast.makeText(context, "Tidak ada produk favorit untuk dihapus", Toast.LENGTH_SHORT).show() + } else { + showNukeDialog = true + } + }, + modifier = Modifier.size(38.dp) + ) { + Icon( + imageVector = Icons.Default.DeleteSweep, + contentDescription = "Hapus Semua Produk Favorit (Nuke)", + tint = if (savedRemoteIds.isNotEmpty()) FrogoStatusFailed else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.size(20.dp) + ) + } + } + + IconButton( + onClick = { + if (statusFilter == DownloadStatusFilter.FAVORITE) { + onStatusFilterChange(DownloadStatusFilter.ALL) + } else { + onStatusFilterChange(DownloadStatusFilter.FAVORITE) + } + }, + modifier = Modifier.size(38.dp) + ) { + Icon( + imageVector = if (statusFilter == DownloadStatusFilter.FAVORITE) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + contentDescription = "Menu List Favorit", + tint = if (statusFilter == DownloadStatusFilter.FAVORITE) FrogoPrimary else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp) + ) + } + + IconButton( + onClick = { + try { + val chuckerIntent = com.chuckerteam.chucker.api.Chucker.getLaunchIntent(context) + context.startActivity(chuckerIntent) + } catch (e: Exception) { + Toast.makeText(context, "Chucker belum tersedia: ${e.message}", Toast.LENGTH_SHORT).show() + } + }, + modifier = Modifier.size(38.dp) + ) { + Icon( + imageVector = Icons.Default.BugReport, + contentDescription = "Buka Chucker Interceptor", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp) + ) + } + + IconButton( + onClick = onRefresh, + enabled = !isLoading, + modifier = Modifier.size(38.dp) + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = FrogoPrimary + ) + } else { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Segarkan data produk", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp) + ) + } + } + } + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // 1. Sticky Search Bar Container & Filter Chips + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + ) { + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = "Cari produk remote...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Cari", + tint = FrogoPrimary + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { onSearchQueryChange("") }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Hapus pencarian", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = FrogoPrimary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface + ) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 2. Download Status Filter Chips (Semua, Favorit, Sudah, Belum) + DownloadStatusFilterChips( + selectedFilter = statusFilter, + totalCount = products.size, + favoriteCount = savedRemoteIds.size, + downloadedCount = products.count { it.isDownloaded }, + notDownloadedCount = products.count { !it.isDownloaded }, + onFilterSelected = onStatusFilterChange + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 3. Metric & Sync Summary Banner + if (products.isNotEmpty()) { + ProductRemoteSummaryBanner( + totalCount = products.size, + filteredCount = filteredProducts.size, + savedCount = savedRemoteIds.size, + isSearching = searchQuery.isNotBlank(), + isSyncing = isSyncing, + onSyncAll = onSyncAllToRoomDb + ) + } + } + + // 4. Main Content Area (State Management) + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f) + ) { + when { + statusFilter == DownloadStatusFilter.FAVORITE && filteredProducts.isEmpty() && searchQuery.isBlank() -> { + FrogoEmptyView( + title = "Belum Ada Produk Favorit", + subtitle = "Simpan produk dari katalog remote agar muncul di daftar favorit dan keyboard.", + actionButtonText = "Lihat Semua Katalog", + onActionClick = { onStatusFilterChange(DownloadStatusFilter.ALL) }, + modifier = Modifier.align(Alignment.Center) + ) + } + + statusFilter == DownloadStatusFilter.FAVORITE -> { + if (filteredProducts.isEmpty()) { + ProductRemoteEmptySearchState( + query = searchQuery, + onResetSearch = { + onSearchQueryChange("") + }, + modifier = Modifier.align(Alignment.Center) + ) + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + itemsIndexed( + items = filteredProducts, + key = { index, item -> "${item.id ?: "fav"}_${item.displayIndex}_$index" } + ) { _, item -> + val isSaved = savedRemoteIds.contains(item.id) + val itemId = item.id ?: item.productName ?: item.rowIndex?.toString() ?: "" + val itemDownloadState = downloadStates[itemId] + ProductRemoteInAppCard( + item = item, + isSavedInRoomDb = isSaved, + downloadState = itemDownloadState, + onToggleSave = { onToggleSave(item) }, + onEditClick = { editingItem = item }, + onDeleteClick = { itemToDelete = item }, + onCopyCaption = { onCopyCaption(item) }, + onCopyTitle = { onCopyTitle(item) }, + onCopyHook = { onCopyHook(item) }, + onCopySnippet = { onCopySnippet(item) }, + onCopyDriveLink = { item.driveLink?.let(onCopyDriveLink) }, + onCopyProductLink = { item.linkProduct?.let(onCopyProductLink) ?: item.toProductLinkCommitText().takeIf { it.isNotBlank() }?.let(onCopyProductLink) ?: onCopyProductLink("") }, + onDownloadVideo = { onDownloadVideo(item) } + ) + } + } + } + } + + isLoading && products.isEmpty() -> { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator( + color = FrogoPrimary, + strokeWidth = 3.dp + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Memuat data produk remote...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + errorMessage != null && products.isEmpty() -> { + ProductRemoteInAppErrorView( + errorMessage = errorMessage, + onRetry = onRefresh, + modifier = Modifier.align(Alignment.Center) + ) + } + + products.isEmpty() -> { + FrogoEmptyView( + title = "Belum Ada Produk Remote", + subtitle = "Server belum memiliki data katalog produk aktif. Periksa kembali nanti.", + actionButtonText = "Segarkan", + onActionClick = onRefresh, + modifier = Modifier.align(Alignment.Center) + ) + } + + filteredProducts.isEmpty() -> { + ProductRemoteEmptySearchState( + query = searchQuery, + onResetSearch = { + onSearchQueryChange("") + onStatusFilterChange(DownloadStatusFilter.ALL) + }, + modifier = Modifier.align(Alignment.Center) + ) + } + + else -> { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + itemsIndexed( + items = filteredProducts, + key = { index, item -> "${item.id ?: "prod"}_${item.displayIndex}_$index" } + ) { _, item -> + val isSaved = savedRemoteIds.contains(item.id) + val itemId = item.id ?: item.productName ?: item.rowIndex?.toString() ?: "" + val itemDownloadState = downloadStates[itemId] + ProductRemoteInAppCard( + item = item, + isSavedInRoomDb = isSaved, + downloadState = itemDownloadState, + onToggleSave = { onToggleSave(item) }, + onEditClick = { editingItem = item }, + onDeleteClick = { itemToDelete = item }, + onCopyCaption = { onCopyCaption(item) }, + onCopyTitle = { onCopyTitle(item) }, + onCopyHook = { onCopyHook(item) }, + onCopySnippet = { onCopySnippet(item) }, + onCopyDriveLink = { item.driveLink?.let(onCopyDriveLink) }, + onCopyProductLink = { item.linkProduct?.let(onCopyProductLink) ?: item.toProductLinkCommitText().takeIf { it.isNotBlank() }?.let(onCopyProductLink) ?: onCopyProductLink("") }, + onDownloadVideo = { onDownloadVideo(item) } + ) + } + } + } + } + } + } + } + + editingItem?.let { item -> + ProductFormDialog( + initialProduct = item, + onDismiss = { editingItem = null }, + onSave = { updatedProduct -> + onUpdateProduct(updatedProduct) + editingItem = null + } + ) + } + + // Delete Confirmation Dialog + itemToDelete?.let { item -> + AlertDialog( + onDismissRequest = { itemToDelete = null }, + title = { + Text( + text = "Hapus Produk?", + fontWeight = FontWeight.Bold + ) + }, + text = { + Text( + text = "Apakah Anda yakin ingin menghapus \"${item.displayTitle}\" dari database lokal? Produk ini tidak akan tampil lagi di keyboard.", + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + Button( + onClick = { + onDeleteProduct(item) + itemToDelete = null + }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { + Text("Hapus", fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = { itemToDelete = null }) { + Text("Batal") + } + } + ) + } + + // Nuke All Favorites Confirmation Dialog + if (showNukeDialog) { + AlertDialog( + onDismissRequest = { showNukeDialog = false }, + icon = { + Icon( + imageVector = Icons.Default.DeleteSweep, + contentDescription = null, + tint = FrogoStatusFailed, + modifier = Modifier.size(32.dp) + ) + }, + title = { + Text( + text = "Hapus Semua Favorit?", + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + textAlign = TextAlign.Center + ) + }, + text = { + Text( + text = "Apakah Anda yakin ingin menghapus semua (${savedRemoteIds.size}) produk favorit dari database lokal? Data ini akan dihapus dari Room DB dan tidak lagi muncul di keyboard.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + }, + confirmButton = { + Button( + onClick = { + onNukeAllFavorites() + showNukeDialog = false + }, + colors = ButtonDefaults.buttonColors( + containerColor = FrogoStatusFailed + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = "Hapus Semua", + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + }, + dismissButton = { + OutlinedButton( + onClick = { showNukeDialog = false }, + shape = RoundedCornerShape(8.dp) + ) { + Text("Batal") + } + } + ) + } +} + +/** + * Filter Chips Bar for download status filtering. + */ +@Composable +private fun DownloadStatusFilterChips( + selectedFilter: DownloadStatusFilter, + totalCount: Int, + favoriteCount: Int, + downloadedCount: Int, + notDownloadedCount: Int, + onFilterSelected: (DownloadStatusFilter) -> Unit, + modifier: Modifier = Modifier +) { + LazyRow( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + val isSelected = selectedFilter == DownloadStatusFilter.ALL + Surface( + shape = RoundedCornerShape(8.dp), + color = if (isSelected) FrogoPrimary else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = BorderStroke( + 1.dp, + if (isSelected) FrogoPrimary else MaterialTheme.colorScheme.outlineVariant + ), + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { onFilterSelected(DownloadStatusFilter.ALL) } + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Semua ($totalCount)", + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + color = if (isSelected) Color.White else MaterialTheme.colorScheme.onSurface + ) + } + } + } + + item { + val isSelected = selectedFilter == DownloadStatusFilter.FAVORITE + Surface( + shape = RoundedCornerShape(8.dp), + color = if (isSelected) FrogoPrimary else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = BorderStroke( + 1.dp, + if (isSelected) FrogoPrimary else MaterialTheme.colorScheme.outlineVariant + ), + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { onFilterSelected(DownloadStatusFilter.FAVORITE) } + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (isSelected) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = if (isSelected) Color.White else FrogoPrimary + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Favorit ($favoriteCount)", + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + color = if (isSelected) Color.White else MaterialTheme.colorScheme.onSurface + ) + } + } + } + + item { + val isSelected = selectedFilter == DownloadStatusFilter.DOWNLOADED + Surface( + shape = RoundedCornerShape(8.dp), + color = if (isSelected) Color(0xFF1B5E20) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = BorderStroke( + 1.dp, + if (isSelected) Color(0xFF81C784) else MaterialTheme.colorScheme.outlineVariant + ), + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { onFilterSelected(DownloadStatusFilter.DOWNLOADED) } + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = if (isSelected) Color.White else Color(0xFF2E7D32) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Sudah Diunduh ($downloadedCount)", + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + color = if (isSelected) Color.White else MaterialTheme.colorScheme.onSurface + ) + } + } + } + + item { + val isSelected = selectedFilter == DownloadStatusFilter.NOT_DOWNLOADED + Surface( + shape = RoundedCornerShape(8.dp), + color = if (isSelected) Color(0xFFE65100) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = BorderStroke( + 1.dp, + if (isSelected) Color(0xFFFFB74D) else MaterialTheme.colorScheme.outlineVariant + ), + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { onFilterSelected(DownloadStatusFilter.NOT_DOWNLOADED) } + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.HourglassEmpty, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = if (isSelected) Color.White else Color(0xFFE65100) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Belum Diunduh ($notDownloadedCount)", + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + color = if (isSelected) Color.White else MaterialTheme.colorScheme.onSurface + ) + } + } + } + } +} + +/** + * Metric summary banner showing catalog status, item count, and Room DB sync trigger. + */ +@Composable +private fun ProductRemoteSummaryBanner( + totalCount: Int, + filteredCount: Int, + savedCount: Int, + isSearching: Boolean, + isSyncing: Boolean, + endpoint: String = NetworkModule.BASE_URL.removePrefix("http://").removePrefix("https://").removeSuffix("/"), + onSyncAll: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(8.dp)) + .background(FrogoPrimary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Inventory2, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.width(10.dp)) + + Column { + Text( + text = if (isSearching) "Hasil Pencarian ($filteredCount/$totalCount)" else "Katalog Server", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 13.5.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + val context = LocalContext.current + Text( + text = "Endpoint: $endpoint โ€ข $savedCount di DB", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.clickable { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val fullEndpoint = "${NetworkModule.BASE_URL}api/data.json" + val clip = ClipData.newPlainText("API Endpoint", fullEndpoint) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Endpoint disalin: $fullEndpoint", Toast.LENGTH_SHORT).show() + } + ) + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = onSyncAll, + enabled = !isSyncing, + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors(containerColor = FrogoPrimary), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp) + ) { + if (isSyncing) { + CircularProgressIndicator( + modifier = Modifier.size(13.dp), + strokeWidth = 2.dp, + color = Color.White + ) + Spacer(modifier = Modifier.width(5.dp)) + Text( + text = "Sync...", + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + } else { + Icon( + imageVector = Icons.Default.CloudDownload, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(5.dp)) + Text( + text = "Sync ke DB", + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } +} + +/** + * Rich In-App Product Card with media thumbnail, status badges, Room DB indicator, and CRUD controls. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ProductRemoteInAppCard( + item: DataItemResponse, + isSavedInRoomDb: Boolean, + downloadState: DownloadProgressState? = null, + onToggleSave: () -> Unit, + onEditClick: () -> Unit, + onDeleteClick: () -> Unit, + onCopyCaption: () -> Unit, + onCopyTitle: () -> Unit, + onCopyHook: () -> Unit, + onCopySnippet: () -> Unit, + onCopyDriveLink: () -> Unit, + onCopyProductLink: () -> Unit, + onDownloadVideo: () -> Unit = {}, + modifier: Modifier = Modifier +) { + val hasThumbnail = !item.thumbnailUrl.isNullOrBlank() + val hasDriveLink = !item.driveLink.isNullOrBlank() + val hasProductLink = !item.linkProduct.isNullOrBlank() + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Media Header (9:16 Vertical Ratio) + if (hasThumbnail) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(9f / 16f) + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + ) { + AsyncGlideImage( + url = item.thumbnailUrl, + contentDescription = item.displayTitle, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + + // Video Indicator Badge + if (item.isVideo == true) { + Surface( + shape = RoundedCornerShape(4.dp), + color = Color.Black.copy(alpha = 0.72f), + modifier = Modifier + .align(Alignment.BottomStart) + .padding(6.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = "Video product", + tint = Color.White, + modifier = Modifier.size(12.dp) + ) + if (!item.fileSize.isNullOrBlank()) { + Spacer(modifier = Modifier.width(3.dp)) + Text( + text = item.fileSize, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + } + } + } + } + } + } + + // Body Details + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp) + ) { + // Status Badges & Room DB Indicator Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f, fill = false) + ) { + // Download Status Badge + if (!item.statusDownload.isNullOrBlank()) { + val isDownloaded = item.isDownloaded + Surface( + shape = RoundedCornerShape(4.dp), + color = if (isDownloaded) Color(0xFFE8F5E9) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + border = BorderStroke( + 0.5.dp, + if (isDownloaded) Color(0xFF81C784) else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f) + ) + ) { + Text( + text = if (isDownloaded) "โœ“ Terunduh" else "Belum", + fontSize = 9.5.sp, + fontWeight = FontWeight.SemiBold, + color = if (isDownloaded) Color(0xFF1B5E20) else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp) + ) + } + } + + // Room DB Saved Badge + if (isSavedInRoomDb) { + Surface( + shape = RoundedCornerShape(4.dp), + color = FrogoPrimary.copy(alpha = 0.12f), + border = BorderStroke(0.5.dp, FrogoPrimary.copy(alpha = 0.35f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Bookmark, + contentDescription = null, + tint = FrogoPrimary, + modifier = Modifier.size(10.dp) + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = "DB", + fontSize = 9.5.sp, + fontWeight = FontWeight.Bold, + color = FrogoPrimary + ) + } + } + } + } + + Spacer(modifier = Modifier.width(4.dp)) + + Text( + text = "#${item.displayIndex}", + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 10.5.sp, + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Title + Text( + text = item.displayTitle, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + lineHeight = 17.sp + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + // Caption + val captionText = item.caption + if (!captionText.isNullOrBlank() && captionText != item.displayTitle) { + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = "Caption: $captionText", + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + lineHeight = 15.sp + ), + color = MaterialTheme.colorScheme.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + // Hook snippet + val hookText = item.hook + if (!hookText.isNullOrBlank()) { + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = "๐Ÿช Hook: $hookText", + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + lineHeight = 15.sp + ), + color = MaterialTheme.colorScheme.tertiary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + // Product Link + val productLinkText = item.linkProduct + if (!productLinkText.isNullOrBlank()) { + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = "๐Ÿ”— Link: $productLinkText", + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 10.5.sp, + color = MaterialTheme.colorScheme.primary + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + // Body Description + val bodyText = item.displayBody + if (!bodyText.isNullOrBlank() && bodyText != item.displayTitle && bodyText != captionText && bodyText != hookText) { + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = bodyText, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 11.sp, + lineHeight = 15.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + + // Metadata Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f, fill = false), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.size(11.dp) + ) + Spacer(modifier = Modifier.width(3.dp)) + Text( + text = item.originalFileName ?: "File #${item.displayIndex}", + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + val timestamp = item.displayTimestamp + if (!timestamp.isNullOrBlank()) { + Spacer(modifier = Modifier.width(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.CalendarToday, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.size(10.dp) + ) + Spacer(modifier = Modifier.width(3.dp)) + Text( + text = timestamp, + fontSize = 9.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + HorizontalDivider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + + Spacer(modifier = Modifier.height(6.dp)) + + // Primary Output Row: Salin Caption, Salin Judul, Salin Hook + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = onCopyCaption, + shape = RoundedCornerShape(6.dp), + modifier = Modifier + .weight(1f) + .height(30.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + contentPadding = PaddingValues(vertical = 4.dp, horizontal = 2.dp) + ) { + Text( + text = "Caption", + fontSize = 10.5.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Button( + onClick = onCopyTitle, + shape = RoundedCornerShape(6.dp), + modifier = Modifier + .weight(1f) + .height(30.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ), + contentPadding = PaddingValues(vertical = 4.dp, horizontal = 2.dp) + ) { + Text( + text = "Judul", + fontSize = 10.5.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Button( + onClick = onCopyHook, + shape = RoundedCornerShape(6.dp), + modifier = Modifier + .weight(1f) + .height(30.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ), + contentPadding = PaddingValues(vertical = 4.dp, horizontal = 2.dp) + ) { + Text( + text = "๐Ÿช Hook", + fontSize = 10.5.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Secondary Row: Link Produk, Drive, & Chat + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = onCopyProductLink, + shape = RoundedCornerShape(6.dp), + modifier = Modifier + .weight(1f) + .height(28.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (hasProductLink) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + contentColor = if (hasProductLink) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant + ), + contentPadding = PaddingValues(vertical = 2.dp, horizontal = 2.dp) + ) { + Icon( + imageVector = Icons.Default.Link, + contentDescription = null, + modifier = Modifier.size(11.dp) + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = "Link", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + if (hasDriveLink) { + val isDownloading = downloadState is DownloadProgressState.Downloading + val progress = (downloadState as? DownloadProgressState.Downloading)?.progress ?: 0 + val isDownloaded = item.isDownloaded || downloadState is DownloadProgressState.Success + + val containerColor = when { + isDownloading -> FrogoPrimary.copy(alpha = 0.85f) + isDownloaded -> Color(0xFF2E7D32) + else -> FrogoPrimary + } + + Surface( + shape = RoundedCornerShape(6.dp), + color = containerColor, + modifier = Modifier + .weight(1f) + .height(28.dp) + .clip(RoundedCornerShape(6.dp)) + .combinedClickable( + onClick = { + if (!isDownloading) { + onDownloadVideo() + } + }, + onLongClick = onCopyDriveLink + ) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 4.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + when { + isDownloading -> { + CircularProgressIndicator( + color = Color.White, + strokeWidth = 1.5.dp, + modifier = Modifier.size(11.dp) + ) + Spacer(modifier = Modifier.width(3.dp)) + Text( + text = if (progress >= 0) "$progress%" else "Unduh", + fontSize = 9.5.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + isDownloaded -> { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Sudah Diunduh", + tint = Color.White, + modifier = Modifier.size(11.dp) + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = "Video", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + else -> { + Icon( + imageVector = Icons.Default.CloudDownload, + contentDescription = "Unduh Video Drive", + tint = Color.White, + modifier = Modifier.size(11.dp) + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = "Drive", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + + OutlinedButton( + onClick = onCopySnippet, + shape = RoundedCornerShape(6.dp), + modifier = Modifier + .weight(1f) + .height(28.dp), + contentPadding = PaddingValues(vertical = 2.dp, horizontal = 2.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + modifier = Modifier.size(11.dp) + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = "Chat", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Room DB Sync & CRUD Operations Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Toggle Save / Unsave Room DB Button + OutlinedButton( + onClick = onToggleSave, + shape = RoundedCornerShape(6.dp), + modifier = Modifier + .weight(1f) + .height(30.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (isSavedInRoomDb) FrogoPrimary.copy(alpha = 0.1f) else Color.Transparent, + contentColor = if (isSavedInRoomDb) FrogoPrimary else MaterialTheme.colorScheme.onSurface + ), + border = BorderStroke( + 1.dp, + if (isSavedInRoomDb) FrogoPrimary else MaterialTheme.colorScheme.outlineVariant + ), + contentPadding = PaddingValues(vertical = 2.dp, horizontal = 4.dp) + ) { + Icon( + imageVector = if (isSavedInRoomDb) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = if (isSavedInRoomDb) FrogoPrimary else MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(3.dp)) + Text( + text = if (isSavedInRoomDb) "Tersimpan" else "Simpan", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + // Edit Button + Box( + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f), + RoundedCornerShape(6.dp) + ) + .clickable( + role = Role.Button, + onClick = onEditClick + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit produk", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp) + ) + } + + // Delete Button + Box( + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.25f)) + .border( + 1.dp, + MaterialTheme.colorScheme.error.copy(alpha = 0.35f), + RoundedCornerShape(6.dp) + ) + .clickable( + role = Role.Button, + onClick = onDeleteClick + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.DeleteOutline, + contentDescription = "Hapus produk", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(14.dp) + ) + } + } + } + } + } +} + +/** + * Product Form Dialog for Create & Update operations. + */ +@Composable +private fun ProductFormDialog( + initialProduct: DataItemResponse?, + onDismiss: () -> Unit, + onSave: (ProductEntity) -> Unit +) { + var productName by remember { mutableStateOf(initialProduct?.productName ?: initialProduct?.displayTitle ?: "") } + var caption by remember { mutableStateOf(initialProduct?.caption ?: "") } + var hook by remember { mutableStateOf(initialProduct?.hook ?: "") } + var linkProduct by remember { mutableStateOf(initialProduct?.linkProduct ?: "") } + var statusDownload by remember { mutableStateOf(initialProduct?.statusDownload ?: "Belum") } + var driveLink by remember { mutableStateOf(initialProduct?.driveLink ?: "") } + var originalFileName by remember { mutableStateOf(initialProduct?.originalFileName ?: "") } + var fileSize by remember { mutableStateOf(initialProduct?.fileSize ?: "") } + + var isError by remember { mutableStateOf(false) } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = if (initialProduct == null) "Tambah Produk Baru" else "Edit Produk", + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(14.dp)) + + OutlinedTextField( + value = productName, + onValueChange = { + productName = it + if (it.isNotBlank()) isError = false + }, + label = { Text("Nama Produk *") }, + modifier = Modifier.fillMaxWidth(), + isError = isError && productName.isBlank(), + singleLine = true, + shape = RoundedCornerShape(8.dp) + ) + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + value = caption, + onValueChange = { + caption = it + if (it.isNotBlank()) isError = false + }, + label = { Text("Caption Promosi *") }, + modifier = Modifier.fillMaxWidth(), + isError = isError && caption.isBlank(), + minLines = 3, + maxLines = 5, + shape = RoundedCornerShape(8.dp) + ) + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + value = hook, + onValueChange = { hook = it }, + label = { Text("Hook Promosi (Opsional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 4, + shape = RoundedCornerShape(8.dp) + ) + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + value = linkProduct, + onValueChange = { linkProduct = it }, + label = { Text("Link Produk (Shopee/Tokopedia/Web) (Opsional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(8.dp) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Download Status Selection + Text( + text = "Status Unduh", + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val isBelum = statusDownload.equals("Belum", ignoreCase = true) + Surface( + shape = RoundedCornerShape(8.dp), + color = if (isBelum) Color(0xFFE65100) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = BorderStroke(1.dp, if (isBelum) Color(0xFFFFB74D) else MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .clickable { statusDownload = "Belum" } + ) { + Text( + text = "Belum Diunduh", + fontSize = 12.sp, + fontWeight = if (isBelum) FontWeight.Bold else FontWeight.Medium, + color = if (isBelum) Color.White else MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + + val isSudah = statusDownload.equals("Sudah", ignoreCase = true) + Surface( + shape = RoundedCornerShape(8.dp), + color = if (isSudah) Color(0xFF1B5E20) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = BorderStroke(1.dp, if (isSudah) Color(0xFF81C784) else MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .clickable { statusDownload = "Sudah" } + ) { + Text( + text = "โœ“ Sudah Diunduh", + fontSize = 12.sp, + fontWeight = if (isSudah) FontWeight.Bold else FontWeight.Medium, + color = if (isSudah) Color.White else MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + value = driveLink, + onValueChange = { driveLink = it }, + label = { Text("Link Google Drive (Opsional)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(8.dp) + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = originalFileName, + onValueChange = { originalFileName = it }, + label = { Text("Nama File") }, + modifier = Modifier.weight(1f), + singleLine = true, + shape = RoundedCornerShape(8.dp) + ) + + OutlinedTextField( + value = fileSize, + onValueChange = { fileSize = it }, + label = { Text("Ukuran File") }, + modifier = Modifier.weight(1f), + singleLine = true, + shape = RoundedCornerShape(8.dp) + ) + } + + if (isError) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Nama produk dan caption wajib diisi", + color = MaterialTheme.colorScheme.error, + fontSize = 12.sp + ) + } + + Spacer(modifier = Modifier.height(18.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { + Text("Batal") + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + if (productName.isBlank() || caption.isBlank()) { + isError = true + return@Button + } + val localId = initialProduct?.displayIndex ?: 0 + val entity = ProductEntity( + id = localId, + remoteId = initialProduct?.id, + uploadTimestamp = initialProduct?.uploadTimestamp, + productName = productName.trim(), + caption = caption.trim(), + hook = hook.trim().takeIf { it.isNotBlank() }, + originalFileName = originalFileName.takeIf { it.isNotBlank() }, + fileSize = fileSize.takeIf { it.isNotBlank() }, + fileType = initialProduct?.fileType, + driveLink = driveLink.takeIf { it.isNotBlank() }, + linkProduct = linkProduct.trim().takeIf { it.isNotBlank() }, + driveFileId = initialProduct?.driveFileId, + thumbnailUrl = initialProduct?.thumbnailUrl, + previewUrl = initialProduct?.previewUrl, + isVideo = initialProduct?.isVideo ?: false, + statusDownload = statusDownload, + rowIndex = initialProduct?.rowIndex + ) + onSave(entity) + }, + colors = ButtonDefaults.buttonColors(containerColor = FrogoPrimary), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = "Simpan ke DB", + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} + +/** + * Empty Search State when query yields 0 matching products. + */ +@Composable +private fun ProductRemoteEmptySearchState( + query: String, + onResetSearch: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.SearchOff, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.outline + ) + } + + Spacer(modifier = Modifier.height(14.dp)) + + Text( + text = "Produk Tidak Ditemukan", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ), + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = if (query.isNotBlank()) "Tidak ada produk yang cocok dengan kata kunci \"$query\"." else "Tidak ada produk dengan filter ini.", + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 13.sp, + lineHeight = 18.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedButton( + onClick = onResetSearch, + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = "Reset Pencarian", + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold + ) + } + } +} + +/** + * In-App Network Error View with tactile retry action. + */ +@Composable +private fun ProductRemoteInAppErrorView( + errorMessage: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(60.dp) + .clip(RoundedCornerShape(14.dp)) + .background(FrogoStatusFailed.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.CloudOff, + contentDescription = null, + tint = FrogoStatusFailed, + modifier = Modifier.size(32.dp) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Koneksi Server Terputus", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 17.sp + ), + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = errorMessage, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 13.sp, + lineHeight = 18.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Button( + onClick = onRetry, + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = FrogoPrimary + ) + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Coba Lagi", + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold + ) + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/productremote/ProductRemoteViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/productremote/ProductRemoteViewModel.kt new file mode 100644 index 0000000..b7831e6 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/productremote/ProductRemoteViewModel.kt @@ -0,0 +1,394 @@ +package com.frogobox.appkeyboard.ui.productremote + +import android.content.Context +import androidx.lifecycle.viewModelScope +import com.frogobox.appkeyboard.common.base.BaseViewModel +import com.frogobox.appkeyboard.common.core.Resource +import com.frogobox.appkeyboard.data.remote.model.DataItemResponse +import com.frogobox.appkeyboard.model.ProductEntity +import com.frogobox.appkeyboard.repository.data.DataApiRepository +import com.frogobox.appkeyboard.repository.productremote.ProductRemoteRepository +import com.frogobox.appkeyboard.util.DriveUrlHelper +import com.frogobox.appkeyboard.util.VideoDownloader +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +enum class DownloadStatusFilter(val label: String) { + ALL("Semua"), + FAVORITE("Favorit"), + DOWNLOADED("Sudah"), + NOT_DOWNLOADED("Belum") +} + +sealed interface DownloadProgressState { + data object Idle : DownloadProgressState + data class Downloading(val progress: Int) : DownloadProgressState + data class Success(val destination: String) : DownloadProgressState + data class Error(val message: String) : DownloadProgressState +} + +sealed interface ProductRemoteUiState { + data object Loading : ProductRemoteUiState + data class Success( + val products: List, + val filteredProducts: List, + val searchQuery: String = "", + val statusFilter: DownloadStatusFilter = DownloadStatusFilter.ALL + ) : ProductRemoteUiState + data class Empty(val message: String = "Belum Ada Produk Remote") : ProductRemoteUiState + data class Error(val message: String, val isOffline: Boolean = false) : ProductRemoteUiState +} + +@HiltViewModel +class ProductRemoteViewModel @Inject constructor( + private val repository: DataApiRepository, + private val productRemoteRepository: ProductRemoteRepository +) : BaseViewModel() { + + private val _rawProducts = MutableStateFlow>(emptyList()) + val products: StateFlow> = _rawProducts.asStateFlow() + + private val _filteredProducts = MutableStateFlow>(emptyList()) + val filteredProducts: StateFlow> = _filteredProducts.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _downloadStatusFilter = MutableStateFlow(DownloadStatusFilter.ALL) + val downloadStatusFilter: StateFlow = _downloadStatusFilter.asStateFlow() + + private val _savedRemoteIds = MutableStateFlow>(emptySet()) + val savedRemoteIds: StateFlow> = _savedRemoteIds.asStateFlow() + + private val _savedProducts = MutableStateFlow>(emptyList()) + val savedProducts: StateFlow> = _savedProducts.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _isSyncing = MutableStateFlow(false) + val isSyncing: StateFlow = _isSyncing.asStateFlow() + + private val _syncMessage = MutableStateFlow(null) + val syncMessage: StateFlow = _syncMessage.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + private val _downloadStates = MutableStateFlow>(emptyMap()) + val downloadStates: StateFlow> = _downloadStates.asStateFlow() + + private val _uiState = MutableStateFlow(ProductRemoteUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + productRemoteRepository.getSavedRemoteIdsStream().collect { ids -> + _savedRemoteIds.value = ids + } + } + viewModelScope.launch { + productRemoteRepository.getSavedProductsStream().collect { list -> + _savedProducts.value = list + if (_downloadStatusFilter.value == DownloadStatusFilter.FAVORITE) { + applyFilter() + updateSuccessUiState() + } + } + } + fetchProducts() + } + + fun fetchProducts() { + _isLoading.value = true + _errorMessage.value = null + _uiState.value = ProductRemoteUiState.Loading + + viewModelScope.launch { + repository.fetchDataStream().collect { resource -> + when (resource) { + is Resource.Loading -> { + _isLoading.value = true + } + is Resource.Success -> { + _isLoading.value = false + _errorMessage.value = null + val rawItems = resource.data.items.orEmpty() + _rawProducts.value = rawItems + applyFilter() + + _uiState.value = if (rawItems.isEmpty() && _downloadStatusFilter.value != DownloadStatusFilter.FAVORITE) { + ProductRemoteUiState.Empty() + } else { + ProductRemoteUiState.Success( + products = if (_downloadStatusFilter.value == DownloadStatusFilter.FAVORITE) { + _savedProducts.value.map { it.toDataItemResponse() } + } else { + rawItems + }, + filteredProducts = _filteredProducts.value, + searchQuery = _searchQuery.value, + statusFilter = _downloadStatusFilter.value + ) + } + } + is Resource.Error -> { + _isLoading.value = false + _errorMessage.value = resource.message + val isOffline = resource.message.contains("Connection refused", ignoreCase = true) || + resource.message.contains("Unable to resolve host", ignoreCase = true) || + resource.message.contains("timeout", ignoreCase = true) + if (_downloadStatusFilter.value == DownloadStatusFilter.FAVORITE) { + applyFilter() + _uiState.value = ProductRemoteUiState.Success( + products = _savedProducts.value.map { it.toDataItemResponse() }, + filteredProducts = _filteredProducts.value, + searchQuery = _searchQuery.value, + statusFilter = _downloadStatusFilter.value + ) + } else { + _uiState.value = ProductRemoteUiState.Error( + message = resource.message, + isOffline = isOffline + ) + } + } + } + } + } + } + + fun retry() { + fetchProducts() + } + + fun onSearchQueryChanged(query: String) { + _searchQuery.value = query + applyFilter() + updateSuccessUiState() + } + + fun onDownloadStatusFilterChanged(filter: DownloadStatusFilter) { + _downloadStatusFilter.value = filter + applyFilter() + updateSuccessUiState() + } + + fun setInitialFilter(filter: DownloadStatusFilter) { + _downloadStatusFilter.value = filter + applyFilter() + updateSuccessUiState() + } + + private fun updateSuccessUiState() { + if (_downloadStatusFilter.value == DownloadStatusFilter.FAVORITE || _rawProducts.value.isNotEmpty()) { + _uiState.value = ProductRemoteUiState.Success( + products = if (_downloadStatusFilter.value == DownloadStatusFilter.FAVORITE) { + _savedProducts.value.map { it.toDataItemResponse() } + } else { + _rawProducts.value + }, + filteredProducts = _filteredProducts.value, + searchQuery = _searchQuery.value, + statusFilter = _downloadStatusFilter.value + ) + } + } + + private fun applyFilter() { + val query = _searchQuery.value.trim().lowercase() + val statusFilter = _downloadStatusFilter.value + val sourceItems = if (statusFilter == DownloadStatusFilter.FAVORITE) { + _savedProducts.value.map { it.toDataItemResponse() } + } else { + _rawProducts.value + } + + _filteredProducts.value = sourceItems.filter { item -> + val matchesStatus = when (statusFilter) { + DownloadStatusFilter.ALL -> true + DownloadStatusFilter.FAVORITE -> true + DownloadStatusFilter.DOWNLOADED -> item.isDownloaded + DownloadStatusFilter.NOT_DOWNLOADED -> !item.isDownloaded + } + + val matchesQuery = if (query.isBlank()) { + true + } else { + item.productName?.lowercase()?.contains(query) == true || + item.displayTitle.lowercase().contains(query) || + item.caption?.lowercase()?.contains(query) == true || + item.hook?.lowercase()?.contains(query) == true || + item.linkProduct?.lowercase()?.contains(query) == true || + item.originalFileName?.lowercase()?.contains(query) == true || + item.statusDownload?.lowercase()?.contains(query) == true || + item.displayBody?.lowercase()?.contains(query) == true + } + + matchesStatus && matchesQuery + } + } + + fun syncAllToRoomDb() { + viewModelScope.launch { + _isSyncing.value = true + productRemoteRepository.syncAllFromRemote().collect { resource -> + when (resource) { + is Resource.Loading -> { + _isSyncing.value = true + } + is Resource.Success -> { + _isSyncing.value = false + _syncMessage.value = "${resource.data} produk berhasil disimpan ke Room DB" + } + is Resource.Error -> { + _isSyncing.value = false + _syncMessage.value = "Gagal sinkronisasi: ${resource.message}" + } + } + } + } + } + + fun toggleSaveProduct(item: DataItemResponse) { + viewModelScope.launch { + val isNowSaved = productRemoteRepository.toggleSaveRemoteProduct(item) + _syncMessage.value = if (isNowSaved) { + "\"${item.displayTitle}\" disimpan ke Room DB (muncul di keyboard)" + } else { + "\"${item.displayTitle}\" dihapus dari Room DB" + } + if (_downloadStatusFilter.value == DownloadStatusFilter.FAVORITE) { + applyFilter() + updateSuccessUiState() + } + } + } + + fun createProduct(product: ProductEntity) { + viewModelScope.launch { + productRemoteRepository.saveProduct(product) + _syncMessage.value = "Produk \"${product.productName}\" berhasil ditambahkan" + // Re-fetch or refresh list to reflect new product + val asResponse = product.toDataItemResponse() + val currentList = _rawProducts.value.toMutableList() + currentList.add(0, asResponse) + _rawProducts.value = currentList + applyFilter() + updateSuccessUiState() + } + } + + fun updateProduct(product: ProductEntity) { + viewModelScope.launch { + productRemoteRepository.updateProduct(product) + _syncMessage.value = "Produk \"${product.productName}\" berhasil diperbarui" + // Update in raw list if matching + val asResponse = product.toDataItemResponse() + val currentList = _rawProducts.value.map { + if ((product.remoteId != null && it.id == product.remoteId) || it.displayIndex == product.id) { + asResponse + } else { + it + } + } + _rawProducts.value = currentList + applyFilter() + updateSuccessUiState() + } + } + + fun deleteProduct(item: DataItemResponse) { + viewModelScope.launch { + val remoteId = item.id + if (remoteId != null) { + productRemoteRepository.deleteProductByRemoteId(remoteId) + } + val matchingLocal = remoteId?.let { id -> _savedProducts.value.find { it.remoteId == id } } + val localId = matchingLocal?.id ?: item.displayIndex + productRemoteRepository.deleteProductById(localId) + + _syncMessage.value = "Produk \"${item.displayTitle}\" dihapus dari database" + val currentList = _rawProducts.value.filterNot { it.id == item.id && it.displayIndex == item.displayIndex } + _rawProducts.value = currentList + applyFilter() + updateSuccessUiState() + } + } + + fun nukeAllFavoriteProducts() { + viewModelScope.launch { + productRemoteRepository.nukeAllSavedProducts() + _syncMessage.value = "Semua produk favorit berhasil dihapus dari Room DB" + if (_downloadStatusFilter.value == DownloadStatusFilter.FAVORITE) { + applyFilter() + updateSuccessUiState() + } + } + } + + fun downloadVideo(context: Context, item: DataItemResponse) { + val itemId = item.id ?: item.productName ?: item.rowIndex?.toString() ?: "item_${System.currentTimeMillis()}" + val currentState = _downloadStates.value[itemId] + if (currentState is DownloadProgressState.Downloading) { + return + } + + val directUrl = DriveUrlHelper.getDirectDownloadUrl(item.driveFileId, item.driveLink) + if (directUrl.isNullOrBlank()) { + val msg = "URL Google Drive tidak ditemukan atau tidak valid" + _downloadStates.value = _downloadStates.value + (itemId to DownloadProgressState.Error(msg)) + _errorMessage.value = msg + return + } + + val fileName = DriveUrlHelper.sanitizeFileName(item.originalFileName, item.productName, item.driveFileId) + + viewModelScope.launch { + _downloadStates.value = _downloadStates.value + (itemId to DownloadProgressState.Downloading(0)) + + val result = VideoDownloader.downloadVideo( + context = context.applicationContext, + downloadUrl = directUrl, + fileName = fileName, + onProgress = { progress -> + _downloadStates.value = _downloadStates.value + (itemId to DownloadProgressState.Downloading(progress)) + } + ) + + result.fold( + onSuccess = { destination -> + _downloadStates.value = _downloadStates.value + (itemId to DownloadProgressState.Success(destination)) + + productRemoteRepository.updateDownloadStatus(item.id, 0, "Sudah") + + _rawProducts.value = _rawProducts.value.map { current -> + if (current.id == item.id || (item.id == null && current.productName == item.productName)) { + current.copy(statusDownload = "Sudah") + } else { + current + } + } + applyFilter() + updateSuccessUiState() + + _syncMessage.value = "Video \"$fileName\" berhasil diunduh ke Galeri!" + }, + onFailure = { error -> + val errorMsg = error.message ?: "Gagal mengunduh video" + _downloadStates.value = _downloadStates.value + (itemId to DownloadProgressState.Error(errorMsg)) + _errorMessage.value = errorMsg + } + ) + } + } + + fun clearSyncMessage() { + _syncMessage.value = null + } + +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/templatetext/TemplateTextActivity.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/templatetext/TemplateTextActivity.kt new file mode 100644 index 0000000..1b9f4d7 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/templatetext/TemplateTextActivity.kt @@ -0,0 +1,71 @@ +package com.frogobox.appkeyboard.ui.templatetext + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.frogobox.appkeyboard.common.base.BaseComposeActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TemplateTextActivity : BaseComposeActivity() { + + companion object { + const val EXTRA_INITIAL_CATEGORY = "EXTRA_INITIAL_CATEGORY" + } + + private val viewModel: TemplateTextViewModel by viewModels() + + override fun onCreateExt(savedInstanceState: Bundle?) { + super.onCreateExt(savedInstanceState) + val initialCategory = intent.getStringExtra(EXTRA_INITIAL_CATEGORY) + if (!initialCategory.isNullOrBlank()) { + viewModel.setCategory(initialCategory) + } + } + + @Composable + override fun Content() { + val templateList by viewModel.templateList.collectAsState() + val selectedCategory by viewModel.selectedCategory.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val eventMessage by viewModel.eventMessage.collectAsState() + + LaunchedEffect(eventMessage) { + eventMessage?.let { msg -> + Toast.makeText(this@TemplateTextActivity, msg, Toast.LENGTH_SHORT).show() + viewModel.clearEventMessage() + } + } + + TemplateTextScreen( + templateList = templateList, + selectedCategory = selectedCategory, + searchQuery = searchQuery, + isLoading = isLoading, + onCategorySelected = { category -> + viewModel.setCategory(category) + }, + onSearchQueryChange = { query -> + viewModel.setSearchQuery(query) + }, + onAddTemplate = { category, text -> + viewModel.insertTemplate(category, text) + }, + onUpdateTemplate = { id, category, text -> + viewModel.updateTemplate(id, category, text) + }, + onDeleteTemplate = { id -> + viewModel.deleteTemplate(id) + }, + onResetAllToDefaults = { + viewModel.resetAllToDefaults() + }, + onBackClick = { finish() } + ) + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/templatetext/TemplateTextScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/templatetext/TemplateTextScreen.kt new file mode 100644 index 0000000..56d188d --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/templatetext/TemplateTextScreen.kt @@ -0,0 +1,815 @@ +package com.frogobox.appkeyboard.ui.templatetext + +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.LazyRow +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.filled.Add +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Warning +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.CircularProgressIndicator +import androidx.compose.material3.ExtendedFloatingActionButton +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.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.unit.dp +import androidx.compose.ui.unit.sp +import com.frogobox.appkeyboard.model.TemplateCategoryType +import com.frogobox.appkeyboard.model.TemplateTextEntity +import com.frogobox.appkeyboard.ui.theme.compose.FrogoEmptyView +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 TemplateTextScreen( + templateList: List, + selectedCategory: String?, + searchQuery: String, + isLoading: Boolean, + onCategorySelected: (String?) -> Unit, + onSearchQueryChange: (String) -> Unit, + onAddTemplate: (category: String, text: String) -> Unit, + onUpdateTemplate: (id: Int, category: String, text: String) -> Unit, + onDeleteTemplate: (id: Int) -> Unit, + onResetAllToDefaults: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var isAddDialogOpen by remember { mutableStateOf(false) } + var itemToEdit by remember { mutableStateOf(null) } + var itemToDelete by remember { mutableStateOf(null) } + var isResetConfirmOpen by remember { mutableStateOf(false) } + + val filteredList = remember(templateList, searchQuery) { + if (searchQuery.isBlank()) { + templateList + } else { + templateList.filter { + it.text.contains(searchQuery, ignoreCase = true) + } + } + } + + val onCopySnippet: (TemplateTextEntity) -> Unit = { item -> + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Template Text", item.text) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Disalin ke clipboard", Toast.LENGTH_SHORT).show() + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + FrogoTopAppBar( + title = "Template Teks", + onBackClick = onBackClick, + actions = { + IconButton( + onClick = { isResetConfirmOpen = true } + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Reset Default", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { isAddDialogOpen = true }, + containerColor = FrogoPrimary, + contentColor = Color.White, + shape = RoundedCornerShape(12.dp), + icon = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Tambah Template" + ) + }, + text = { + Text( + text = "Tambah Template", + style = MaterialTheme.typography.labelLarge.copy( + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + ) + } + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Header Stats Banner + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + TemplateHeaderBanner( + totalCount = templateList.size, + filteredCount = filteredList.size, + activeCategory = selectedCategory, + isSearching = searchQuery.isNotBlank() + ) + + Spacer(modifier = Modifier.height(10.dp)) + + // Search Bar + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = "Cari kata kunci template...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Cari", + tint = FrogoPrimary, + modifier = Modifier.size(20.dp) + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { onSearchQueryChange("") }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Hapus Pencarian", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(10.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + focusedBorderColor = FrogoPrimary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant + ) + ) + } + + // Category Filter Chips Horizontal Row + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + val isSelected = selectedCategory == null + FilterChip( + selected = isSelected, + onClick = { onCategorySelected(null) }, + label = { + Text( + text = "Semua", + fontSize = 13.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + }, + shape = RoundedCornerShape(8.dp), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = isSelected, + borderColor = MaterialTheme.colorScheme.outlineVariant, + selectedBorderColor = FrogoPrimary.copy(alpha = 0.6f) + ) + ) + } + + items(TemplateCategoryType.entries) { category -> + val isSelected = selectedCategory == category.key + FilterChip( + selected = isSelected, + onClick = { onCategorySelected(category.key) }, + label = { + Text( + text = "${category.icon} ${category.title}", + fontSize = 13.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + }, + shape = RoundedCornerShape(8.dp), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = isSelected, + borderColor = MaterialTheme.colorScheme.outlineVariant, + selectedBorderColor = FrogoPrimary.copy(alpha = 0.6f) + ) + ) + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + // Main Content Area + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f) + ) { + if (isLoading && templateList.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = FrogoPrimary, + strokeWidth = 3.dp, + modifier = Modifier.size(36.dp) + ) + } + } else if (filteredList.isEmpty()) { + FrogoEmptyView( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + title = if (searchQuery.isNotBlank()) "Tidak Ditemukan" else "Belum Ada Template", + subtitle = if (searchQuery.isNotBlank()) { + "Tidak ditemukan template dengan kata kunci \"$searchQuery\"" + } else { + "Belum ada template pada kategori ini. Tekan tombol Tambah Template di bawah untuk membuat baru." + } + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 88.dp + ), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + items( + items = filteredList, + key = { it.id } + ) { template -> + TemplateItemCard( + template = template, + onCopy = { onCopySnippet(template) }, + onEdit = { itemToEdit = template }, + onDelete = { itemToDelete = template } + ) + } + } + } + } + } + } + + // Add Template Dialog + if (isAddDialogOpen) { + TemplateTextEditorDialog( + isEditMode = false, + initialCategory = selectedCategory ?: TemplateCategoryType.GAME.key, + initialText = "", + onDismiss = { isAddDialogOpen = false }, + onSave = { category, text -> + onAddTemplate(category, text) + isAddDialogOpen = false + } + ) + } + + // Edit Template Dialog + itemToEdit?.let { item -> + TemplateTextEditorDialog( + isEditMode = true, + initialCategory = item.category, + initialText = item.text, + onDismiss = { itemToEdit = null }, + onSave = { category, text -> + onUpdateTemplate(item.id, category, text) + itemToEdit = null + } + ) + } + + // Delete Confirmation Dialog + itemToDelete?.let { item -> + AlertDialog( + onDismissRequest = { itemToDelete = null }, + icon = { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = "Konfirmasi Hapus", + tint = FrogoStatusFailed, + modifier = Modifier.size(28.dp) + ) + }, + title = { + Text( + text = "Hapus Template?", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold) + ) + }, + text = { + Text( + text = "Template ini akan dihapus secara permanen:\n\n\"${item.text}\"", + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + Button( + onClick = { + onDeleteTemplate(item.id) + itemToDelete = null + }, + colors = ButtonDefaults.buttonColors(containerColor = FrogoStatusFailed), + shape = RoundedCornerShape(8.dp) + ) { + Text("Hapus", color = Color.White, fontWeight = FontWeight.SemiBold) + } + }, + dismissButton = { + TextButton(onClick = { itemToDelete = null }) { + Text("Batal") + } + }, + shape = RoundedCornerShape(16.dp), + containerColor = MaterialTheme.colorScheme.surface + ) + } + + // Reset Confirmation Dialog + if (isResetConfirmOpen) { + AlertDialog( + onDismissRequest = { isResetConfirmOpen = false }, + icon = { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Reset", + tint = FrogoPrimary, + modifier = Modifier.size(28.dp) + ) + }, + title = { + Text( + text = "Reset Semua Template?", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold) + ) + }, + text = { + Text( + text = "Seluruh template kustom akan diganti dengan data preset standar bawaan aplikasi.", + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + Button( + onClick = { + onResetAllToDefaults() + isResetConfirmOpen = false + }, + colors = ButtonDefaults.buttonColors(containerColor = FrogoPrimary), + shape = RoundedCornerShape(8.dp) + ) { + Text("Reset Sekarang", color = Color.White, fontWeight = FontWeight.SemiBold) + } + }, + dismissButton = { + TextButton(onClick = { isResetConfirmOpen = false }) { + Text("Batal") + } + }, + shape = RoundedCornerShape(16.dp), + containerColor = MaterialTheme.colorScheme.surface + ) + } +} + +@Composable +private fun TemplateHeaderBanner( + totalCount: Int, + filteredCount: Int, + activeCategory: String?, + isSearching: Boolean, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(38.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(20.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (activeCategory == null) "Semua Kategori" else "Kategori: ${TemplateCategoryType.fromKey(activeCategory).title}", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = if (isSearching) { + "Menampilkan $filteredCount dari $totalCount template" + } else { + "Total $totalCount template tersedia untuk keyboard" + }, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp, + lineHeight = 16.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun TemplateItemCard( + template: TemplateTextEntity, + onCopy: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier +) { + val categoryType = TemplateCategoryType.fromKey(template.category) + + val categoryColor = when (categoryType) { + TemplateCategoryType.GAME -> FrogoPrimary + TemplateCategoryType.APP -> Color(0xFF4F46E5) + TemplateCategoryType.SALE -> Color(0xFF15803D) + TemplateCategoryType.GREETING -> Color(0xFFB45309) + TemplateCategoryType.LOVE -> Color(0xFFBE123C) + } + + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp) + ) { + // Category Badge Header Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(6.dp), + color = categoryColor.copy(alpha = 0.12f) + ) { + Text( + text = "${categoryType.icon} ${categoryType.title}", + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 11.sp + ), + color = categoryColor, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp) + ) + } + + Text( + text = "#${template.id}", + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + // Body Text + Text( + text = template.text, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + lineHeight = 20.sp + ), + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Action Buttons Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = onCopy, + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + border = BorderStroke(0.8.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.height(32.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Salin", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Salin", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + OutlinedButton( + onClick = onEdit, + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + border = BorderStroke(0.8.dp, FrogoPrimary.copy(alpha = 0.5f)), + modifier = Modifier.height(32.dp) + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Ubah", + modifier = Modifier.size(14.dp), + tint = FrogoPrimary + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Ubah", + fontSize = 12.sp, + color = FrogoPrimary, + fontWeight = FontWeight.SemiBold + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + IconButton( + onClick = onDelete, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.DeleteOutline, + contentDescription = "Hapus", + tint = FrogoStatusFailed, + modifier = Modifier.size(18.dp) + ) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun TemplateTextEditorDialog( + isEditMode: Boolean, + initialCategory: String, + initialText: String, + onDismiss: () -> Unit, + onSave: (category: String, text: String) -> Unit +) { + var selectedCategory by remember { mutableStateOf(initialCategory) } + var textInput by remember { mutableStateOf(initialText) } + val isValid = textInput.trim().isNotEmpty() + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = if (isEditMode) "Ubah Template" else "Tambah Template Baru", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold) + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Pilih Kategori:", + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(6.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + TemplateCategoryType.entries.forEach { cat -> + val isSelected = selectedCategory.equals(cat.key, ignoreCase = true) + Surface( + shape = RoundedCornerShape(8.dp), + color = if (isSelected) FrogoPrimary else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = BorderStroke( + 1.dp, + if (isSelected) FrogoPrimary else MaterialTheme.colorScheme.outlineVariant + ), + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { selectedCategory = cat.key } + ) { + Text( + text = "${cat.icon} ${cat.title}", + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 11.5.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ), + color = if (isSelected) Color.White else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(14.dp)) + + Text( + text = "Teks Template:", + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(6.dp)) + + OutlinedTextField( + value = textInput, + onValueChange = { + if (it.length <= 500) textInput = it + }, + placeholder = { + Text( + text = "Ketik teks template pesan di sini...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 6, + shape = RoundedCornerShape(10.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + focusedBorderColor = FrogoPrimary, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant + ) + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "${textInput.length} / 500 karakter", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.align(Alignment.End) + ) + } + }, + confirmButton = { + Button( + onClick = { + if (isValid) { + onSave(selectedCategory, textInput) + } + }, + enabled = isValid, + colors = ButtonDefaults.buttonColors( + containerColor = FrogoPrimary, + disabledContainerColor = FrogoPrimary.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = if (isEditMode) "Simpan Perubahan" else "Tambahkan", + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Batal") + } + }, + shape = RoundedCornerShape(16.dp), + containerColor = MaterialTheme.colorScheme.surface + ) +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/templatetext/TemplateTextViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/templatetext/TemplateTextViewModel.kt new file mode 100644 index 0000000..99d15e1 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/templatetext/TemplateTextViewModel.kt @@ -0,0 +1,138 @@ +package com.frogobox.appkeyboard.ui.templatetext + +import androidx.lifecycle.viewModelScope +import com.frogobox.appkeyboard.common.base.BaseViewModel +import com.frogobox.appkeyboard.model.TemplateCategoryType +import com.frogobox.appkeyboard.model.TemplateTextEntity +import com.frogobox.appkeyboard.repository.templatetext.TemplateTextRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class TemplateTextViewModel @Inject constructor( + private val repository: TemplateTextRepository +) : BaseViewModel() { + + private val _templateList = MutableStateFlow>(emptyList()) + val templateList: StateFlow> = _templateList.asStateFlow() + + private val _selectedCategory = MutableStateFlow(null) // null = ALL + val selectedCategory: StateFlow = _selectedCategory.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _eventMessage = MutableStateFlow(null) + val eventMessage: StateFlow = _eventMessage.asStateFlow() + + private var collectJob: Job? = null + + init { + viewModelScope.launch { + repository.seedDefaultsIfEmpty() + loadTemplates() + } + } + + fun setCategory(category: String?) { + _selectedCategory.value = category + loadTemplates() + } + + fun setSearchQuery(query: String) { + _searchQuery.value = query + } + + fun loadTemplates() { + collectJob?.cancel() + val category = _selectedCategory.value + val flow = if (category.isNullOrBlank()) { + repository.getAll() + } else { + val normalizedKey = TemplateCategoryType.fromKey(category).key + repository.getByCategory(normalizedKey) + } + + collectJob = viewModelScope.launch { + flow.onStart { _isLoading.value = true } + .catch { + _isLoading.value = false + _eventMessage.value = "Gagal memuat template teks" + } + .collect { items -> + _isLoading.value = false + _templateList.value = items + } + } + } + + fun insertTemplate(category: String, text: String) { + if (text.isBlank()) return + viewModelScope.launch { + val normalizedKey = TemplateCategoryType.fromKey(category).key + val id = repository.insertTemplate(normalizedKey, text.trim()) + if (id > 0) { + _eventMessage.value = "Template berhasil ditambahkan" + } else { + _eventMessage.value = "Gagal menambahkan template" + } + } + } + + fun updateTemplate(id: Int, category: String, text: String) { + if (text.isBlank()) return + viewModelScope.launch { + val normalizedKey = TemplateCategoryType.fromKey(category).key + val success = repository.updateTemplate(id, normalizedKey, text.trim()) + if (success) { + _eventMessage.value = "Template berhasil diperbarui" + } else { + _eventMessage.value = "Gagal memperbarui template" + } + } + } + + fun deleteTemplate(id: Int) { + viewModelScope.launch { + val success = repository.deleteTemplate(id) + if (success) { + _eventMessage.value = "Template berhasil dihapus" + } else { + _eventMessage.value = "Gagal menghapus template" + } + } + } + + fun resetCategoryToDefaults(category: String) { + viewModelScope.launch { + _isLoading.value = true + val normalizedKey = TemplateCategoryType.fromKey(category).key + repository.resetCategoryToDefaults(normalizedKey) + _isLoading.value = false + _eventMessage.value = "Template kategori $normalizedKey berhasil direset ke default" + } + } + + fun resetAllToDefaults() { + viewModelScope.launch { + _isLoading.value = true + repository.resetAllToDefaults() + _isLoading.value = false + _eventMessage.value = "Semua template berhasil direset ke default" + } + } + + fun clearEventMessage() { + _eventMessage.value = null + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestScreen.kt index 2a643b6..ba7516a 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/test/TestScreen.kt @@ -44,7 +44,6 @@ 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 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 accb896..c3ba8f5 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 @@ -140,14 +140,14 @@ class TestViewModel @Inject constructor( _remoteApiUiState.value = DataApiUiState.Loading } is Resource.Success -> { - val items = resource.data.data ?: emptyList() + val items = resource.data.items ?: emptyList() val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).apply { timeZone = TimeZone.getTimeZone("Asia/Jakarta") } val nowWib = "${sdf.format(Date())} WIB" _remoteApiUiState.value = DataApiUiState.Success( total = resource.data.total ?: items.size, - lastUpdatedWib = resource.data.lastUpdated ?: nowWib, + lastUpdatedWib = resource.data.lastUpdatedWib ?: resource.data.lastUpdated ?: nowWib, items = items ) } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeScreen.kt index 3dc2ad6..4cebb68 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/theme/ThemeScreen.kt @@ -62,6 +62,7 @@ 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.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight @@ -73,6 +74,7 @@ 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.model.isThemeDark import com.frogobox.appkeyboard.ui.theme.compose.FrogoKeyboardTheme import com.frogobox.appkeyboard.ui.theme.compose.FrogoPrimary import com.frogobox.appkeyboard.ui.theme.compose.FrogoStatusSuccess @@ -311,6 +313,20 @@ private fun ThemePreviewHero( onApplyClick: () -> Unit, modifier: Modifier = Modifier ) { + val context = LocalContext.current + val isDark = remember(theme) { isThemeDark(context, theme) } + var typedPreviewText by remember(theme.name) { mutableStateOf("") } + + val keyTextColor = if (isDark) Color.White else Color(0xFF0F172A) + val keyBgColor = if (isDark) Color.White.copy(alpha = 0.22f) else Color.White.copy(alpha = 0.90f) + val keyStrokeColor = if (isDark) Color.White.copy(alpha = 0.35f) else Color(0xFFCBD5E1) + val specialKeyBgColor = if (isDark) Color.Black.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.08f) + val specialKeyIconColor = if (isDark) Color.White else Color(0xFF0F172A) + val toolbarIconColor = if (isDark) Color.White.copy(alpha = 0.9f) else Color(0xFF0F172A).copy(alpha = 0.85f) + val brandTitleColor = if (isDark) Color.White.copy(alpha = 0.85f) else Color(0xFF0F172A).copy(alpha = 0.85f) + val spacebarBgColor = if (isDark) Color.White.copy(alpha = 0.28f) else Color.White.copy(alpha = 0.92f) + val spacebarTextColor = if (isDark) Color.White.copy(alpha = 0.9f) else Color(0xFF0F172A) + Card( modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), @@ -480,10 +496,10 @@ private fun ThemePreviewHero( 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) + MockToolIcon(Icons.Default.SentimentSatisfied, iconTint = toolbarIconColor) + MockToolIcon(Icons.Default.TextFields, iconTint = toolbarIconColor) + MockToolIcon(Icons.Default.Language, iconTint = toolbarIconColor) + MockToolIcon(Icons.Default.Settings, iconTint = toolbarIconColor) } Text( @@ -491,18 +507,73 @@ private fun ThemePreviewHero( style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Bold, fontSize = 10.sp, - color = Color.White.copy(alpha = 0.85f) + color = brandTitleColor ) ) } + // Live Interactive Typing Test Bar (Enhancement) + Surface( + shape = RoundedCornerShape(8.dp), + color = if (isDark) Color.Black.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.88f), + border = BorderStroke(1.dp, if (isDark) Color.White.copy(alpha = 0.2f) else Color(0xFFCBD5E1)), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = if (typedPreviewText.isEmpty()) "Ketik di sini untuk uji coba tema..." else typedPreviewText, + style = MaterialTheme.typography.bodySmall.copy( + fontWeight = if (typedPreviewText.isEmpty()) FontWeight.Normal else FontWeight.Medium, + fontSize = 12.sp + ), + color = if (typedPreviewText.isEmpty()) { + if (isDark) Color.White.copy(alpha = 0.5f) else Color(0xFF64748B) + } else { + keyTextColor + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + if (typedPreviewText.isNotEmpty()) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Backspace, + contentDescription = "Hapus teks", + tint = if (isDark) Color.White.copy(alpha = 0.7f) else Color(0xFF64748B), + modifier = Modifier + .size(16.dp) + .clip(CircleShape) + .clickable { typedPreviewText = "" } + ) + } + } + } + // 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")) + MockKeyRow( + keys = listOf("Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"), + textColor = keyTextColor, + backgroundColor = keyBgColor, + strokeColor = keyStrokeColor, + onKeyClick = { letter -> typedPreviewText += letter } + ) // 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 + horizontalPadding = 12.dp, + textColor = keyTextColor, + backgroundColor = keyBgColor, + strokeColor = keyStrokeColor, + onKeyClick = { letter -> typedPreviewText += letter } ) // Keycap Row 3: Shift, Z X C V B N M, Backspace @@ -513,17 +584,32 @@ private fun ThemePreviewHero( ) { MockTextKey( text = "โ‡ง", - weight = 1.3f + textColor = specialKeyIconColor, + backgroundColor = specialKeyBgColor, + strokeColor = keyStrokeColor, + modifier = Modifier.weight(1.3f) ) listOf("Z", "X", "C", "V", "B", "N", "M").forEach { letter -> MockKey( text = letter, - modifier = Modifier.weight(1f) + textColor = keyTextColor, + backgroundColor = keyBgColor, + strokeColor = keyStrokeColor, + modifier = Modifier.weight(1f), + onClick = { typedPreviewText += letter } ) } MockSpecialKey( icon = Icons.AutoMirrored.Filled.Backspace, - weight = 1.3f + iconTint = specialKeyIconColor, + backgroundColor = specialKeyBgColor, + strokeColor = keyStrokeColor, + modifier = Modifier.weight(1.3f), + onClick = { + if (typedPreviewText.isNotEmpty()) { + typedPreviewText = typedPreviewText.dropLast(1) + } + } ) } @@ -533,10 +619,33 @@ private fun ThemePreviewHero( 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) + MockTextKey( + text = "?123", + textColor = specialKeyIconColor, + backgroundColor = specialKeyBgColor, + strokeColor = keyStrokeColor, + modifier = Modifier.weight(1.4f) + ) + MockIconKey( + icon = Icons.Default.Language, + iconTint = specialKeyIconColor, + backgroundColor = specialKeyBgColor, + strokeColor = keyStrokeColor, + modifier = Modifier.weight(1f) + ) + MockSpacebarKey( + text = "English", + textColor = spacebarTextColor, + backgroundColor = spacebarBgColor, + strokeColor = keyStrokeColor, + modifier = Modifier.weight(4.2f), + onClick = { typedPreviewText += " " } + ) + MockActionKey( + icon = Icons.AutoMirrored.Filled.KeyboardReturn, + modifier = Modifier.weight(1.6f), + onClick = { typedPreviewText += " " } + ) } } } @@ -634,6 +743,12 @@ private fun ThemeGridCard( label = "cardBorderColor" ) + val context = LocalContext.current + val isDark = remember(theme) { isThemeDark(context, theme) } + val silhouetteColor = if (isDark) Color.White.copy(alpha = 0.28f) else Color(0xFF0F172A).copy(alpha = 0.15f) + val silhouetteSpecialColor = if (isDark) Color.White.copy(alpha = 0.35f) else Color(0xFF0F172A).copy(alpha = 0.22f) + val silhouetteSpaceColor = if (isDark) Color.White.copy(alpha = 0.38f) else Color(0xFF0F172A).copy(alpha = 0.25f) + Card( modifier = modifier .fillMaxWidth() @@ -691,7 +806,7 @@ private fun ThemeGridCard( .weight(1f) .height(14.dp) .clip(RoundedCornerShape(2.dp)) - .background(Color.White.copy(alpha = 0.28f)) + .background(silhouetteColor) ) } } @@ -708,7 +823,7 @@ private fun ThemeGridCard( .weight(1f) .height(14.dp) .clip(RoundedCornerShape(2.dp)) - .background(Color.White.copy(alpha = 0.28f)) + .background(silhouetteColor) ) } } @@ -722,7 +837,7 @@ private fun ThemeGridCard( .weight(1.3f) .height(14.dp) .clip(RoundedCornerShape(2.dp)) - .background(Color.White.copy(alpha = 0.35f)) + .background(silhouetteSpecialColor) ) repeat(7) { Box( @@ -730,7 +845,7 @@ private fun ThemeGridCard( .weight(1f) .height(14.dp) .clip(RoundedCornerShape(2.dp)) - .background(Color.White.copy(alpha = 0.28f)) + .background(silhouetteColor) ) } Box( @@ -738,7 +853,7 @@ private fun ThemeGridCard( .weight(1.3f) .height(14.dp) .clip(RoundedCornerShape(2.dp)) - .background(Color.White.copy(alpha = 0.35f)) + .background(silhouetteSpecialColor) ) } @@ -751,14 +866,14 @@ private fun ThemeGridCard( .weight(1.5f) .height(14.dp) .clip(RoundedCornerShape(2.dp)) - .background(Color.White.copy(alpha = 0.28f)) + .background(silhouetteColor) ) Box( modifier = Modifier .weight(4.5f) .height(14.dp) .clip(RoundedCornerShape(2.dp)) - .background(Color.White.copy(alpha = 0.38f)) + .background(silhouetteSpaceColor) ) Box( modifier = Modifier @@ -857,18 +972,23 @@ private fun ThemeGridCard( @Composable private fun MockToolIcon( icon: ImageVector, + iconTint: Color, modifier: Modifier = Modifier ) { Surface( shape = CircleShape, - color = Color.Black.copy(alpha = 0.25f), + color = if (iconTint == Color.White || (iconTint.red > 0.8f && iconTint.green > 0.8f && iconTint.blue > 0.8f)) { + Color.Black.copy(alpha = 0.25f) + } else { + Color.Black.copy(alpha = 0.08f) + }, modifier = modifier.size(20.dp) ) { Box(contentAlignment = Alignment.Center) { Icon( imageVector = icon, contentDescription = null, - tint = Color.White.copy(alpha = 0.9f), + tint = iconTint, modifier = Modifier.size(12.dp) ) } @@ -878,8 +998,12 @@ private fun MockToolIcon( @Composable private fun MockKeyRow( keys: List, + textColor: Color, + backgroundColor: Color, + strokeColor: Color, horizontalPadding: androidx.compose.ui.unit.Dp = 0.dp, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onKeyClick: (String) -> Unit = {} ) { Row( modifier = modifier @@ -891,7 +1015,11 @@ private fun MockKeyRow( keys.forEach { letter -> MockKey( text = letter, - modifier = Modifier.weight(1f) + textColor = textColor, + backgroundColor = backgroundColor, + strokeColor = strokeColor, + modifier = Modifier.weight(1f), + onClick = { onKeyClick(letter) } ) } } @@ -900,13 +1028,20 @@ private fun MockKeyRow( @Composable private fun MockKey( text: String, - modifier: Modifier = Modifier + textColor: Color, + backgroundColor: Color, + strokeColor: Color, + modifier: Modifier = Modifier, + onClick: () -> Unit = {} ) { 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) + color = backgroundColor, + border = BorderStroke(0.5.dp, strokeColor), + modifier = modifier + .height(28.dp) + .clip(RoundedCornerShape(5.dp)) + .clickable(onClick = onClick) ) { Box(contentAlignment = Alignment.Center) { Text( @@ -914,7 +1049,7 @@ private fun MockKey( style = MaterialTheme.typography.labelMedium.copy( fontWeight = FontWeight.Bold, fontSize = 11.sp, - color = Color.White + color = textColor ) ) } @@ -924,16 +1059,20 @@ private fun MockKey( @Composable private fun MockSpecialKey( icon: ImageVector, - weight: Float, - modifier: Modifier = Modifier + iconTint: Color, + backgroundColor: Color, + strokeColor: Color, + modifier: Modifier = Modifier, + onClick: () -> Unit = {} ) { Surface( shape = RoundedCornerShape(5.dp), - color = Color.Black.copy(alpha = 0.25f), - border = BorderStroke(0.5.dp, Color.White.copy(alpha = 0.3f)), + color = backgroundColor, + border = BorderStroke(0.5.dp, strokeColor), modifier = modifier - .height(26.dp) - .then(Modifier.run { this }) + .height(28.dp) + .clip(RoundedCornerShape(5.dp)) + .clickable(onClick = onClick) ) { Box( contentAlignment = Alignment.Center, @@ -942,7 +1081,7 @@ private fun MockSpecialKey( Icon( imageVector = icon, contentDescription = null, - tint = Color.White, + tint = iconTint, modifier = Modifier.size(14.dp) ) } @@ -952,14 +1091,20 @@ private fun MockSpecialKey( @Composable private fun MockTextKey( text: String, - weight: Float, - modifier: Modifier = Modifier + textColor: Color, + backgroundColor: Color, + strokeColor: Color, + modifier: Modifier = Modifier, + onClick: () -> Unit = {} ) { 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) + color = backgroundColor, + border = BorderStroke(0.5.dp, strokeColor), + modifier = modifier + .height(28.dp) + .clip(RoundedCornerShape(5.dp)) + .clickable(onClick = onClick) ) { Box( contentAlignment = Alignment.Center, @@ -970,7 +1115,7 @@ private fun MockTextKey( style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Bold, fontSize = 10.sp, - color = Color.White + color = textColor ) ) } @@ -980,14 +1125,20 @@ private fun MockTextKey( @Composable private fun MockIconKey( icon: ImageVector, - weight: Float, - modifier: Modifier = Modifier + iconTint: Color, + backgroundColor: Color, + strokeColor: Color, + modifier: Modifier = Modifier, + onClick: () -> Unit = {} ) { 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) + color = backgroundColor, + border = BorderStroke(0.5.dp, strokeColor), + modifier = modifier + .height(28.dp) + .clip(RoundedCornerShape(5.dp)) + .clickable(onClick = onClick) ) { Box( contentAlignment = Alignment.Center, @@ -996,7 +1147,7 @@ private fun MockIconKey( Icon( imageVector = icon, contentDescription = null, - tint = Color.White, + tint = iconTint, modifier = Modifier.size(13.dp) ) } @@ -1006,14 +1157,20 @@ private fun MockIconKey( @Composable private fun MockSpacebarKey( text: String, - weight: Float, - modifier: Modifier = Modifier + textColor: Color, + backgroundColor: Color, + strokeColor: Color, + modifier: Modifier = Modifier, + onClick: () -> Unit = {} ) { 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) + color = backgroundColor, + border = BorderStroke(0.5.dp, strokeColor), + modifier = modifier + .height(28.dp) + .clip(RoundedCornerShape(5.dp)) + .clickable(onClick = onClick) ) { Box(contentAlignment = Alignment.Center) { Text( @@ -1021,7 +1178,7 @@ private fun MockSpacebarKey( style = MaterialTheme.typography.labelSmall.copy( fontWeight = FontWeight.Medium, fontSize = 10.sp, - color = Color.White.copy(alpha = 0.9f) + color = textColor ) ) } @@ -1031,13 +1188,16 @@ private fun MockSpacebarKey( @Composable private fun MockActionKey( icon: ImageVector, - weight: Float, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onClick: () -> Unit = {} ) { Surface( shape = RoundedCornerShape(5.dp), color = FrogoPrimary, - modifier = modifier.height(26.dp) + modifier = modifier + .height(28.dp) + .clip(RoundedCornerShape(5.dp)) + .clickable(onClick = onClick) ) { Box( contentAlignment = Alignment.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 index 520c08c..5d169c0 100644 --- 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 @@ -12,7 +12,10 @@ 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.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp /** * Reusable Material 3 TopAppBar for Frogo Keyboard app screens. @@ -23,15 +26,19 @@ fun FrogoTopAppBar( title: String, modifier: Modifier = Modifier, onBackClick: (() -> Unit)? = null, + titleStyle: TextStyle = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ), actions: @Composable RowScope.() -> Unit = {} ) { TopAppBar( title = { Text( text = title, - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold - ), + style = titleStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.onBackground ) }, 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 5e665b2..ecbb6e4 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 @@ -18,9 +18,11 @@ class ToggleActivity : BaseComposeActivity() { private var featureList by mutableStateOf>(emptyList()) private val toggleStates = mutableStateMapOf() + private var alwaysShowFeature by mutableStateOf(null) override fun onCreateExt(savedInstanceState: Bundle?) { super.onCreateExt(savedInstanceState) + alwaysShowFeature = viewModel.getAlwaysShowFeature() viewModel.keyboardFeatureState.observe(this) { list -> featureList = list list.forEach { feature -> @@ -39,6 +41,18 @@ class ToggleActivity : BaseComposeActivity() { toggleStates[id] = isChecked viewModel.switchToggle(id, isChecked) }, + onMoveFeature = { from, to -> + viewModel.moveFeature(from, to) + }, + onResetOrder = { + viewModel.resetFeatureOrder() + }, + alwaysShowFeatureId = alwaysShowFeature, + onToggleAlwaysShow = { featureId -> + val next = if (alwaysShowFeature == featureId) null else featureId + alwaysShowFeature = next + viewModel.setAlwaysShowFeature(next) + }, onBackClick = { finish() } ) } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt index 32e772a..0eaf062 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleScreen.kt @@ -22,8 +22,12 @@ 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.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward 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 @@ -64,7 +68,11 @@ fun ToggleScreen( getToggleState: (String) -> Boolean, onToggleChanged: (String, Boolean) -> Unit, onBackClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onMoveFeature: ((fromIndex: Int, toIndex: Int) -> Unit)? = null, + onResetOrder: (() -> Unit)? = null, + alwaysShowFeatureId: String? = null, + onToggleAlwaysShow: ((String) -> Unit)? = null ) { var selectedTab by remember { mutableStateOf(ToggleFilterTab.ALL) } @@ -106,7 +114,8 @@ fun ToggleScreen( }, onDisableAll = { features.forEach { onToggleChanged(it.id, false) } - } + }, + onResetOrder = onResetOrder ?: {} ) } @@ -152,12 +161,26 @@ fun ToggleScreen( } } else { items(filteredFeatures, key = { it.id }) { feature -> + val currentIndex = features.indexOf(feature) + val canMoveUp = onMoveFeature != null && currentIndex > 0 + val canMoveDown = onMoveFeature != null && currentIndex in 0 until (features.size - 1) + ToggleFeatureCard( feature = feature, isChecked = getToggleState(feature.id), onCheckedChange = { isChecked -> onToggleChanged(feature.id, isChecked) - } + }, + canMoveUp = canMoveUp, + canMoveDown = canMoveDown, + onMoveUp = { + onMoveFeature?.invoke(currentIndex, currentIndex - 1) + }, + onMoveDown = { + onMoveFeature?.invoke(currentIndex, currentIndex + 1) + }, + isAlwaysShow = (alwaysShowFeatureId == feature.id), + onToggleAlwaysShow = onToggleAlwaysShow?.let { cb -> { cb(feature.id) } } ) } } @@ -171,6 +194,7 @@ private fun ToggleHeaderBanner( activeCount: Int, onEnableAll: () -> Unit, onDisableAll: () -> Unit, + onResetOrder: () -> Unit = {}, modifier: Modifier = Modifier ) { Card( @@ -237,6 +261,22 @@ private fun ToggleHeaderBanner( horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { + TextButton( + onClick = onResetOrder, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = "Reset Urutan", + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Normal, + fontSize = 12.sp + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + TextButton( onClick = onEnableAll, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp) @@ -354,7 +394,13 @@ private fun ToggleFeatureCard( feature: KeyboardFeatureModel, isChecked: Boolean, onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + canMoveUp: Boolean = false, + canMoveDown: Boolean = false, + onMoveUp: () -> Unit = {}, + onMoveDown: () -> Unit = {}, + isAlwaysShow: Boolean = false, + onToggleAlwaysShow: (() -> Unit)? = null ) { val primaryColor = MaterialTheme.colorScheme.primary val surfaceColor = MaterialTheme.colorScheme.surface @@ -477,6 +523,92 @@ private fun ToggleFeatureCard( } ) } + + Spacer(modifier = Modifier.height(10.dp)) + + // Bottom action row: Always show (Pin) button + Re-order arrows + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Pin / Always Show toggle chip + Surface( + shape = RoundedCornerShape(8.dp), + color = if (isAlwaysShow) { + primaryColor.copy(alpha = 0.2f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + }, + border = if (isAlwaysShow) BorderStroke(1.dp, primaryColor) else null, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { onToggleAlwaysShow?.invoke() } + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_feature_pin), + contentDescription = if (isAlwaysShow) "Default Startup" else "Jadikan Default", + tint = if (isAlwaysShow) primaryColor else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(13.dp) + ) + Text( + text = if (isAlwaysShow) "Default" else "Pin", + fontSize = 10.sp, + fontWeight = if (isAlwaysShow) FontWeight.Bold else FontWeight.Medium, + color = if (isAlwaysShow) primaryColor else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Reorder / Move Buttons + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(6.dp)) + .background( + if (canMoveUp) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f) + ) + .clickable(enabled = canMoveUp) { onMoveUp() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Geser Maju", + tint = if (canMoveUp) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f), + modifier = Modifier.size(14.dp) + ) + } + + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(6.dp)) + .background( + if (canMoveDown) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f) + ) + .clickable(enabled = canMoveDown) { onMoveDown() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Geser Mundur", + tint = if (canMoveDown) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f), + modifier = Modifier.size(14.dp) + ) + } + } + } } } } diff --git a/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleViewModel.kt b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleViewModel.kt index 6e79dd3..69037e8 100644 --- a/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleViewModel.kt +++ b/app/src/main/java/com/frogobox/appkeyboard/ui/toggle/ToggleViewModel.kt @@ -40,4 +40,34 @@ class ToggleViewModel @Inject constructor( _keyboardFeatureState.postValue(keyboardUtil.menuToggle()) } + fun moveFeature(fromIndex: Int, toIndex: Int) { + val currentList = _keyboardFeatureState.value?.toMutableList() ?: return + if (fromIndex !in currentList.indices || toIndex !in currentList.indices || fromIndex == toIndex) return + val item = currentList.removeAt(fromIndex) + currentList.add(toIndex, item) + _keyboardFeatureState.value = currentList + keyboardUtil.saveFeatureOrder(currentList.map { it.id }) + } + + fun swapFeatures(fromIndex: Int, toIndex: Int) { + val currentList = _keyboardFeatureState.value?.toMutableList() ?: return + if (fromIndex !in currentList.indices || toIndex !in currentList.indices || fromIndex == toIndex) return + java.util.Collections.swap(currentList, fromIndex, toIndex) + _keyboardFeatureState.value = currentList + keyboardUtil.saveFeatureOrder(currentList.map { it.id }) + } + + fun resetFeatureOrder() { + keyboardUtil.resetFeatureOrder() + _keyboardFeatureState.value = keyboardUtil.menuToggle() + } + + fun getAlwaysShowFeature(): String? { + return keyboardUtil.getAlwaysShowFeature() + } + + fun setAlwaysShowFeature(featureId: String?) { + keyboardUtil.setAlwaysShowFeature(featureId) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/DriveUrlHelper.kt b/app/src/main/java/com/frogobox/appkeyboard/util/DriveUrlHelper.kt new file mode 100644 index 0000000..57a82de --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/util/DriveUrlHelper.kt @@ -0,0 +1,81 @@ +package com.frogobox.appkeyboard.util + +/** + * Utility for parsing and resolving Google Drive URLs and direct video download links. + */ +object DriveUrlHelper { + + private val DRIVE_FILE_ID_REGEX = Regex("""(?:/file/d/|/d/|[?&]id=)([a-zA-Z0-9_-]+)""") + + /** + * Extracts the Google Drive file ID from a URL or raw ID string. + */ + fun extractDriveFileId(input: String?): String? { + if (input.isNullOrBlank()) return null + val trimmed = input.trim() + + // Check if trimmed input is already an ID (alphanumeric, underscores, hyphens, typically >= 20 chars) + if (trimmed.matches(Regex("""^[a-zA-Z0-9_-]{20,}$""")) && !trimmed.startsWith("http", ignoreCase = true)) { + return trimmed + } + + val match = DRIVE_FILE_ID_REGEX.find(trimmed) + return match?.groupValues?.getOrNull(1) + } + + /** + * Constructs a direct download URL from either a Google Drive file ID or a drive link. + * Falls back to the original link if it's already a direct HTTP/HTTPS URL. + */ + fun getDirectDownloadUrl(driveFileId: String?, driveLink: String?): String? { + val fileId = driveFileId?.takeIf { it.isNotBlank() } + ?: extractDriveFileId(driveLink) + + return if (!fileId.isNullOrBlank()) { + "https://drive.usercontent.google.com/download?id=$fileId&export=download" + } else if (!driveLink.isNullOrBlank() && (driveLink.startsWith("http://") || driveLink.startsWith("https://"))) { + driveLink + } else { + null + } + } + + /** + * Secondary fallback download URL using standard Google Drive uc endpoint. + */ + fun getFallbackDownloadUrl(driveFileId: String?, driveLink: String?): String? { + val fileId = driveFileId?.takeIf { it.isNotBlank() } + ?: extractDriveFileId(driveLink) + + return if (!fileId.isNullOrBlank()) { + "https://drive.google.com/uc?export=download&id=$fileId" + } else { + getDirectDownloadUrl(driveFileId, driveLink) + } + } + + /** + * Sanitizes and produces a valid video file name. + */ + fun sanitizeFileName(originalFileName: String?, productName: String?, fileId: String? = null): String { + val candidate = when { + !originalFileName.isNullOrBlank() -> originalFileName.trim() + !productName.isNullOrBlank() -> "${productName.trim()}.mp4" + !fileId.isNullOrBlank() -> "video_$fileId.mp4" + else -> "video_${System.currentTimeMillis()}.mp4" + } + + // Replace illegal filesystem characters + val cleanName = candidate.replace(Regex("""[\\/:*?"<>|]"""), "_") + + return if (cleanName.endsWith(".mp4", ignoreCase = true) || + cleanName.endsWith(".mov", ignoreCase = true) || + cleanName.endsWith(".mkv", ignoreCase = true) || + cleanName.endsWith(".webm", ignoreCase = true) + ) { + cleanName + } else { + "$cleanName.mp4" + } + } +} diff --git a/app/src/main/java/com/frogobox/appkeyboard/util/VideoDownloader.kt b/app/src/main/java/com/frogobox/appkeyboard/util/VideoDownloader.kt new file mode 100644 index 0000000..69c5e88 --- /dev/null +++ b/app/src/main/java/com/frogobox/appkeyboard/util/VideoDownloader.kt @@ -0,0 +1,202 @@ +package com.frogobox.appkeyboard.util + +import android.content.ContentValues +import android.content.Context +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import com.frogobox.appkeyboard.data.remote.VideoDownloadService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.TimeUnit + +/** + * Modern Android & Retrofit video downloader supporting: + * - Scoped Storage via MediaStore on Android 10+ (API 29+) without requiring WRITE_EXTERNAL_STORAGE. + * - Legacy storage fallback with MediaScanner registration. + * - Retrofit @Streaming byte-by-byte piping to avoid OutOfMemoryError. + * - Automatic redirect following for Google Drive direct links. + * - Real-time progress callback (0% - 100%). + */ +object VideoDownloader { + + private val okHttpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .followRedirects(true) + .followSslRedirects(true) + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + } + + private val downloadService: VideoDownloadService by lazy { + Retrofit.Builder() + .baseUrl("https://drive.google.com/") + .client(okHttpClient) + .build() + .create(VideoDownloadService::class.java) + } + + /** + * Downloads a video from [downloadUrl] and saves it directly to Android public Movies/SellerKeyboard + * or app media storage, emitting progress through [onProgress]. + * + * @return Result containing the saved URI/Path as String on success, or exception on failure. + */ + suspend fun downloadVideo( + context: Context, + downloadUrl: String, + fileName: String, + onProgress: (Int) -> Unit = {} + ): Result = withContext(Dispatchers.IO) { + try { + val response = downloadService.downloadFile(downloadUrl) + if (!response.isSuccessful) { + return@withContext Result.failure( + IllegalStateException("HTTP ${response.code()}: ${response.message()}") + ) + } + + val body = response.body() + ?: return@withContext Result.failure(IllegalStateException("Response body is empty")) + + val totalBytes = body.contentLength() + val inputStream = body.byteStream() + + val saveResult = saveStreamToStorage(context, inputStream, fileName, totalBytes, onProgress) + saveResult + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun saveStreamToStorage( + context: Context, + inputStream: InputStream, + fileName: String, + totalBytes: Long, + onProgress: (Int) -> Unit + ): Result { + val cleanFileName = DriveUrlHelper.sanitizeFileName(fileName, null) + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveToMediaStoreQ(context, inputStream, cleanFileName, totalBytes, onProgress) + } else { + saveToLegacyFiles(context, inputStream, cleanFileName, totalBytes, onProgress) + } + } + + private fun saveToMediaStoreQ( + context: Context, + inputStream: InputStream, + fileName: String, + totalBytes: Long, + onProgress: (Int) -> Unit + ): Result { + val contentValues = ContentValues().apply { + put(MediaStore.Video.Media.DISPLAY_NAME, fileName) + put(MediaStore.Video.Media.MIME_TYPE, "video/mp4") + put(MediaStore.Video.Media.RELATIVE_PATH, "${Environment.DIRECTORY_MOVIES}/SellerKeyboard") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Video.Media.IS_PENDING, 1) + } + } + + val resolver = context.contentResolver + val uri: Uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues) + ?: return saveToLegacyFiles(context, inputStream, fileName, totalBytes, onProgress) + + return try { + resolver.openOutputStream(uri)?.use { outputStream -> + copyStreamWithProgress(inputStream, outputStream, totalBytes, onProgress) + } ?: throw IllegalStateException("Cannot open output stream for MediaStore URI") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + contentValues.clear() + contentValues.put(MediaStore.Video.Media.IS_PENDING, 0) + resolver.update(uri, contentValues, null, null) + } + + Result.success(uri.toString()) + } catch (e: Exception) { + resolver.delete(uri, null, null) + Result.failure(e) + } + } + + private fun saveToLegacyFiles( + context: Context, + inputStream: InputStream, + fileName: String, + totalBytes: Long, + onProgress: (Int) -> Unit + ): Result { + val targetDir = File( + context.getExternalFilesDir(Environment.DIRECTORY_MOVIES), + "SellerKeyboard" + ).apply { if (!exists()) mkdirs() } + + val targetFile = File(targetDir, fileName) + + return try { + FileOutputStream(targetFile).use { outputStream -> + copyStreamWithProgress(inputStream, outputStream, totalBytes, onProgress) + } + + MediaScannerConnection.scanFile( + context, + arrayOf(targetFile.absolutePath), + arrayOf("video/mp4"), + null + ) + + Result.success(targetFile.absolutePath) + } catch (e: Exception) { + if (targetFile.exists()) targetFile.delete() + Result.failure(e) + } + } + + private fun copyStreamWithProgress( + input: InputStream, + output: OutputStream, + totalBytes: Long, + onProgress: (Int) -> Unit + ) { + val buffer = ByteArray(8 * 1024) + var bytesCopied = 0L + var lastReportedPercent = -1 + + input.use { inStream -> + output.use { outStream -> + var bytes = inStream.read(buffer) + while (bytes >= 0) { + outStream.write(buffer, 0, bytes) + bytesCopied += bytes + + if (totalBytes > 0) { + val percent = ((bytesCopied * 100) / totalBytes).toInt().coerceIn(0, 100) + if (percent != lastReportedPercent) { + lastReportedPercent = percent + onProgress(percent) + } + } else { + onProgress(-1) // indeterminate + } + bytes = inStream.read(buffer) + } + outStream.flush() + } + } + onProgress(100) + } +} diff --git a/app/src/main/res/drawable/ic_feature_pin.xml b/app/src/main/res/drawable/ic_feature_pin.xml new file mode 100644 index 0000000..a7f462a --- /dev/null +++ b/app/src/main/res/drawable/ic_feature_pin.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu_clipboard.xml b/app/src/main/res/drawable/ic_menu_clipboard.xml new file mode 100644 index 0000000..de05cbf --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_clipboard.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu_text_edit.xml b/app/src/main/res/drawable/ic_menu_text_edit.xml new file mode 100644 index 0000000..fbf6eee --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_text_edit.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/test/java/com/frogobox/appkeyboard/AutoTextSubMenuConsolidationTest.kt b/app/src/test/java/com/frogobox/appkeyboard/AutoTextSubMenuConsolidationTest.kt new file mode 100644 index 0000000..a01d1c5 --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/AutoTextSubMenuConsolidationTest.kt @@ -0,0 +1,180 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.model.KeyboardFeatureType +import com.frogobox.appkeyboard.services.KeyboardUtil +import com.frogobox.appkeyboard.ui.keyboard.autotext.AutoTextCategory +import com.frogobox.sdk.delegate.preference.PreferenceDelegates +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit test suite validating TASK-028 AutoText & Templates Consolidation: + * - AutoTextCategory enum structure, titles, icons, and bidirectional mappings + * - KeyboardUtil menuToggle exclusion of standalone template features + * - Validation of consolidated menu items in the main keyboard toolbar + */ +class AutoTextSubMenuConsolidationTest { + + @Test + fun testAutoTextCategoryEnumValuesAndOrder() { + val categories = AutoTextCategory.entries + assertEquals(6, categories.size) + + assertEquals(AutoTextCategory.MY_CUSTOM, categories[0]) + assertEquals("cat_my_custom", categories[0].id) + assertEquals("My Custom", categories[0].title) + assertEquals("โœ๏ธ", categories[0].icon) + assertTrue(categories[0].isCustom) + assertNull(categories[0].templateFeatureType) + + assertEquals(AutoTextCategory.GAME, categories[1]) + assertEquals("cat_game", categories[1].id) + assertEquals("Game", categories[1].title) + assertEquals("๐ŸŽฎ", categories[1].icon) + assertFalse(categories[1].isCustom) + assertEquals(KeyboardFeatureType.TEMPLATE_TEXT_GAME, categories[1].templateFeatureType) + + assertEquals(AutoTextCategory.APP, categories[2]) + assertEquals("cat_app", categories[2].id) + assertEquals("App", categories[2].title) + assertEquals("๐Ÿ“ฑ", categories[2].icon) + assertFalse(categories[2].isCustom) + assertEquals(KeyboardFeatureType.TEMPLATE_TEXT_APP, categories[2].templateFeatureType) + + assertEquals(AutoTextCategory.SALE, categories[3]) + assertEquals("cat_sale", categories[3].id) + assertEquals("Sale", categories[3].title) + assertEquals("๐Ÿ’ฐ", categories[3].icon) + assertFalse(categories[3].isCustom) + assertEquals(KeyboardFeatureType.TEMPLATE_TEXT_SALE, categories[3].templateFeatureType) + + assertEquals(AutoTextCategory.GREETING, categories[4]) + assertEquals("cat_greeting", categories[4].id) + assertEquals("Greeting", categories[4].title) + assertEquals("๐Ÿ‘‹", categories[4].icon) + assertFalse(categories[4].isCustom) + assertEquals(KeyboardFeatureType.TEMPLATE_TEXT_GREETING, categories[4].templateFeatureType) + + assertEquals(AutoTextCategory.LOVE, categories[5]) + assertEquals("cat_love", categories[5].id) + assertEquals("Love", categories[5].title) + assertEquals("โค๏ธ", categories[5].icon) + assertFalse(categories[5].isCustom) + assertEquals(KeyboardFeatureType.TEMPLATE_TEXT_LOVE, categories[5].templateFeatureType) + } + + @Test + fun testAutoTextCategoryFromFeatureTypeMapping() { + assertEquals(AutoTextCategory.GAME, AutoTextCategory.fromFeatureType(KeyboardFeatureType.TEMPLATE_TEXT_GAME)) + assertEquals(AutoTextCategory.APP, AutoTextCategory.fromFeatureType(KeyboardFeatureType.TEMPLATE_TEXT_APP)) + assertEquals(AutoTextCategory.SALE, AutoTextCategory.fromFeatureType(KeyboardFeatureType.TEMPLATE_TEXT_SALE)) + assertEquals(AutoTextCategory.GREETING, AutoTextCategory.fromFeatureType(KeyboardFeatureType.TEMPLATE_TEXT_GREETING)) + assertEquals(AutoTextCategory.LOVE, AutoTextCategory.fromFeatureType(KeyboardFeatureType.TEMPLATE_TEXT_LOVE)) + + // Non-template feature types should map to default MY_CUSTOM + assertEquals(AutoTextCategory.MY_CUSTOM, AutoTextCategory.fromFeatureType(KeyboardFeatureType.AUTO_TEXT)) + assertEquals(AutoTextCategory.MY_CUSTOM, AutoTextCategory.fromFeatureType(KeyboardFeatureType.NEWS)) + assertEquals(AutoTextCategory.MY_CUSTOM, AutoTextCategory.fromFeatureType(KeyboardFeatureType.PRODUCT_REMOTE)) + } + + @Test + fun testKeyboardUtilMenuToggleExcludesTemplates() { + val fakePref = TestPreferenceDelegates() + val keyboardUtil = KeyboardUtil(fakePref) + val menuList = keyboardUtil.menuToggle() + + // 11 functional tools remain (Suggestion, AutoText, ProductRemote, Clipboard, TextEdit, News, Movie, Web, Form, ChangeKeyboard, Setting) + assertEquals(11, menuList.size) + + val menuIds = menuList.map { it.id }.toSet() + + // Standalone template items must be absent from main menu + assertFalse(menuIds.contains(KeyboardFeatureType.TEMPLATE_TEXT_GAME.id)) + assertFalse(menuIds.contains(KeyboardFeatureType.TEMPLATE_TEXT_APP.id)) + assertFalse(menuIds.contains(KeyboardFeatureType.TEMPLATE_TEXT_SALE.id)) + assertFalse(menuIds.contains(KeyboardFeatureType.TEMPLATE_TEXT_GREETING.id)) + assertFalse(menuIds.contains(KeyboardFeatureType.TEMPLATE_TEXT_LOVE.id)) + + // Core AutoText must be present + assertTrue(menuIds.contains(KeyboardFeatureType.AUTO_TEXT.id)) + assertTrue(menuIds.contains(KeyboardFeatureType.PRODUCT_REMOTE.id)) + assertTrue(menuIds.contains(KeyboardFeatureType.CLIPBOARD.id)) + assertTrue(menuIds.contains(KeyboardFeatureType.TEXT_EDIT.id)) + assertTrue(menuIds.contains(KeyboardFeatureType.NEWS.id)) + assertTrue(menuIds.contains(KeyboardFeatureType.MOVIE.id)) + assertTrue(menuIds.contains(KeyboardFeatureType.WEB.id)) + assertTrue(menuIds.contains(KeyboardFeatureType.FORM.id)) + assertTrue(menuIds.contains(KeyboardFeatureType.CHANGE_KEYBOARD.id)) + assertTrue(menuIds.contains(KeyboardFeatureType.SETTING.id)) + assertTrue(menuIds.contains(KeyboardFeatureType.SUGGESTION.id)) + } + + @Test + fun testCategorySwitchingModelLogic() { + var activeCategory = AutoTextCategory.MY_CUSTOM + assertEquals("Tap to insert shortcut into chat", getSubtitle(activeCategory)) + + activeCategory = AutoTextCategory.GAME + assertEquals("Tap any game template to paste into chat", getSubtitle(activeCategory)) + + activeCategory = AutoTextCategory.SALE + assertEquals("Tap any sale template to paste into chat", getSubtitle(activeCategory)) + + activeCategory = AutoTextCategory.GREETING + assertEquals("Tap any greeting template to paste into chat", getSubtitle(activeCategory)) + + activeCategory = AutoTextCategory.LOVE + assertEquals("Tap any love template to paste into chat", getSubtitle(activeCategory)) + + activeCategory = AutoTextCategory.APP + assertEquals("Tap any app template to paste into chat", getSubtitle(activeCategory)) + } + + private fun getSubtitle(category: AutoTextCategory): String { + return when (category) { + AutoTextCategory.MY_CUSTOM -> "Tap to insert shortcut into chat" + AutoTextCategory.GAME -> "Tap any game template to paste into chat" + AutoTextCategory.APP -> "Tap any app template to paste into chat" + AutoTextCategory.SALE -> "Tap any sale template to paste into chat" + AutoTextCategory.GREETING -> "Tap any greeting template to paste into chat" + AutoTextCategory.LOVE -> "Tap any love template to paste into chat" + } + } + + private class TestPreferenceDelegates : PreferenceDelegates { + private val boolMap = mutableMapOf() + + override fun savePrefString(key: String, value: String) {} + override fun getPrefString(key: String): String = "" + override fun getPrefString(key: String, defaultValue: String): String = defaultValue + + override fun savePrefInt(key: String, value: Int) {} + override fun getPrefInt(key: String): Int = 0 + override fun getPrefInt(key: String, defaultValue: Int): Int = defaultValue + + override fun savePrefBoolean(key: String, value: Boolean) { boolMap[key] = value } + override fun getPrefBoolean(key: String): Boolean = boolMap[key] ?: true + override fun getPrefBoolean(key: String, defaultValue: Boolean): Boolean = boolMap[key] ?: defaultValue + + override fun savePrefFloat(key: String, value: Float) {} + override fun getPrefFloat(key: String): Float = 0f + override fun getPrefFloat(key: String, defaultValue: Float): Float = defaultValue + + override fun savePrefLong(key: String, value: Long) {} + override fun getPrefLong(key: String): Long = 0L + override fun getPrefLong(key: String, defaultValue: Long): Long = defaultValue + + override fun deletePref(key: String) { boolMap.remove(key) } + override fun nukePref() { boolMap.clear() } + + @Suppress("UNCHECKED_CAST") + override fun save(key: String, value: T) {} + + @Suppress("UNCHECKED_CAST") + override fun get(key: String, defaultValue: T): T = defaultValue + } + +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/DriveUrlHelperTest.kt b/app/src/test/java/com/frogobox/appkeyboard/DriveUrlHelperTest.kt new file mode 100644 index 0000000..cc9fc32 --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/DriveUrlHelperTest.kt @@ -0,0 +1,80 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.util.DriveUrlHelper +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DriveUrlHelperTest { + + @Test + fun testExtractDriveFileId_fromViewUrl() { + val url = "https://drive.google.com/file/d/1N3pdG1aOZf2_ENVzFDA68sOlyhI3CKGg/view?usp=drivesdk" + val id = DriveUrlHelper.extractDriveFileId(url) + assertEquals("1N3pdG1aOZf2_ENVzFDA68sOlyhI3CKGg", id) + } + + @Test + fun testExtractDriveFileId_fromOpenIdUrl() { + val url = "https://drive.google.com/open?id=1BrY05mLRCR92g5vo2TCb6MOB69_L8fmF" + val id = DriveUrlHelper.extractDriveFileId(url) + assertEquals("1BrY05mLRCR92g5vo2TCb6MOB69_L8fmF", id) + } + + @Test + fun testExtractDriveFileId_fromRawId() { + val rawId = "1N3pdG1aOZf2_ENVzFDA68sOlyhI3CKGg" + val id = DriveUrlHelper.extractDriveFileId(rawId) + assertEquals("1N3pdG1aOZf2_ENVzFDA68sOlyhI3CKGg", id) + } + + @Test + fun testExtractDriveFileId_emptyOrInvalid() { + assertNull(DriveUrlHelper.extractDriveFileId(null)) + assertNull(DriveUrlHelper.extractDriveFileId("")) + assertNull(DriveUrlHelper.extractDriveFileId(" ")) + } + + @Test + fun testGetDirectDownloadUrl_withFileId() { + val fileId = "1N3pdG1aOZf2_ENVzFDA68sOlyhI3CKGg" + val downloadUrl = DriveUrlHelper.getDirectDownloadUrl(fileId, null) + assertEquals( + "https://drive.usercontent.google.com/download?id=1N3pdG1aOZf2_ENVzFDA68sOlyhI3CKGg&export=download", + downloadUrl + ) + } + + @Test + fun testGetDirectDownloadUrl_fromDriveLinkFallback() { + val driveLink = "https://drive.google.com/file/d/67890/view" + val downloadUrl = DriveUrlHelper.getDirectDownloadUrl(null, driveLink) + assertEquals( + "https://drive.usercontent.google.com/download?id=67890&export=download", + downloadUrl + ) + } + + @Test + fun testGetDirectDownloadUrl_directHttpUrl() { + val directUrl = "https://example.com/videos/product123.mp4" + val downloadUrl = DriveUrlHelper.getDirectDownloadUrl(null, directUrl) + assertEquals(directUrl, downloadUrl) + } + + @Test + fun testSanitizeFileName() { + assertEquals( + "test_video.mp4", + DriveUrlHelper.sanitizeFileName("test_video.mp4", "Product Name") + ) + assertEquals( + "Speaker Bluetooth.mp4", + DriveUrlHelper.sanitizeFileName(null, "Speaker Bluetooth") + ) + assertEquals( + "video_12345.mp4", + DriveUrlHelper.sanitizeFileName(null, null, "12345") + ) + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/FormAndWebviewAboveKeyboardTest.kt b/app/src/test/java/com/frogobox/appkeyboard/FormAndWebviewAboveKeyboardTest.kt new file mode 100644 index 0000000..0164478 --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/FormAndWebviewAboveKeyboardTest.kt @@ -0,0 +1,241 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.ui.keyboard.form.FormField +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.URLEncoder + +/** + * Unit tests validating TASK-027: Website and Form Above Keyboard integration. + * Tests key routing simulation, field state progression, formatting, URL parsing, and dimension budgets. + */ +class FormAndWebviewAboveKeyboardTest { + + @Test + fun testFormFieldEnumValues() { + val fields = FormField.entries + assertEquals(3, fields.size) + assertEquals(FormField.SUBJECT, FormField.valueOf("SUBJECT")) + assertEquals(FormField.DETAILS, FormField.valueOf("DETAILS")) + assertEquals(FormField.REF_NUMBER, FormField.valueOf("REF_NUMBER")) + } + + @Test + fun testFormKeyRoutingSubjectFieldTyping() { + var subject = "" + var details = "" + var refNumber = "" + var activeField = FormField.SUBJECT + + val handleKey: (Int, Boolean) -> Boolean = { code, isShifted -> + when (code) { + ItemMainKeyboard.KEYCODE_DELETE -> { + when (activeField) { + FormField.SUBJECT -> if (subject.isNotEmpty()) subject = subject.dropLast(1) + FormField.DETAILS -> if (details.isNotEmpty()) details = details.dropLast(1) + FormField.REF_NUMBER -> if (refNumber.isNotEmpty()) refNumber = refNumber.dropLast(1) + } + true + } + ItemMainKeyboard.KEYCODE_SPACE -> { + when (activeField) { + FormField.SUBJECT -> subject += " " + FormField.DETAILS -> details += " " + FormField.REF_NUMBER -> refNumber += " " + } + true + } + ItemMainKeyboard.KEYCODE_SHIFT, + ItemMainKeyboard.KEYCODE_MODE_CHANGE, + ItemMainKeyboard.KEYCODE_EMOJI -> false + else -> { + if (code > 0) { + var ch = code.toChar() + if (ch.isLetter() && isShifted) ch = ch.uppercaseChar() + when (activeField) { + FormField.SUBJECT -> subject += ch + FormField.DETAILS -> details += ch + FormField.REF_NUMBER -> refNumber += ch + } + true + } else false + } + } + } + + // Type 'I' (shifted) + assertTrue(handleKey('i'.code, true)) + assertEquals("I", subject) + + // Type 'n', 'v', 'o', 'i', 'c', 'e' (unshifted) + "nvoice".forEach { ch -> + assertTrue(handleKey(ch.code, false)) + } + assertEquals("Invoice", subject) + + // Space + assertTrue(handleKey(ItemMainKeyboard.KEYCODE_SPACE, false)) + assertEquals("Invoice ", subject) + + // Delete last char + assertTrue(handleKey(ItemMainKeyboard.KEYCODE_DELETE, false)) + assertEquals("Invoice", subject) + } + + @Test + fun testFormKeyRoutingEnterFieldProgression() { + var activeField = FormField.SUBJECT + + val onEnter: () -> Unit = { + activeField = when (activeField) { + FormField.SUBJECT -> FormField.DETAILS + FormField.DETAILS -> FormField.REF_NUMBER + FormField.REF_NUMBER -> FormField.REF_NUMBER + } + } + + assertEquals(FormField.SUBJECT, activeField) + onEnter() + assertEquals(FormField.DETAILS, activeField) + onEnter() + assertEquals(FormField.REF_NUMBER, activeField) + } + + @Test + fun testFormKeyRoutingTabCyclicNavigation() { + var activeField = FormField.SUBJECT + + val onTab: () -> Unit = { + activeField = when (activeField) { + FormField.SUBJECT -> FormField.DETAILS + FormField.DETAILS -> FormField.REF_NUMBER + FormField.REF_NUMBER -> FormField.SUBJECT + } + } + + assertEquals(FormField.SUBJECT, activeField) + onTab() + assertEquals(FormField.DETAILS, activeField) + onTab() + assertEquals(FormField.REF_NUMBER, activeField) + onTab() + assertEquals(FormField.SUBJECT, activeField) + } + + @Test + fun testFormKeyRoutingShiftAndModeChangeBypass() { + val handleKey: (Int, Boolean) -> Boolean = { code, _ -> + when (code) { + ItemMainKeyboard.KEYCODE_SHIFT, + ItemMainKeyboard.KEYCODE_MODE_CHANGE, + ItemMainKeyboard.KEYCODE_EMOJI -> false + else -> true + } + } + + // Shift and Mode change must return false so BaseKeyboardIME can update layout + assertFalse(handleKey(ItemMainKeyboard.KEYCODE_SHIFT, false)) + assertFalse(handleKey(ItemMainKeyboard.KEYCODE_MODE_CHANGE, false)) + assertFalse(handleKey(ItemMainKeyboard.KEYCODE_EMOJI, false)) + } + + @Test + fun testFormMessageFormatting() { + fun formatMessage(subject: String, details: String, refNumber: String): String { + val sb = StringBuilder() + if (subject.isNotBlank()) sb.append("Subject: ${subject.trim()}\n") + if (details.isNotBlank()) sb.append("Details: ${details.trim()}\n") + if (refNumber.isNotBlank()) sb.append("Ref/No: ${refNumber.trim()}\n") + return sb.toString() + } + + val output = formatMessage( + subject = "Pesanan Kaos Hitam XL", + details = "Kirim via J&T Express, alamat Jl. Merdeka No. 10", + refNumber = "INV-2026-0901" + ) + + val expected = "Subject: Pesanan Kaos Hitam XL\n" + + "Details: Kirim via J&T Express, alamat Jl. Merdeka No. 10\n" + + "Ref/No: INV-2026-0901\n" + + assertEquals(expected, output) + } + + @Test + fun testWebviewUrlKeyRouting() { + var urlInput = "" + + val handleKey: (Int, Boolean) -> Boolean = { code, isShifted -> + when (code) { + ItemMainKeyboard.KEYCODE_DELETE -> { + if (urlInput.isNotEmpty()) urlInput = urlInput.dropLast(1) + true + } + ItemMainKeyboard.KEYCODE_SPACE -> { + urlInput += " " + true + } + ItemMainKeyboard.KEYCODE_SHIFT, + ItemMainKeyboard.KEYCODE_MODE_CHANGE, + ItemMainKeyboard.KEYCODE_EMOJI -> false + else -> { + if (code > 0) { + var ch = code.toChar() + if (ch.isLetter() && isShifted) ch = ch.uppercaseChar() + urlInput += ch + true + } else false + } + } + } + + "google.com".forEach { ch -> + assertTrue(handleKey(ch.code, false)) + } + assertEquals("google.com", urlInput) + + // Delete 'm' + assertTrue(handleKey(ItemMainKeyboard.KEYCODE_DELETE, false)) + assertEquals("google.co", urlInput) + } + + @Test + fun testWebviewSearchQueryAutoPrefix() { + fun resolveUrl(query: String): String { + val q = query.trim() + return when { + q.startsWith("http://") || q.startsWith("https://") -> q + q.contains(".") && !q.contains(" ") -> "https://$q" + q.isNotEmpty() -> "https://www.google.com/search?q=" + URLEncoder.encode(q, "UTF-8") + else -> "https://www.google.com" + } + } + + assertEquals("https://www.google.com", resolveUrl("")) + assertEquals("https://tokopedia.com", resolveUrl("tokopedia.com")) + assertEquals("https://shopee.co.id/flash-sale", resolveUrl("https://shopee.co.id/flash-sale")) + assertEquals("https://www.google.com/search?q=cek+resi+jne", resolveUrl("cek resi jne")) + } + + @Test + fun testAboveKeyboardPanelDimensionsBudget() { + val screenHeightStandardDp = 800 + val formPanelHeightDp = 240 + val webviewPanelHeightDp = 260 + val keyboardHeightDp = 220 + + val totalFormHeightDp = formPanelHeightDp + keyboardHeightDp + val totalWebviewHeightDp = webviewPanelHeightDp + keyboardHeightDp + + assertEquals(460, totalFormHeightDp) + assertEquals(480, totalWebviewHeightDp) + + assertTrue("Form panel + keyboard must fit within 65% of screen height", totalFormHeightDp < screenHeightStandardDp * 0.65) + assertTrue("Webview panel + keyboard must fit within 65% of screen height", totalWebviewHeightDp < screenHeightStandardDp * 0.65) + } + +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/GboardCapabilitiesEnhancementTest.kt b/app/src/test/java/com/frogobox/appkeyboard/GboardCapabilitiesEnhancementTest.kt new file mode 100644 index 0000000..fb06652 --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/GboardCapabilitiesEnhancementTest.kt @@ -0,0 +1,253 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.model.ClipboardItem +import com.frogobox.appkeyboard.model.KeyboardFeatureType +import com.frogobox.appkeyboard.repository.clipboard.ClipboardRepositoryImpl +import com.frogobox.appkeyboard.ui.keyboard.root.KeyboardPanelState +import com.frogobox.appkeyboard.ui.keyboard.textedit.TextEditAction +import com.frogobox.sdk.delegate.preference.PreferenceDelegates +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class GboardCapabilitiesEnhancementTest { + + private lateinit var fakePref: FakePreferenceDelegates + private lateinit var repository: ClipboardRepositoryImpl + + @Before + fun setUp() { + fakePref = FakePreferenceDelegates() + repository = ClipboardRepositoryImpl(fakePref, Dispatchers.Unconfined) + } + + @Test + fun testClipboardItemCreationAndCopy() { + val item = ClipboardItem( + id = "test-1", + text = "Nomor Rekening BCA: 1234567890", + timestamp = 1000L, + isPinned = false + ) + + assertEquals("test-1", item.id) + assertEquals("Nomor Rekening BCA: 1234567890", item.text) + assertEquals(1000L, item.timestamp) + assertFalse(item.isPinned) + + val pinned = item.copy(isPinned = true) + assertTrue(pinned.isPinned) + assertEquals(item.text, pinned.text) + } + + @Test + fun testClipboardRepositoryAddAndOrdering() = runTest { + repository.addClip("Clip Pertama") + repository.addClip("Clip Kedua") + + val items = repository.getClipboardItems().first() + assertEquals(2, items.size) + // Most recent should be first + assertEquals("Clip Kedua", items[0].text) + assertEquals("Clip Pertama", items[1].text) + } + + @Test + fun testClipboardRepositoryDeduplication() = runTest { + repository.addClip("Sama") + repository.addClip("Beda") + repository.addClip("Sama") + + val items = repository.getClipboardItems().first() + assertEquals(2, items.size) + // Re-adding "Sama" moves it to the top + assertEquals("Sama", items[0].text) + assertEquals("Beda", items[1].text) + } + + @Test + fun testClipboardRepositoryPinningPrioritization() = runTest { + repository.addClip("Item 1") + repository.addClip("Item 2") + repository.addClip("Item 3") + + val itemsBefore = repository.getClipboardItems().first() + val item1Id = itemsBefore.first { it.text == "Item 1" }.id + + // Pin Item 1 + repository.togglePin(item1Id) + + val itemsAfter = repository.getClipboardItems().first() + assertEquals(3, itemsAfter.size) + // Pinned item must always be at index 0 ahead of unpinned newer items + assertEquals("Item 1", itemsAfter[0].text) + assertTrue(itemsAfter[0].isPinned) + assertFalse(itemsAfter[1].isPinned) + assertFalse(itemsAfter[2].isPinned) + } + + @Test + fun testClipboardRepositoryDeleteAndClear() = runTest { + repository.addClip("Hapus Saya") + repository.addClip("Simpan Saya (Pinned)") + repository.addClip("Riwayat Biasa") + + val items = repository.getClipboardItems().first() + val pinnedId = items.first { it.text == "Simpan Saya (Pinned)" }.id + val deleteId = items.first { it.text == "Hapus Saya" }.id + + repository.togglePin(pinnedId) + repository.deleteClip(deleteId) + + val afterDelete = repository.getClipboardItems().first() + assertEquals(2, afterDelete.size) + assertFalse(afterDelete.any { it.text == "Hapus Saya" }) + + // Clear history keeping pinned + repository.clearHistory(keepPinned = true) + val afterClear = repository.getClipboardItems().first() + assertEquals(1, afterClear.size) + assertEquals("Simpan Saya (Pinned)", afterClear[0].text) + assertTrue(afterClear[0].isPinned) + } + + @Test + fun testKeyboardFeatureTypeClipboardAndTextEdit() { + val clipboardFeature = KeyboardFeatureType.from("menu_clipboard") + assertEquals(KeyboardFeatureType.CLIPBOARD, clipboardFeature) + assertEquals("menu_clipboard", clipboardFeature.id) + assertEquals("Clipboard", clipboardFeature.text) + + val textEditFeature = KeyboardFeatureType.from("menu_text_edit") + assertEquals(KeyboardFeatureType.TEXT_EDIT, textEditFeature) + assertEquals("menu_text_edit", textEditFeature.id) + assertEquals("Text Editing", textEditFeature.text) + + val modelClipboard = clipboardFeature.mapToModel() + assertEquals("menu_clipboard", modelClipboard.id) + assertEquals("Clipboard", modelClipboard.text) + } + + @Test + fun testKeyboardPanelStateMappings() { + assertEquals( + KeyboardPanelState.CLIPBOARD, + KeyboardPanelState.fromFeature(KeyboardFeatureType.CLIPBOARD) + ) + assertEquals( + KeyboardPanelState.TEXT_EDIT, + KeyboardPanelState.fromFeature(KeyboardFeatureType.TEXT_EDIT) + ) + } + + @Test + fun testTextEditActionEnumCompleteness() { + val actions = TextEditAction.entries + assertEquals(13, actions.size) + assertTrue(actions.contains(TextEditAction.MOVE_LEFT)) + assertTrue(actions.contains(TextEditAction.MOVE_RIGHT)) + assertTrue(actions.contains(TextEditAction.MOVE_UP)) + assertTrue(actions.contains(TextEditAction.MOVE_DOWN)) + assertTrue(actions.contains(TextEditAction.MOVE_HOME)) + assertTrue(actions.contains(TextEditAction.MOVE_END)) + assertTrue(actions.contains(TextEditAction.TOGGLE_SELECT)) + assertTrue(actions.contains(TextEditAction.SELECT_ALL)) + assertTrue(actions.contains(TextEditAction.CUT)) + assertTrue(actions.contains(TextEditAction.COPY)) + assertTrue(actions.contains(TextEditAction.PASTE)) + assertTrue(actions.contains(TextEditAction.DELETE)) + assertTrue(actions.contains(TextEditAction.ENTER)) + } + + @Test + fun testRecentEmojisLRUCapping() { + val recentEmojis = mutableListOf() + val incoming = listOf("๐Ÿ‘", "๐Ÿ™", "๐Ÿ˜Š", "โค๏ธ", "๐Ÿ”ฅ", "๐Ÿ‘") + + for (emoji in incoming) { + recentEmojis.remove(emoji) + recentEmojis.add(0, emoji) + } + + // "๐Ÿ‘" was repeated, so it should only appear once at the head + assertEquals(5, recentEmojis.size) + assertEquals("๐Ÿ‘", recentEmojis[0]) + assertEquals("๐Ÿ”ฅ", recentEmojis[1]) + assertEquals("โค๏ธ", recentEmojis[2]) + } + + /** + * In-memory test double for PreferenceDelegates. + */ + private class FakePreferenceDelegates : PreferenceDelegates { + private val stringMap = mutableMapOf() + private val intMap = mutableMapOf() + private val boolMap = mutableMapOf() + private val floatMap = mutableMapOf() + private val longMap = mutableMapOf() + + override fun savePrefString(key: String, value: String) { stringMap[key] = value } + override fun getPrefString(key: String): String = stringMap[key] ?: "" + override fun getPrefString(key: String, defaultValue: String): String = stringMap[key] ?: defaultValue + + override fun savePrefInt(key: String, value: Int) { intMap[key] = value } + override fun getPrefInt(key: String): Int = intMap[key] ?: 0 + override fun getPrefInt(key: String, defaultValue: Int): Int = intMap[key] ?: defaultValue + + override fun savePrefBoolean(key: String, value: Boolean) { boolMap[key] = value } + override fun getPrefBoolean(key: String): Boolean = boolMap[key] ?: false + override fun getPrefBoolean(key: String, defaultValue: Boolean): Boolean = boolMap[key] ?: defaultValue + + override fun savePrefFloat(key: String, value: Float) { floatMap[key] = value } + override fun getPrefFloat(key: String): Float = floatMap[key] ?: 0f + override fun getPrefFloat(key: String, defaultValue: Float): Float = floatMap[key] ?: defaultValue + + override fun savePrefLong(key: String, value: Long) { longMap[key] = value } + override fun getPrefLong(key: String): Long = longMap[key] ?: 0L + override fun getPrefLong(key: String, defaultValue: Long): Long = longMap[key] ?: defaultValue + + override fun deletePref(key: String) { + stringMap.remove(key) + intMap.remove(key) + boolMap.remove(key) + floatMap.remove(key) + longMap.remove(key) + } + + override fun nukePref() { + stringMap.clear() + intMap.clear() + boolMap.clear() + floatMap.clear() + longMap.clear() + } + + @Suppress("UNCHECKED_CAST") + override fun save(key: String, value: T) { + when (value) { + is String -> savePrefString(key, value) + is Int -> savePrefInt(key, value) + is Boolean -> savePrefBoolean(key, value) + is Float -> savePrefFloat(key, value) + is Long -> savePrefLong(key, value) + } + } + + @Suppress("UNCHECKED_CAST") + override fun get(key: String, defaultValue: T): T { + return when (defaultValue) { + is String -> getPrefString(key, defaultValue) as T + is Int -> getPrefInt(key, defaultValue) as T + is Boolean -> getPrefBoolean(key, defaultValue) as T + is Float -> getPrefFloat(key, defaultValue) as T + is Long -> getPrefLong(key, defaultValue) as T + else -> defaultValue + } + } + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/GboardParityRecommendationsTest.kt b/app/src/test/java/com/frogobox/appkeyboard/GboardParityRecommendationsTest.kt new file mode 100644 index 0000000..ced83f7 --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/GboardParityRecommendationsTest.kt @@ -0,0 +1,152 @@ +package com.frogobox.appkeyboard + +import android.text.InputType +import com.frogobox.appkeyboard.suggestion.WordSuggestionEngine +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit tests verifying TASK-032 Google Keyboard (Gboard) parity recommendations: + * 1. Indonesian Dictionary & Seller Slang Typo Map + * 2. Spacebar Auto-Correction + * 3. Single-Tap Backspace Undo + * 4. Sensitive Field Exclusion Guard + */ +class GboardParityRecommendationsTest { + + private lateinit var suggestionEngine: WordSuggestionEngine + + @Before + fun setUp() { + suggestionEngine = WordSuggestionEngine() + } + + @Test + fun testIndonesianShorthandsAndSellerTypoMap() { + // Test common Indonesian conversational shorthands + assertEquals("yang", suggestionEngine.getAutoCorrectReplacement("yg")) + assertEquals("dengan", suggestionEngine.getAutoCorrectReplacement("dgn")) + assertEquals("saya", suggestionEngine.getAutoCorrectReplacement("sy")) + assertEquals("kamu", suggestionEngine.getAutoCorrectReplacement("km")) + assertEquals("tidak", suggestionEngine.getAutoCorrectReplacement("gak")) + assertEquals("sudah", suggestionEngine.getAutoCorrectReplacement("sdh")) + assertEquals("belum", suggestionEngine.getAutoCorrectReplacement("blm")) + assertEquals("banget", suggestionEngine.getAutoCorrectReplacement("bgt")) + assertEquals("terus", suggestionEngine.getAutoCorrectReplacement("trs")) + assertEquals("tolong", suggestionEngine.getAutoCorrectReplacement("tlg")) + assertEquals("terima kasih", suggestionEngine.getAutoCorrectReplacement("makasih")) + + // Test seller-specific abbreviations + assertEquals("transfer", suggestionEngine.getAutoCorrectReplacement("tf")) + assertEquals("rekening", suggestionEngine.getAutoCorrectReplacement("rek")) + assertEquals("barang", suggestionEngine.getAutoCorrectReplacement("brg")) + assertEquals("COD", suggestionEngine.getAutoCorrectReplacement("cod")) + assertEquals("BCA", suggestionEngine.getAutoCorrectReplacement("bca")) + } + + @Test + fun testEnglishCommonTypoAutoCorrect() { + assertEquals("the", suggestionEngine.getAutoCorrectReplacement("teh")) + assertEquals("and", suggestionEngine.getAutoCorrectReplacement("adn")) + assertEquals("what", suggestionEngine.getAutoCorrectReplacement("wht")) + assertEquals("receive", suggestionEngine.getAutoCorrectReplacement("recive")) + } + + @Test + fun testCasingPreservationOnAutoCorrect() { + // Capitalized + assertEquals("Yang", suggestionEngine.getAutoCorrectReplacement("Yg")) + assertEquals("The", suggestionEngine.getAutoCorrectReplacement("Teh")) + assertEquals("Saya", suggestionEngine.getAutoCorrectReplacement("Sy")) + + // All caps + assertEquals("YANG", suggestionEngine.getAutoCorrectReplacement("YG")) + assertEquals("THE", suggestionEngine.getAutoCorrectReplacement("TEH")) + } + + @Test + fun testValidDictionaryWordsDoNotAutoCorrect() { + // Words recognized in dictionary should not be auto-corrected to something else + assertNull(suggestionEngine.getAutoCorrectReplacement("the")) + assertNull(suggestionEngine.getAutoCorrectReplacement("yang")) + assertNull(suggestionEngine.getAutoCorrectReplacement("bisa")) + assertNull(suggestionEngine.getAutoCorrectReplacement("sudah")) + assertNull(suggestionEngine.getAutoCorrectReplacement("pesanan")) + assertNull(suggestionEngine.getAutoCorrectReplacement("barang")) + } + + @Test + fun testSingleTapBackspaceUndoLogic() { + data class AutoCorrectionRecord(val original: String, val replacement: String, val timestamp: Long) + + var lastAutoCorrection: AutoCorrectionRecord? = null + var committedText = "teh" + + // Step 1: User types "teh" and presses space + val replacement = suggestionEngine.getAutoCorrectReplacement(committedText) + assertNotNull(replacement) + assertEquals("the", replacement) + + // Simulate auto-correction commit: replaces "teh" with "the " + val originalWord = committedText + committedText = "$replacement " + lastAutoCorrection = AutoCorrectionRecord(originalWord, replacement!!, System.currentTimeMillis()) + assertEquals("the ", committedText) + + // Step 2: User presses Backspace immediately -> Single-Tap Undo! + val record = lastAutoCorrection + assertNotNull(record) + val expectedSuffix = "${record.replacement} " + assertTrue(committedText.endsWith(expectedSuffix)) + + // Revert: delete "${replacement} " and restore original word + committedText = committedText.removeSuffix(expectedSuffix) + record.original + lastAutoCorrection = null + + // Verification: Text is restored to original verbatim "teh" + assertEquals("teh", committedText) + assertNull(lastAutoCorrection) + } + + @Test + fun testSensitiveFieldExclusionGuard() { + fun isFieldEligibleForAutoCorrect(inputType: Int): Boolean { + val inputClass = inputType and InputType.TYPE_MASK_CLASS + val variation = inputType and InputType.TYPE_MASK_VARIATION + val isSensitiveOrNumeric = (inputClass == InputType.TYPE_CLASS_NUMBER) || + (inputClass == InputType.TYPE_CLASS_PHONE) || + (inputClass == InputType.TYPE_CLASS_DATETIME) || + (inputClass == InputType.TYPE_CLASS_TEXT && ( + variation == InputType.TYPE_TEXT_VARIATION_PASSWORD || + variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD || + variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD || + variation == InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS || + variation == InputType.TYPE_TEXT_VARIATION_URI + )) + return !isSensitiveOrNumeric + } + + // Standard text field -> ELIGIBLE + assertTrue(isFieldEligibleForAutoCorrect(InputType.TYPE_CLASS_TEXT)) + assertTrue(isFieldEligibleForAutoCorrect(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE)) + + // Password field -> NOT ELIGIBLE + assertTrue(!isFieldEligibleForAutoCorrect(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD)) + assertTrue(!isFieldEligibleForAutoCorrect(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD)) + + // Email address field -> NOT ELIGIBLE + assertTrue(!isFieldEligibleForAutoCorrect(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS)) + + // Web URL field -> NOT ELIGIBLE + assertTrue(!isFieldEligibleForAutoCorrect(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI)) + + // Number & Phone fields -> NOT ELIGIBLE + assertTrue(!isFieldEligibleForAutoCorrect(InputType.TYPE_CLASS_NUMBER)) + assertTrue(!isFieldEligibleForAutoCorrect(InputType.TYPE_CLASS_PHONE)) + } + +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/GboardPolishAndBugFixTest.kt b/app/src/test/java/com/frogobox/appkeyboard/GboardPolishAndBugFixTest.kt new file mode 100644 index 0000000..5847cab --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/GboardPolishAndBugFixTest.kt @@ -0,0 +1,225 @@ +package com.frogobox.appkeyboard + +import android.content.ClipData +import android.os.Build +import android.view.HapticFeedbackConstants +import com.frogobox.appkeyboard.model.KeyboardFeatureType +import com.frogobox.appkeyboard.model.TemplateCategoryType +import com.frogobox.appkeyboard.repository.clipboard.ClipboardRepositoryImpl +import com.frogobox.appkeyboard.ui.keyboard.form.FormField +import com.frogobox.libkeyboard.ui.main.ItemMainKeyboard +import com.frogobox.sdk.delegate.preference.PreferenceDelegates +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class GboardPolishAndBugFixTest { + + private lateinit var fakePref: FakePreferenceDelegates + private lateinit var clipboardRepository: ClipboardRepositoryImpl + + @Before + fun setUp() { + fakePref = FakePreferenceDelegates() + clipboardRepository = ClipboardRepositoryImpl(fakePref, Dispatchers.Default) + } + + @Test + fun testPasteCrashGuardWithEmptyAndNullClipboard() { + var committedText: String? = null + val onCommitText: (String) -> Unit = { committedText = it } + + // Scenario 1: clipData is null + val nullClip: ClipData? = null + try { + if (nullClip != null && nullClip.itemCount > 0) { + val clip = nullClip.getItemAt(0)?.text?.toString() ?: "" + if (clip.isNotEmpty()) { + onCommitText(clip) + } + } + } catch (e: Exception) { + org.junit.Assert.fail("Should not throw any exception when clipData is null: ${e.message}") + } + assertEquals(null, committedText) + + // Scenario 2: clipData has 0 items (represented by safe check) + val hasItems = 0 > 0 + if (hasItems) { + onCommitText("Should not reach") + } + assertEquals(null, committedText) + } + + @Test + fun testSelectAllNativeFallbackLogic() { + var setSelectionCalled = false + var performContextMenuActionCalled = false + + fun handleSelectAll(extractedTextLength: Int?) { + val totalLength = extractedTextLength ?: 0 + if (totalLength > 0) { + setSelectionCalled = true + } else { + performContextMenuActionCalled = true + } + } + + // When extractedText is null (e.g. WebView editor or password field) + handleSelectAll(null) + assertTrue(performContextMenuActionCalled) + assertEquals(false, setSelectionCalled) + + // When extractedText is present + performContextMenuActionCalled = false + handleSelectAll(42) + assertTrue(setSelectionCalled) + assertEquals(false, performContextMenuActionCalled) + } + + @Test + fun testClipboardRepositoryMutexConcurrency() = runTest { + // Concurrently invoke addClip from 30 parallel coroutines to verify thread safety + val jobs = (1..30).map { index -> + async(Dispatchers.Default) { + clipboardRepository.addClip("Snippet concurrency $index") + } + } + jobs.awaitAll() + + val items = clipboardRepository.getClipboardItems().first() + assertTrue("Items count should be positive", items.isNotEmpty()) + assertEquals(30, items.size) + } + + @Test + fun testFormDetailsMultilineEnterKey() { + var subject = "Invoice" + var details = "Item 1: Kaos" + var refNumber = "REF-001" + var activeField = FormField.DETAILS + + // Simulate KEYCODE_ENTER on FormField.DETAILS + val code = ItemMainKeyboard.KEYCODE_ENTER + when (code) { + ItemMainKeyboard.KEYCODE_ENTER -> { + when (activeField) { + FormField.SUBJECT -> activeField = FormField.DETAILS + FormField.DETAILS -> { + details += "\n" + } + FormField.REF_NUMBER -> { + // Submit + } + } + } + } + + // Details should now contain newline and remain on FormField.DETAILS + assertEquals("Item 1: Kaos\n", details) + assertEquals(FormField.DETAILS, activeField) + + // Simulate typing on line 2 + details += "Item 2: Celana" + assertEquals("Item 1: Kaos\nItem 2: Celana", details) + } + + @Test + fun testGboardHapticConstantsConfiguration() { + val keyboardTap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + HapticFeedbackConstants.KEYBOARD_TAP + } else { + HapticFeedbackConstants.VIRTUAL_KEY + } + assertNotNull(keyboardTap) + } + + @Test + fun testTemplateCategoryTypeMapping() { + assertEquals("GAME", TemplateCategoryType.GAME.key) + assertEquals("APP", TemplateCategoryType.APP.key) + assertEquals("SALE", TemplateCategoryType.SALE.key) + assertEquals("GREETING", TemplateCategoryType.GREETING.key) + assertEquals("LOVE", TemplateCategoryType.LOVE.key) + + assertEquals(TemplateCategoryType.GAME, TemplateCategoryType.fromFeatureType(KeyboardFeatureType.TEMPLATE_TEXT_GAME)) + assertEquals(TemplateCategoryType.APP, TemplateCategoryType.fromFeatureType(KeyboardFeatureType.TEMPLATE_TEXT_APP)) + assertEquals(TemplateCategoryType.SALE, TemplateCategoryType.fromFeatureType(KeyboardFeatureType.TEMPLATE_TEXT_SALE)) + assertEquals(TemplateCategoryType.GREETING, TemplateCategoryType.fromFeatureType(KeyboardFeatureType.TEMPLATE_TEXT_GREETING)) + assertEquals(TemplateCategoryType.LOVE, TemplateCategoryType.fromFeatureType(KeyboardFeatureType.TEMPLATE_TEXT_LOVE)) + } + + private class FakePreferenceDelegates : PreferenceDelegates { + private val stringMap = mutableMapOf() + private val intMap = mutableMapOf() + private val boolMap = mutableMapOf() + private val floatMap = mutableMapOf() + private val longMap = mutableMapOf() + + override fun savePrefString(key: String, value: String) { stringMap[key] = value } + override fun getPrefString(key: String): String = stringMap[key] ?: "" + override fun getPrefString(key: String, defaultValue: String): String = stringMap[key] ?: defaultValue + + override fun savePrefInt(key: String, value: Int) { intMap[key] = value } + override fun getPrefInt(key: String): Int = intMap[key] ?: 0 + override fun getPrefInt(key: String, defaultValue: Int): Int = intMap[key] ?: defaultValue + + override fun savePrefBoolean(key: String, value: Boolean) { boolMap[key] = value } + override fun getPrefBoolean(key: String): Boolean = boolMap[key] ?: false + override fun getPrefBoolean(key: String, defaultValue: Boolean): Boolean = boolMap[key] ?: defaultValue + + override fun savePrefFloat(key: String, value: Float) { floatMap[key] = value } + override fun getPrefFloat(key: String): Float = floatMap[key] ?: 0f + override fun getPrefFloat(key: String, defaultValue: Float): Float = floatMap[key] ?: defaultValue + + override fun savePrefLong(key: String, value: Long) { longMap[key] = value } + override fun getPrefLong(key: String): Long = longMap[key] ?: 0L + override fun getPrefLong(key: String, defaultValue: Long): Long = longMap[key] ?: defaultValue + + override fun deletePref(key: String) { + stringMap.remove(key) + intMap.remove(key) + boolMap.remove(key) + floatMap.remove(key) + longMap.remove(key) + } + + override fun nukePref() { + stringMap.clear() + intMap.clear() + boolMap.clear() + floatMap.clear() + longMap.clear() + } + + @Suppress("UNCHECKED_CAST") + override fun save(key: String, value: T) { + when (value) { + is String -> savePrefString(key, value) + is Int -> savePrefInt(key, value) + is Boolean -> savePrefBoolean(key, value) + is Float -> savePrefFloat(key, value) + is Long -> savePrefLong(key, value) + } + } + + @Suppress("UNCHECKED_CAST") + override fun get(key: String, defaultValue: T): T { + return when (defaultValue) { + is String -> getPrefString(key, defaultValue) as T + is Int -> getPrefInt(key, defaultValue) as T + is Boolean -> getPrefBoolean(key, defaultValue) as T + is Float -> getPrefFloat(key, defaultValue) as T + is Long -> getPrefLong(key, defaultValue) as T + else -> defaultValue + } + } + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/KeyboardFeaturesRedesignTest.kt b/app/src/test/java/com/frogobox/appkeyboard/KeyboardFeaturesRedesignTest.kt index 89cc2f6..aec5524 100644 --- a/app/src/test/java/com/frogobox/appkeyboard/KeyboardFeaturesRedesignTest.kt +++ b/app/src/test/java/com/frogobox/appkeyboard/KeyboardFeaturesRedesignTest.kt @@ -122,6 +122,7 @@ class KeyboardFeaturesRedesignTest { @Test fun testFeatureTypeFromIdMapping() { assertEquals(KeyboardFeatureType.AUTO_TEXT, KeyboardFeatureType.from("menu_auto_text")) + assertEquals(KeyboardFeatureType.PRODUCT_REMOTE, KeyboardFeatureType.from("menu_product_remote")) assertEquals(KeyboardFeatureType.MOVIE, KeyboardFeatureType.from("menu_movie")) assertEquals(KeyboardFeatureType.NEWS, KeyboardFeatureType.from("menu_news")) assertEquals(KeyboardFeatureType.WEB, KeyboardFeatureType.from("menu_web")) diff --git a/app/src/test/java/com/frogobox/appkeyboard/KeyboardFeaturesUpdateTest.kt b/app/src/test/java/com/frogobox/appkeyboard/KeyboardFeaturesUpdateTest.kt new file mode 100644 index 0000000..1c9d71b --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/KeyboardFeaturesUpdateTest.kt @@ -0,0 +1,174 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.model.KeyboardFeatureModel +import com.frogobox.appkeyboard.model.KeyboardFeatureType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests verifying AC-01 to AC-04 for TASK-001-keyboard-features-update. + * Authored by Sandra (QA Engineer). + */ +class KeyboardFeaturesUpdateTest { + + // ------------------------------------------------------------- + // AC-01: Always Show Feature Logic & Mutual Exclusivity + // ------------------------------------------------------------- + @Test + fun testAlwaysShowFeatureMutualExclusivity() { + var activeAlwaysShowFeature: String? = null + + fun toggleAlwaysShow(featureId: String) { + activeAlwaysShowFeature = if (activeAlwaysShowFeature == featureId) { + null + } else { + featureId + } + } + + // Initially no feature is pinned + assertNull(activeAlwaysShowFeature) + + // Turn ON Auto Text as Always Show + toggleAlwaysShow(KeyboardFeatureType.AUTO_TEXT.id) + assertEquals(KeyboardFeatureType.AUTO_TEXT.id, activeAlwaysShowFeature) + + // Turn ON Product Remote -> Auto Text must automatically turn OFF + toggleAlwaysShow(KeyboardFeatureType.PRODUCT_REMOTE.id) + assertEquals(KeyboardFeatureType.PRODUCT_REMOTE.id, activeAlwaysShowFeature) + assertNotEquals(KeyboardFeatureType.AUTO_TEXT.id, activeAlwaysShowFeature) + + // Toggling Product Remote again turns it OFF -> Reverts to null (standard QWERTY) + toggleAlwaysShow(KeyboardFeatureType.PRODUCT_REMOTE.id) + assertNull(activeAlwaysShowFeature) + } + + // ------------------------------------------------------------- + // AC-02: Emoji Duplicate Output Prevention & Backspace Callback + // ------------------------------------------------------------- + @Test + fun testEmojiSingleCommitCallback() { + var commitCount = 0 + var committedText: String? = null + + // Callback simulating fixed onEmojiClicked handler (single commit) + val onEmojiClicked: (String) -> Unit = { emoji -> + commitCount++ + committedText = emoji + } + + val testEmoji = "๐ŸŽ‰" + onEmojiClicked(testEmoji) + + // Verify that exactly 1 emission occurs per tap (Fix for duplicate emoji bug) + assertEquals(1, commitCount) + assertEquals(testEmoji, committedText) + } + + @Test + fun testEmojiBackspaceCallbackTriggered() { + var backspaceTriggered = false + val onDeleteEmoji: () -> Unit = { + backspaceTriggered = true + } + + onDeleteEmoji() + assertTrue(backspaceTriggered) + } + + // ------------------------------------------------------------- + // AC-03: Feature Reorder, Swap, and Order Preservation + // ------------------------------------------------------------- + @Test + fun testFeatureReorderAndSwapLogic() { + val initialFeatures = listOf( + KeyboardFeatureType.AUTO_TEXT.id, + KeyboardFeatureType.TEMPLATE_TEXT_APP.id, + KeyboardFeatureType.TEXT_EDIT.id, + KeyboardFeatureType.CLIPBOARD.id + ) + + val featureList = initialFeatures.toMutableList() + + // Move item at index 0 (AUTO_TEXT) to index 2 + val item = featureList.removeAt(0) + featureList.add(2, item) + + assertEquals( + listOf( + KeyboardFeatureType.TEMPLATE_TEXT_APP.id, + KeyboardFeatureType.TEXT_EDIT.id, + KeyboardFeatureType.AUTO_TEXT.id, + KeyboardFeatureType.CLIPBOARD.id + ), + featureList + ) + + // Swap item at index 1 and index 2 + val temp = featureList[1] + featureList[1] = featureList[2] + featureList[2] = temp + + assertEquals( + listOf( + KeyboardFeatureType.TEMPLATE_TEXT_APP.id, + KeyboardFeatureType.AUTO_TEXT.id, + KeyboardFeatureType.TEXT_EDIT.id, + KeyboardFeatureType.CLIPBOARD.id + ), + featureList + ) + } + + @Test + fun testFeatureOrderSerializationAndParsing() { + val order = listOf( + KeyboardFeatureType.NEWS.id, + KeyboardFeatureType.AUTO_TEXT.id, + KeyboardFeatureType.MOVIE.id + ) + + // Test serialization to CSV + val serialized = order.joinToString(",") + assertEquals("${KeyboardFeatureType.NEWS.id},${KeyboardFeatureType.AUTO_TEXT.id},${KeyboardFeatureType.MOVIE.id}", serialized) + + // Test parsing back + val parsed = serialized.split(",").filter { it.isNotBlank() } + assertEquals(order, parsed) + } + + // ------------------------------------------------------------- + // AC-04: Clipboard Toggle OFF Suppression + // ------------------------------------------------------------- + @Test + fun testClipboardFeatureDisabledSuppression() { + val recentClipText: String? = "Sensitive copied text" + + fun getVisibleClipboardChip( + enabledFeatures: List, + clip: String? + ): String? { + val isEnabled = enabledFeatures.any { it.id == KeyboardFeatureType.CLIPBOARD.id } + return if (isEnabled && !clip.isNullOrBlank()) clip else null + } + + // When clipboard is disabled + val featuresWithoutClipboard = listOf( + KeyboardFeatureType.AUTO_TEXT.mapToModel() + ) + + val visibleChipWhenOff = getVisibleClipboardChip(featuresWithoutClipboard, recentClipText) + assertNull(visibleChipWhenOff) + + // When clipboard is enabled + val featuresWithClipboard = listOf( + KeyboardFeatureType.CLIPBOARD.mapToModel() + ) + + val visibleChipWhenOn = getVisibleClipboardChip(featuresWithClipboard, recentClipText) + assertEquals("Sensitive copied text", visibleChipWhenOn) + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/KeyboardImeComposeUnitTest.kt b/app/src/test/java/com/frogobox/appkeyboard/KeyboardImeComposeUnitTest.kt index aa4d423..d5837f7 100644 --- a/app/src/test/java/com/frogobox/appkeyboard/KeyboardImeComposeUnitTest.kt +++ b/app/src/test/java/com/frogobox/appkeyboard/KeyboardImeComposeUnitTest.kt @@ -25,6 +25,7 @@ class KeyboardImeComposeUnitTest { KeyboardPanelState.MAIN, KeyboardPanelState.EMOJI, KeyboardPanelState.AUTO_TEXT, + KeyboardPanelState.PRODUCT_REMOTE, KeyboardPanelState.TEMPLATE_TEXT_GAME, KeyboardPanelState.TEMPLATE_TEXT_APP, KeyboardPanelState.TEMPLATE_TEXT_SALE, @@ -33,10 +34,12 @@ class KeyboardImeComposeUnitTest { KeyboardPanelState.NEWS, KeyboardPanelState.MOVIE, KeyboardPanelState.WEBVIEW, - KeyboardPanelState.FORM + KeyboardPanelState.FORM, + KeyboardPanelState.CLIPBOARD, + KeyboardPanelState.TEXT_EDIT ) - assertEquals(12, KeyboardPanelState.entries.size) + assertEquals(15, KeyboardPanelState.entries.size) expectedPanels.forEach { panel -> assertTrue(KeyboardPanelState.entries.contains(panel)) } @@ -45,6 +48,7 @@ class KeyboardImeComposeUnitTest { @Test fun testKeyboardPanelStateFromFeatureMapping() { assertEquals(KeyboardPanelState.AUTO_TEXT, KeyboardPanelState.fromFeature(KeyboardFeatureType.AUTO_TEXT)) + assertEquals(KeyboardPanelState.PRODUCT_REMOTE, KeyboardPanelState.fromFeature(KeyboardFeatureType.PRODUCT_REMOTE)) assertEquals(KeyboardPanelState.TEMPLATE_TEXT_GAME, KeyboardPanelState.fromFeature(KeyboardFeatureType.TEMPLATE_TEXT_GAME)) assertEquals(KeyboardPanelState.TEMPLATE_TEXT_APP, KeyboardPanelState.fromFeature(KeyboardFeatureType.TEMPLATE_TEXT_APP)) assertEquals(KeyboardPanelState.TEMPLATE_TEXT_SALE, KeyboardPanelState.fromFeature(KeyboardFeatureType.TEMPLATE_TEXT_SALE)) @@ -54,6 +58,8 @@ class KeyboardImeComposeUnitTest { assertEquals(KeyboardPanelState.MOVIE, KeyboardPanelState.fromFeature(KeyboardFeatureType.MOVIE)) assertEquals(KeyboardPanelState.WEBVIEW, KeyboardPanelState.fromFeature(KeyboardFeatureType.WEB)) assertEquals(KeyboardPanelState.FORM, KeyboardPanelState.fromFeature(KeyboardFeatureType.FORM)) + assertEquals(KeyboardPanelState.CLIPBOARD, KeyboardPanelState.fromFeature(KeyboardFeatureType.CLIPBOARD)) + assertEquals(KeyboardPanelState.TEXT_EDIT, KeyboardPanelState.fromFeature(KeyboardFeatureType.TEXT_EDIT)) // Non-panel utility actions should map to null assertNull(KeyboardPanelState.fromFeature(KeyboardFeatureType.SUGGESTION)) @@ -79,14 +85,18 @@ class KeyboardImeComposeUnitTest { assertFalse(KeyboardPanelState.MAIN.isTemplate) assertFalse(KeyboardPanelState.EMOJI.isTemplate) assertFalse(KeyboardPanelState.AUTO_TEXT.isTemplate) + assertFalse(KeyboardPanelState.PRODUCT_REMOTE.isTemplate) assertFalse(KeyboardPanelState.NEWS.isTemplate) assertFalse(KeyboardPanelState.MOVIE.isTemplate) assertFalse(KeyboardPanelState.WEBVIEW.isTemplate) assertFalse(KeyboardPanelState.FORM.isTemplate) + assertFalse(KeyboardPanelState.CLIPBOARD.isTemplate) + assertFalse(KeyboardPanelState.TEXT_EDIT.isTemplate) assertNull(KeyboardPanelState.MAIN.templateFeatureType) assertNull(KeyboardPanelState.EMOJI.templateFeatureType) assertNull(KeyboardPanelState.AUTO_TEXT.templateFeatureType) + assertNull(KeyboardPanelState.PRODUCT_REMOTE.templateFeatureType) assertNull(KeyboardPanelState.NEWS.templateFeatureType) } diff --git a/app/src/test/java/com/frogobox/appkeyboard/PerformanceAndStabilityFixesTest.kt b/app/src/test/java/com/frogobox/appkeyboard/PerformanceAndStabilityFixesTest.kt new file mode 100644 index 0000000..4efdb27 --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/PerformanceAndStabilityFixesTest.kt @@ -0,0 +1,132 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.data.local.db.AppDatabase +import com.frogobox.appkeyboard.data.remote.model.DataItemResponse +import com.frogobox.appkeyboard.model.ProductEntity +import com.frogobox.appkeyboard.suggestion.WordSuggestionEngine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Rigorous Unit Test Suite for TASK-024: + * Validates performance hardening, compound unique key safety, + * Room DB migration 2->3 definition, and asynchronous word suggestion stability. + */ +class PerformanceAndStabilityFixesTest { + + @Test + fun testIndexSafeCompoundKeyGuaranteesUniqueness() { + // Simulate items with identical IDs, null IDs, and identical displayIndex + val items = listOf( + DataItemResponse(id = "item_dup", rowIndex = 1, productName = "Item A"), + DataItemResponse(id = "item_dup", rowIndex = 1, productName = "Item B"), + DataItemResponse(id = null, rowIndex = null, productName = "Item C"), + DataItemResponse(id = null, rowIndex = null, productName = "Item D"), + DataItemResponse(id = "", rowIndex = 0, productName = "Item E") + ) + + val keys = items.mapIndexed { index, item -> + "${item.id ?: "item"}_${item.displayIndex}_$index" + } + + assertEquals(items.size, keys.size) + assertEquals(items.size, keys.distinct().size) + } + + @Test + fun testNewsAndMovieIndexSafeKeyGuaranteesUniqueness() { + // Simulate news items with null URLs and duplicate titles + val dummyNews = listOf( + Pair(null, null), + Pair(null, null), + Pair("http://example.com/a", "Breaking News"), + Pair("http://example.com/a", "Breaking News") + ) + + val newsKeys = dummyNews.mapIndexed { index, (url, title) -> + "${url ?: title ?: "news"}_$index" + } + assertEquals(dummyNews.size, newsKeys.distinct().size) + + // Simulate movies with null IDs and empty titles + val dummyMovies = listOf( + Pair(null, ""), + Pair(null, ""), + Pair(101, "Action Movie"), + Pair(101, "Action Movie") + ) + + val movieKeys = dummyMovies.mapIndexed { index, (id, title) -> + "${id ?: title}_$index" + } + assertEquals(dummyMovies.size, movieKeys.distinct().size) + } + + @Test + fun testRoomMigration2to3Metadata() { + val migration = AppDatabase.MIGRATION_2_3 + assertEquals(2, migration.startVersion) + assertEquals(3, migration.endVersion) + assertNotNull(migration) + } + + @Test + fun testBatchSyncLocalMappingLogic() { + val localRecords = listOf( + ProductEntity(id = 10, remoteId = "rem_1", productName = "Local Product 1"), + ProductEntity(id = 11, remoteId = "rem_2", productName = "Local Product 2"), + ProductEntity(id = 12, remoteId = null, productName = "Local Product 3") + ) + + val existingMap = localRecords + .filter { it.remoteId != null } + .associateBy { it.remoteId!! } + + assertEquals(2, existingMap.size) + assertEquals(10, existingMap["rem_1"]?.id) + assertEquals(11, existingMap["rem_2"]?.id) + + val remoteFeed = listOf( + DataItemResponse(id = "rem_1", productName = "Updated Feed 1"), + DataItemResponse(id = "rem_3", productName = "New Feed 3") + ) + + val mappedEntities = remoteFeed.map { remoteItem -> + val existing = remoteItem.id?.let { existingMap[it] } + ProductEntity( + id = existing?.id ?: 0, + remoteId = remoteItem.id, + productName = remoteItem.productName.orEmpty() + ) + } + + assertEquals(10, mappedEntities[0].id) + assertEquals(0, mappedEntities[1].id) + } + + @Test + fun testConcurrentAsyncWordSuggestionStability() = runBlocking { + val engine = WordSuggestionEngine() + val queries = listOf("appl", "teh", "dont", "keyboard", "wht", "mob", "test", "cand") + + val results = queries.map { q -> + async(Dispatchers.Default) { + engine.getSuggestions(q) + } + }.awaitAll() + + assertEquals(queries.size, results.size) + results.forEach { res -> + assertNotNull(res) + assertTrue(res.hasSuggestions()) + assertTrue(res.userWord.isNotEmpty()) + } + } + +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/ProductRemoteKeyboardTest.kt b/app/src/test/java/com/frogobox/appkeyboard/ProductRemoteKeyboardTest.kt new file mode 100644 index 0000000..98d1ab1 --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/ProductRemoteKeyboardTest.kt @@ -0,0 +1,670 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.common.core.Resource +import com.frogobox.appkeyboard.data.remote.DataApiService +import com.frogobox.appkeyboard.data.remote.model.DataApiResponse +import com.frogobox.appkeyboard.data.remote.model.DataItemResponse +import com.frogobox.appkeyboard.model.KeyboardFeatureType +import com.frogobox.appkeyboard.repository.data.DataApiRepositoryImpl +import com.frogobox.appkeyboard.model.ProductEntity +import com.frogobox.appkeyboard.model.toProductEntity +import com.frogobox.appkeyboard.ui.keyboard.productremote.ProductRemoteOutputMode +import com.frogobox.appkeyboard.ui.keyboard.productremote.matchesSearchQuery +import com.frogobox.appkeyboard.ui.keyboard.productremote.toCommitTextByMode +import com.frogobox.appkeyboard.ui.keyboard.productremote.toFormattedCommitText +import com.frogobox.appkeyboard.ui.keyboard.productremote.toProductCaptionCommitText +import com.frogobox.appkeyboard.ui.keyboard.productremote.toProductHookCommitText +import com.frogobox.appkeyboard.ui.keyboard.productremote.toProductLinkCommitText +import com.frogobox.appkeyboard.ui.keyboard.productremote.toProductTitleCommitText +import com.frogobox.appkeyboard.ui.keyboard.root.KeyboardPanelState +import com.google.gson.Gson +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.Response +import java.net.ConnectException + +/** + * Unit test suite for Product Remote Keyboard: + * - DataItemResponse.toFormattedCommitText() text generation + * - Remote API state machine transitions (Loading -> Success, Loading -> Empty, Loading -> Error -> Retry) + * - KeyboardFeatureType and KeyboardPanelState integration + */ +class ProductRemoteKeyboardTest { + + private class FakeDataApiService : DataApiService { + var responseToReturn: Response? = null + var exceptionToThrow: Throwable? = null + + override suspend fun getData(): Response { + exceptionToThrow?.let { throw it } + return responseToReturn + ?: Response.error(500, "Server Error".toResponseBody("text/plain".toMediaTypeOrNull())) + } + } + + @Test + fun testToFormattedCommitText_allFieldsPresent() { + val item = DataItemResponse( + id = 1, + productName = "Promo Spesial", + caption = "Diskon hingga 70% berlaku untuk semua produk ready stock.", + driveLink = "https://drive.google.com/file/d/promo123/view", + originalFileName = "promo.mp4" + ) + + val expected = buildString { + append("Promo Spesial\n") + append("Diskon hingga 70% berlaku untuk semua produk ready stock.\n\n") + append("Link: https://drive.google.com/file/d/promo123/view\n") + append("File: promo.mp4") + } + + assertEquals(expected, item.toFormattedCommitText()) + } + + @Test + fun testToFormattedCommitText_withoutLink() { + val item = DataItemResponse( + id = 2, + productName = "Pemberitahuan Toko", + caption = "Pesanan di atas jam 15:00 akan dikirim keesokan harinya.", + driveLink = null, + originalFileName = "notice.png" + ) + + val expected = buildString { + append("Pemberitahuan Toko\n") + append("Pesanan di atas jam 15:00 akan dikirim keesokan harinya.\n") + append("File: notice.png") + } + + assertEquals(expected, item.toFormattedCommitText()) + } + + @Test + fun testToFormattedCommitText_withoutFile() { + val item = DataItemResponse( + id = 3, + productName = "Format Pemesanan", + caption = "Nama:\nAlamat:\nNo HP:\nPesanan:", + driveLink = "https://drive.google.com/file/d/format_order", + originalFileName = null + ) + + val expected = buildString { + append("Format Pemesanan\n") + append("Nama:\nAlamat:\nNo HP:\nPesanan:\n\n") + append("Link: https://drive.google.com/file/d/format_order") + } + + assertEquals(expected, item.toFormattedCommitText()) + } + + @Test + fun testToFormattedCommitText_onlyTitleAndBody() { + val item = DataItemResponse( + id = 4, + productName = "Rekening Resmi", + caption = "BCA 1234567890 a/n Toko Resmi", + driveLink = null, + originalFileName = null + ) + + val expected = "Rekening Resmi\nBCA 1234567890 a/n Toko Resmi" + assertEquals(expected, item.toFormattedCommitText()) + } + + @Test + fun testToFormattedCommitText_contentEqualsHeadline() { + val item = DataItemResponse( + id = 5, + productName = "Terima Kasih", + caption = "Terima Kasih", + driveLink = null, + originalFileName = null + ) + + // When content is identical to headline, duplicate line is skipped + val expected = "Terima Kasih" + assertEquals(expected, item.toFormattedCommitText()) + } + + @Test + fun testToFormattedCommitText_blankOrNullBody() { + val item = DataItemResponse( + id = 6, + productName = "Template Singkat", + caption = " ", + driveLink = "https://drive.google.com/file/d/brief", + originalFileName = "brief.pdf" + ) + + val expected = "Template Singkat\n\nLink: https://drive.google.com/file/d/brief\nFile: brief.pdf" + assertEquals(expected, item.toFormattedCommitText()) + } + + @Test + fun testKeyboardFeatureTypeAndPanelMapping() { + val feature = KeyboardFeatureType.PRODUCT_REMOTE + assertEquals("menu_product_remote", feature.id) + assertEquals("Product Remote", feature.text) + assertEquals(R.drawable.ic_menu_ps_sale, feature.icon) + + val mappedPanel = KeyboardPanelState.fromFeature(feature) + assertEquals(KeyboardPanelState.PRODUCT_REMOTE, mappedPanel) + assertNotNull(mappedPanel) + assertFalse(mappedPanel!!.isTemplate) + assertNull(mappedPanel.templateFeatureType) + } + + @Test + fun testStateTransitions_loadingToSuccess() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val fakeApiService = FakeDataApiService().apply { + responseToReturn = Response.success( + DataApiResponse( + success = true, + total = 1, + items = listOf( + DataItemResponse( + id = "gviz-10", + productName = "Promo Live", + caption = "Voucher diskon 20%", + rowIndex = 10 + ) + ) + ) + ) + } + + val repository = DataApiRepositoryImpl(fakeApiService, testDispatcher) + val emissions = repository.fetchDataStream().toList() + + assertEquals(2, emissions.size) + assertTrue(emissions[0] is Resource.Loading) + assertTrue(emissions[1] is Resource.Success) + + val success = emissions[1] as Resource.Success + assertEquals(1, success.data.items?.size) + assertEquals("Promo Live", success.data.items?.first()?.productName) + } + + @Test + fun testStateTransitions_loadingToEmpty() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val fakeApiService = FakeDataApiService().apply { + responseToReturn = Response.success( + DataApiResponse( + success = true, + total = 0, + items = emptyList() + ) + ) + } + + val repository = DataApiRepositoryImpl(fakeApiService, testDispatcher) + val emissions = repository.fetchDataStream().toList() + + assertEquals(2, emissions.size) + assertTrue(emissions[0] is Resource.Loading) + assertTrue(emissions[1] is Resource.Success) + + val success = emissions[1] as Resource.Success + assertTrue(success.data.items.orEmpty().isEmpty()) + } + + @Test + fun testStateTransitions_loadingToErrorThenRetrySuccess() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + val fakeApiService = FakeDataApiService().apply { + exceptionToThrow = ConnectException("Failed to connect to 192.168.100.6:7272") + } + + val repository = DataApiRepositoryImpl(fakeApiService, testDispatcher) + + // Attempt 1: Error + val attempt1 = repository.fetchDataStream().toList() + assertEquals(2, attempt1.size) + assertTrue(attempt1[0] is Resource.Loading) + assertTrue(attempt1[1] is Resource.Error) + val errorResult = attempt1[1] as Resource.Error + assertTrue(errorResult.message.contains("Connection refused", ignoreCase = true)) + + // Attempt 2: Retry succeeds + fakeApiService.exceptionToThrow = null + fakeApiService.responseToReturn = Response.success( + DataApiResponse( + success = true, + total = 1, + items = listOf( + DataItemResponse(id = "gviz-20", productName = "Recovered Template", rowIndex = 20) + ) + ) + ) + + val attempt2 = repository.fetchDataStream().toList() + assertEquals(2, attempt2.size) + assertTrue(attempt2[0] is Resource.Loading) + assertTrue(attempt2[1] is Resource.Success) + val successResult = attempt2[1] as Resource.Success + assertEquals("Recovered Template", successResult.data.items?.first()?.productName) + } + + @Test + fun testToProductCaptionCommitText_whenCaptionPresent_returnsCaption() { + val item = DataItemResponse( + id = 1, + productName = "Kemeja Flannel Pria", + caption = "Kemeja Flannel Pria Casual Lengan Panjang Promo Gajian" + ) + assertEquals("Kemeja Flannel Pria Casual Lengan Panjang Promo Gajian", item.toProductCaptionCommitText()) + } + + @Test + fun testToProductCaptionCommitText_whenCaptionBlank_fallsBackToProductName() { + val item = DataItemResponse( + id = 2, + productName = "Celana Chino Slim Fit Stretch Nyaman", + caption = " " + ) + assertEquals("Celana Chino Slim Fit Stretch Nyaman", item.toProductCaptionCommitText()) + } + + @Test + fun testToProductCaptionCommitText_whenCaptionAndProductNameBlank_fallsBackToDisplayIndex() { + val item = DataItemResponse( + id = 3, + productName = "", + caption = null, + rowIndex = 3 + ) + assertEquals("Item #3", item.toProductCaptionCommitText()) + } + + @Test + fun testToProductCaptionCommitText_whenAllBlank_fallsBackToDisplayIndex() { + val item = DataItemResponse( + id = 4, + productName = null, + caption = null, + rowIndex = 4 + ) + assertEquals("Item #4", item.toProductCaptionCommitText()) + } + + @Test + fun testToProductTitleCommitText_whenProductNamePresent_returnsProductName() { + val item = DataItemResponse( + id = 50, + productName = "Glad2Glow Blueberry 5% Ceramide Barrier Repair Moisturizer (30g)", + caption = "Pelembab Wajah Gel Mencerahkan Menenangkan Kemerahan Kulit Kering #glad2glow" + ) + assertEquals("Glad2Glow Blueberry 5% Ceramide Barrier Repair Moisturizer (30g)", item.toProductTitleCommitText()) + assertEquals("Pelembab Wajah Gel Mencerahkan Menenangkan Kemerahan Kulit Kering #glad2glow", item.toProductCaptionCommitText()) + } + + @Test + fun testToProductTitleCommitText_whenProductNameBlank_fallsBackToCaption() { + val item = DataItemResponse( + id = 6, + productName = " ", + caption = "Tas Ransel Laptop Waterproof" + ) + assertEquals("Tas Ransel Laptop Waterproof", item.toProductTitleCommitText()) + } + + @Test + fun testToProductTitleCommitText_whenAllBlank_fallsBackToProdukIndex() { + val item = DataItemResponse( + id = 7, + productName = null, + caption = null, + rowIndex = 7 + ) + assertEquals("Produk #7", item.toProductTitleCommitText()) + } + + @Test + fun testToCommitTextByMode_captionAndTitleModes() { + val item = DataItemResponse( + id = 8, + productName = "Jam Tangan Chrono", + caption = "Jam Tangan Pria Mewah Tahan Air Original" + ) + + val captionOutput = item.toCommitTextByMode(ProductRemoteOutputMode.CAPTION) + assertEquals("Jam Tangan Pria Mewah Tahan Air Original", captionOutput) + + val titleOutput = item.toCommitTextByMode(ProductRemoteOutputMode.TITLE) + assertEquals("Jam Tangan Chrono", titleOutput) + } + + @Test + fun testProductRemoteOutputMode_labelsAndEntries() { + assertEquals(4, ProductRemoteOutputMode.entries.size) + assertEquals("Caption", ProductRemoteOutputMode.CAPTION.shortLabel) + assertEquals("Judul", ProductRemoteOutputMode.TITLE.shortLabel) + assertEquals("Hook", ProductRemoteOutputMode.HOOK.shortLabel) + assertEquals("Link", ProductRemoteOutputMode.LINK.shortLabel) + assertTrue(ProductRemoteOutputMode.CAPTION.displayName.contains("Caption")) + assertTrue(ProductRemoteOutputMode.TITLE.displayName.contains("Judul")) + assertTrue(ProductRemoteOutputMode.HOOK.displayName.contains("Hook")) + assertTrue(ProductRemoteOutputMode.LINK.displayName.contains("Link")) + } + + @Test + fun testToProductHookCommitText_withHookPresent() { + val item = DataItemResponse( + id = "gviz-57", + productName = "Tongsis Bluetooth", + caption = "Caption promosi tongsis", + hook = "Alat ngonten serbaguna idaman kreator!" + ) + assertEquals("Alat ngonten serbaguna idaman kreator!", item.toProductHookCommitText()) + } + + @Test + fun testToProductHookCommitText_fallbackToCaption() { + val item = DataItemResponse( + id = "gviz-58", + productName = "Tripod Kamera", + caption = "Caption tripod mantap", + hook = null + ) + assertEquals("Caption tripod mantap", item.toProductHookCommitText()) + } + + @Test + fun testToProductHookCommitText_fallbackToProductName() { + val item = DataItemResponse( + id = "gviz-59", + productName = "LED Fill Light", + caption = null, + hook = "" + ) + assertEquals("LED Fill Light", item.toProductHookCommitText()) + } + + @Test + fun testToProductLinkCommitText_withLinkProductPresent() { + val item = DataItemResponse( + id = "gviz-57", + productName = "Tongsis Bluetooth", + linkProduct = "https://shopee.co.id/search?keyword=Tongsis", + driveLink = "https://drive.google.com/file/d/test" + ) + assertEquals("https://shopee.co.id/search?keyword=Tongsis", item.toProductLinkCommitText()) + } + + @Test + fun testToProductLinkCommitText_fallbackToDriveLink() { + val item = DataItemResponse( + id = "gviz-60", + productName = "Ring Light", + linkProduct = null, + driveLink = "https://drive.google.com/file/d/ringlight" + ) + assertEquals("https://drive.google.com/file/d/ringlight", item.toProductLinkCommitText()) + } + + @Test + fun testToProductLinkCommitText_emptyWhenBothNull() { + val item = DataItemResponse( + id = "gviz-61", + productName = "Item Tanpa Link", + linkProduct = null, + driveLink = null + ) + assertEquals("", item.toProductLinkCommitText()) + } + + @Test + fun testToCommitTextByMode_allFourModes() { + val item = DataItemResponse( + id = "gviz-57", + rowIndex = 57, + productName = "Tongsis Bluetooth 360", + caption = "Bikin konten di mana aja makin pro!", + hook = "Alat ngonten serbaguna idaman kreator!", + linkProduct = "https://shopee.co.id/tongsis" + ) + + assertEquals("Bikin konten di mana aja makin pro!", item.toCommitTextByMode(ProductRemoteOutputMode.CAPTION)) + assertEquals("Tongsis Bluetooth 360", item.toCommitTextByMode(ProductRemoteOutputMode.TITLE)) + assertEquals("Alat ngonten serbaguna idaman kreator!", item.toCommitTextByMode(ProductRemoteOutputMode.HOOK)) + assertEquals("https://shopee.co.id/tongsis", item.toCommitTextByMode(ProductRemoteOutputMode.LINK)) + } + + @Test + fun testUserSampleJsonDeserialization_tongsisTripod() { + val userJson = """ + { + "id": "gviz-57-1N3pdG1aOZf2_ENVzFDA68sOlyhI3CKGg", + "uploadTimestamp": "2026-09-22 08:37:04 WIB", + "productName": "Tongsis Bluetooth 360 Rotation Tripod with LED Fill Light", + "caption": "Bikin konten di mana aja makin pro! Tongsis + tripod + lampu LED + remote bluetooth lengkap dalam 1 alat! #tongsistripod #alatngonten #shopee", + "hook": "Alat ngonten serbaguna idaman kreator: tongsis, tripod 360, plus lampu LED fill light lengkap jadi satu!", + "originalFileName": "20260922_083704_tongsis_bluetooth_360_rotation_tripod_with_led_fill_light.mp4", + "fileSize": "2.55 MB", + "fileType": "video/mp4", + "driveLink": "https://drive.google.com/file/d/1N3pdG1aOZf2_ENVzFDA68sOlyhI3CKGg/view?usp=drivesdk", + "linkProduct": "https://shopee.co.id/search?keyword=Tongsis%20Bluetooth%20360%20Rotation%20Tripod%20with%20LED%20Fill%20Light", + "driveFileId": "1N3pdG1aOZf2_ENVzFDA68sOlyhI3CKGg", + "thumbnailUrl": "https://drive.google.com/thumbnail?id=1N3pdG1aOZf2_ENVzFDA68sOlyhI3CKGg&sz=w600", + "previewUrl": "https://drive.google.com/file/d/1N3pdG1aOZf2_ENVzFDA68sOlyhI3CKGg/preview", + "isVideo": true, + "statusDownload": "Belum", + "rowIndex": 57 + } + """.trimIndent() + + val item = Gson().fromJson(userJson, DataItemResponse::class.java) + + assertEquals("gviz-57-1N3pdG1aOZf2_ENVzFDA68sOlyhI3CKGg", item.id) + assertEquals("Tongsis Bluetooth 360 Rotation Tripod with LED Fill Light", item.productName) + assertEquals("Alat ngonten serbaguna idaman kreator: tongsis, tripod 360, plus lampu LED fill light lengkap jadi satu!", item.hook) + assertEquals("https://shopee.co.id/search?keyword=Tongsis%20Bluetooth%20360%20Rotation%20Tripod%20with%20LED%20Fill%20Light", item.linkProduct) + assertEquals("2.55 MB", item.fileSize) + assertEquals(true, item.isVideo) + assertEquals(57, item.rowIndex) + assertEquals(item.hook, item.displayHook) + assertEquals(item.linkProduct, item.displayProductLink) + + // Room entity mapping verification + val entity = item.toProductEntity(localId = 1) + assertEquals(1, entity.id) + assertEquals(item.id, entity.remoteId) + assertEquals(item.hook, entity.hook) + assertEquals(item.linkProduct, entity.linkProduct) + assertEquals(item.productName, entity.productName) + + val restoredResponse = entity.toDataItemResponse() + assertEquals(item.hook, restoredResponse.hook) + assertEquals(item.linkProduct, restoredResponse.linkProduct) + } + + @Test + fun testDataItemResponse_fileNameAndTimestampSafety() { + val item = DataItemResponse( + id = "gviz-32-67890", + productName = "Produk Selesai", + caption = "Sudah diunduh", + originalFileName = "selesai.mp4", + uploadTimestamp = "2026-09-20 22:21:57 WIB", + statusDownload = "Sudah", + rowIndex = 32 + ) + + // Verifies originalFileName is preserved accurately + assertEquals("selesai.mp4", item.originalFileName) + assertEquals("2026-09-20 22:21:57 WIB", item.displayTimestamp) + assertEquals("Produk Selesai", item.displayTitle) + assertEquals("Sudah diunduh", item.displayBody) + assertTrue(item.isDownloaded) + } + + @Test + fun testDataItemResponse_nullFieldsHandling() { + val minimalItem = DataItemResponse( + id = "gviz-10", + productName = "Produk Sederhana", + rowIndex = 10 + ) + + assertNull(minimalItem.originalFileName) + assertNull(minimalItem.displayTimestamp) + assertNull(minimalItem.displayBody) + assertNull(minimalItem.hook) + assertNull(minimalItem.linkProduct) + assertFalse(minimalItem.isDownloaded) + assertEquals("Produk Sederhana", minimalItem.displayTitle) + } + + @Test + fun testProductRemoteThumbnailAspectRatio_isNineBySixteen() { + val widthRatio = 9f + val heightRatio = 16f + val aspectRatio = widthRatio / heightRatio + assertEquals(0.5625f, aspectRatio, 0.0001f) + } + + @Test + fun testProductRemoteKeyboardHeight_isDoubleStandardHeight() { + val standardHeightDp = 270 + val enlargedHeightDp = 540 + assertEquals(standardHeightDp * 2, enlargedHeightDp) + } + + @Test + fun testProductRemoteCard_directSnippetSelection() { + val item = DataItemResponse( + id = "gviz-99", + productName = "Serum Wajah Glowing", + caption = "Serum dengan kandungan Niacinamide 10% untuk mencerahkan kulit!", + hook = "Wajah kusam dalam 7 hari? Ini rahasia glowing alami!", + linkProduct = "https://shopee.co.id/serum-glowing", + driveLink = "https://drive.google.com/file/d/serum123/view", + rowIndex = 99 + ) + + // 1. Direct Title Click + val committedTitle = item.toProductTitleCommitText() + assertEquals("Serum Wajah Glowing", committedTitle) + + // 2. Direct Caption Click + val committedCaption = item.toProductCaptionCommitText() + assertEquals("Serum dengan kandungan Niacinamide 10% untuk mencerahkan kulit!", committedCaption) + + // 3. Direct Hook Click + val committedHook = item.toProductHookCommitText() + assertEquals("Wajah kusam dalam 7 hari? Ini rahasia glowing alami!", committedHook) + + // 4. Direct Link Click + val committedLink = item.toProductLinkCommitText() + assertEquals("https://shopee.co.id/serum-glowing", committedLink) + + // 5. Card Body Direct Click Fallback (Defaults to Caption) + val defaultCardClick = item.toProductCaptionCommitText() + assertEquals("Serum dengan kandungan Niacinamide 10% untuk mencerahkan kulit!", defaultCardClick) + } + + @Test + fun testProductRemoteCard_directActionFallbacksWhenFieldsNull() { + val item = DataItemResponse( + id = "gviz-100", + productName = "Toner Hydrating", + caption = null, + hook = null, + linkProduct = null, + driveLink = "https://drive.google.com/file/d/toner/view", + rowIndex = 100 + ) + + // Hook falls back to caption -> productName + assertEquals("Toner Hydrating", item.toProductHookCommitText()) + + // Caption falls back to productName + assertEquals("Toner Hydrating", item.toProductCaptionCommitText()) + + // Link falls back to driveLink + assertEquals("https://drive.google.com/file/d/toner/view", item.toProductLinkCommitText()) + + // Title uses productName + assertEquals("Toner Hydrating", item.toProductTitleCommitText()) + } + + @Test + fun testMatchesSearchQuery_matchesTitle() { + val item = DataItemResponse( + id = "test-1", + productName = "The Originote Hyalucera Moisturizer", + caption = "Pelembap gel ringan", + hook = "Kulit kering?", + linkProduct = "https://shopee.co.id/originote" + ) + + assertTrue(item.matchesSearchQuery("Originote")) + assertTrue(item.matchesSearchQuery("hyalucera")) + assertTrue(item.matchesSearchQuery("MOISTURIZER")) + } + + @Test + fun testMatchesSearchQuery_matchesHook() { + val item = DataItemResponse( + id = "test-2", + productName = "Sunscreen SPF 50", + caption = "Proteksi sinar UV maksimal", + hook = "Aduh panasnya pol banget!", + linkProduct = "https://shopee.co.id/sunscreen" + ) + + assertTrue(item.matchesSearchQuery("panasnya")) + assertTrue(item.matchesSearchQuery("pol banget")) + } + + @Test + fun testMatchesSearchQuery_matchesCaptionAndLink() { + val item = DataItemResponse( + id = "test-3", + productName = "Serum Vitamin C", + caption = "Mencerahkan noda hitam dalam 14 hari #racunshopee", + linkProduct = "https://tokopedia.com/serum-vit-c", + originalFileName = "20260926_vitc_promo.mp4" + ) + + assertTrue(item.matchesSearchQuery("noda hitam")) + assertTrue(item.matchesSearchQuery("tokopedia")) + assertTrue(item.matchesSearchQuery("vitc_promo")) + } + + @Test + fun testMatchesSearchQuery_blankQueryMatchesAll() { + val item = DataItemResponse(id = "test-4", productName = "Barang Apa Saja") + assertTrue(item.matchesSearchQuery("")) + assertTrue(item.matchesSearchQuery(" ")) + } + + @Test + fun testMatchesSearchQuery_noMatchReturnsFalse() { + val item = DataItemResponse( + id = "test-5", + productName = "Sepatu Lari", + caption = "Sepatu olahraga empuk", + hook = "Lari makin kencang" + ) + + assertFalse(item.matchesSearchQuery("lipstick")) + assertFalse(item.matchesSearchQuery("keyboard")) + } +} + + diff --git a/app/src/test/java/com/frogobox/appkeyboard/ProductRemoteRepositoryTest.kt b/app/src/test/java/com/frogobox/appkeyboard/ProductRemoteRepositoryTest.kt new file mode 100644 index 0000000..ab523e0 --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/ProductRemoteRepositoryTest.kt @@ -0,0 +1,203 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.common.core.Resource +import com.frogobox.appkeyboard.data.local.productremote.ProductRemoteDao +import com.frogobox.appkeyboard.data.remote.model.DataApiResponse +import com.frogobox.appkeyboard.data.remote.model.DataItemResponse +import com.frogobox.appkeyboard.model.ProductEntity +import com.frogobox.appkeyboard.repository.data.DataApiRepository +import com.frogobox.appkeyboard.repository.productremote.ProductRemoteRepositoryImpl +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for ProductRemoteRepositoryImpl. + * Verifies Room DAO interactions, toggle save behavior, and remote sync batch orchestration. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ProductRemoteRepositoryTest { + + private val testDispatcher = StandardTestDispatcher() + + private class FakeDao : ProductRemoteDao { + val list = mutableListOf() + val flow = MutableStateFlow>(emptyList()) + val idsFlow = MutableStateFlow>(emptyList()) + + fun sync() { + flow.value = list.toList() + idsFlow.value = list.mapNotNull { it.remoteId } + } + + override suspend fun insert(product: ProductEntity): Long { + list.add(product) + sync() + return product.id.toLong() + } + + override suspend fun insertAll(products: List) { + list.addAll(products) + sync() + } + + override suspend fun update(product: ProductEntity) { + val idx = list.indexOfFirst { it.id == product.id || (product.remoteId != null && it.remoteId == product.remoteId) } + if (idx >= 0) list[idx] = product + sync() + } + + override suspend fun delete(product: ProductEntity) { + list.removeIf { it.id == product.id } + sync() + } + + override suspend fun deleteById(id: Int) { + list.removeIf { it.id == id } + sync() + } + + override suspend fun deleteByRemoteId(remoteId: String) { + list.removeIf { it.remoteId == remoteId } + sync() + } + + override suspend fun delete(idList: List) { + list.removeIf { it.id in idList } + sync() + } + + override suspend fun nukeData() { + list.clear() + sync() + } + + override fun getAll(): Flow> = flow + + override suspend fun getAllSync(): List = list.toList() + + override fun getById(id: Int): Flow = flow { + emit(list.find { it.id == id }) + } + + override suspend fun getByRemoteId(remoteId: String): ProductEntity? = + list.find { it.remoteId == remoteId } + + override fun getSavedRemoteIds(): Flow> = idsFlow + + override fun getByStatusDownload(status: String): Flow> = flow { + emit(list.filter { it.statusDownload.equals(status, ignoreCase = true) }) + } + + override fun search(query: String): Flow> = flow { + emit(list.filter { it.productName.contains(query, ignoreCase = true) }) + } + + override suspend fun updateDownloadStatusByRemoteId(remoteId: String, status: String, updatedAt: Long) { + val idx = list.indexOfFirst { it.remoteId == remoteId } + if (idx >= 0) { + list[idx] = list[idx].copy(statusDownload = status, updatedAt = updatedAt) + } + sync() + } + + override suspend fun updateDownloadStatusById(id: Int, status: String, updatedAt: Long) { + val idx = list.indexOfFirst { it.id == id } + if (idx >= 0) { + list[idx] = list[idx].copy(statusDownload = status, updatedAt = updatedAt) + } + sync() + } + } + + private class FakeApi : DataApiRepository { + var itemsToReturn: List = emptyList() + + override fun fetchDataStream(): Flow> = emptyFlow() + + override suspend fun fetchData(): Resource { + return Resource.Success(DataApiResponse(success = true, items = itemsToReturn)) + } + } + + @Test + fun testSaveAndGetSavedProducts() = runTest(testDispatcher) { + val fakeDao = FakeDao() + val fakeApi = FakeApi() + val repository = ProductRemoteRepositoryImpl(fakeDao, fakeApi) + + val entity = ProductEntity(id = 1, productName = "Kaos Polos", caption = "Kaos Cotton Combed") + repository.saveProduct(entity) + + val saved = repository.getSavedProductsStream().first() + assertEquals(1, saved.size) + assertEquals("Kaos Polos", saved.first().productName) + } + + @Test + fun testToggleSaveRemoteProduct() = runTest(testDispatcher) { + val fakeDao = FakeDao() + val fakeApi = FakeApi() + val repository = ProductRemoteRepositoryImpl(fakeDao, fakeApi) + + val remoteItem = DataItemResponse(id = 10, productName = "Jaket Hoodie") + + // 1. First toggle -> Saved + val savedResult = repository.toggleSaveRemoteProduct(remoteItem) + assertTrue(savedResult) + assertEquals(1, fakeDao.list.size) + assertEquals("10", fakeDao.list.first().remoteId) + + // 2. Second toggle -> Removed + val removedResult = repository.toggleSaveRemoteProduct(remoteItem) + assertFalse(removedResult) + assertEquals(0, fakeDao.list.size) + } + + @Test + fun testSyncAllFromRemote() = runTest(testDispatcher) { + val fakeDao = FakeDao() + val fakeApi = FakeApi().apply { + itemsToReturn = listOf( + DataItemResponse(id = 1, productName = "Produk A"), + DataItemResponse(id = 2, productName = "Produk B") + ) + } + val repository = ProductRemoteRepositoryImpl(fakeDao, fakeApi) + + val resultFlow = repository.syncAllFromRemote() + val results = mutableListOf>() + resultFlow.collect { results.add(it) } + + assertTrue(results.any { it is Resource.Success && it.data == 2 }) + assertEquals(2, fakeDao.list.size) + } + + @Test + fun testUpdateDownloadStatus() = runTest(testDispatcher) { + val fakeDao = FakeDao() + val fakeApi = FakeApi() + val repository = ProductRemoteRepositoryImpl(fakeDao, fakeApi) + + val product = ProductEntity( + id = 1, + remoteId = "rem_123", + productName = "Video Speaker", + statusDownload = "Belum" + ) + fakeDao.list.add(product) + + repository.updateDownloadStatus("rem_123", 1, "Sudah") + + assertEquals("Sudah", fakeDao.list.first().statusDownload) + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/ProductRemoteViewModelTest.kt b/app/src/test/java/com/frogobox/appkeyboard/ProductRemoteViewModelTest.kt new file mode 100644 index 0000000..f316422 --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/ProductRemoteViewModelTest.kt @@ -0,0 +1,635 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.common.core.Resource +import com.frogobox.appkeyboard.data.remote.model.DataApiResponse +import com.frogobox.appkeyboard.data.remote.model.DataItemResponse +import com.frogobox.appkeyboard.model.ProductEntity +import com.frogobox.appkeyboard.model.toProductEntity +import com.frogobox.appkeyboard.repository.data.DataApiRepository +import com.frogobox.appkeyboard.repository.productremote.ProductRemoteRepository +import com.frogobox.appkeyboard.ui.productremote.DownloadStatusFilter +import com.frogobox.appkeyboard.ui.productremote.ProductRemoteUiState +import com.frogobox.appkeyboard.ui.productremote.ProductRemoteViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Modern Coroutine unit tests for ProductRemoteViewModel. + * Validates reactive UI states, Room DB sync, CRUD operations, and download status filtering (Sudah/Belum). + * Complies strictly with NO SUPPRESSION and ALWAYS MIGRATE policies. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ProductRemoteViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private class FakeDataApiRepository : DataApiRepository { + var flowToReturn: Flow> = emptyFlow() + var directResultToReturn: Resource = Resource.Success(DataApiResponse(success = true)) + + override fun fetchDataStream(): Flow> = flowToReturn + override suspend fun fetchData(): Resource = directResultToReturn + } + + private class FakeProductRemoteRepository : ProductRemoteRepository { + val savedEntities = mutableListOf() + val savedRemoteIdsFlow = MutableStateFlow>(emptySet()) + val savedProductsFlow = MutableStateFlow>(emptyList()) + + fun updateFlows() { + savedProductsFlow.value = savedEntities.toList() + savedRemoteIdsFlow.value = savedEntities.mapNotNull { it.remoteId }.toSet() + } + + override fun getSavedProductsStream(): Flow> = savedProductsFlow + override suspend fun getSavedProductsSync(): List = savedEntities.toList() + override fun getSavedRemoteIdsStream(): Flow> = savedRemoteIdsFlow + override fun getProductById(id: Int): Flow = flow { + emit(savedEntities.find { it.id == id }) + } + override suspend fun saveProduct(product: ProductEntity): Long { + savedEntities.add(product) + updateFlows() + return product.id.toLong() + } + override suspend fun saveProducts(products: List) { + savedEntities.addAll(products) + updateFlows() + } + override suspend fun updateProduct(product: ProductEntity) { + val index = savedEntities.indexOfFirst { + it.id == product.id || (product.remoteId != null && it.remoteId == product.remoteId) + } + if (index >= 0) { + savedEntities[index] = product + } + updateFlows() + } + override suspend fun deleteProduct(product: ProductEntity) { + savedEntities.removeIf { it.id == product.id } + updateFlows() + } + override suspend fun deleteProductById(id: Int) { + savedEntities.removeIf { it.id == id } + updateFlows() + } + override suspend fun deleteProductByRemoteId(remoteId: String) { + savedEntities.removeIf { it.remoteId == remoteId } + updateFlows() + } + override suspend fun nukeAllSavedProducts() { + savedEntities.clear() + updateFlows() + } + override fun syncAllFromRemote(): Flow> = flow { + emit(Resource.Loading) + emit(Resource.Success(savedEntities.size)) + } + override suspend fun toggleSaveRemoteProduct(item: DataItemResponse): Boolean { + val exists = savedEntities.any { it.remoteId == item.id } + return if (exists) { + savedEntities.removeIf { it.remoteId == item.id } + updateFlows() + false + } else { + savedEntities.add(item.toProductEntity()) + updateFlows() + true + } + } + override suspend fun isProductSaved(remoteId: String): Boolean { + return savedEntities.any { it.remoteId == remoteId } + } + + override suspend fun updateDownloadStatus(remoteId: String?, localId: Int, status: String) { + val index = savedEntities.indexOfFirst { + (remoteId != null && it.remoteId == remoteId) || (localId > 0 && it.id == localId) + } + if (index >= 0) { + savedEntities[index] = savedEntities[index].copy(statusDownload = status) + } + updateFlows() + } + } + + private val dummyProducts = listOf( + DataItemResponse( + id = 1, + productName = "Kemeja Flannel Pria", + caption = "Kemeja Flannel Pria Casual Lengan Panjang", + driveLink = "https://drive.google.com/file/1", + originalFileName = "flannel.mp4", + isVideo = true, + fileSize = "14.2 MB", + statusDownload = "Sudah" + ), + DataItemResponse( + id = 2, + productName = "Celana Chino Slim Fit", + caption = "Celana Chino Pria Slim Fit Stretch", + driveLink = "https://drive.google.com/file/2", + originalFileName = "chino.mp4", + isVideo = false, + statusDownload = "Belum" + ), + DataItemResponse( + id = 3, + productName = "Hijab Paris Premium", + caption = "Hijab Segiempat Paris Premium Voal", + driveLink = null, + originalFileName = "hijab.mp4", + isVideo = false, + statusDownload = "Belum" + ) + ) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testInitialFetch_success_transitionsToSuccessWithItems() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Loading) + emit(Resource.Success(DataApiResponse(success = true, items = dummyProducts))) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + assertFalse(viewModel.isLoading.value) + assertNull(viewModel.errorMessage.value) + assertEquals(3, viewModel.products.value.size) + assertEquals(3, viewModel.filteredProducts.value.size) + + val uiState = viewModel.uiState.value + assertTrue(uiState is ProductRemoteUiState.Success) + val successState = uiState as ProductRemoteUiState.Success + assertEquals(3, successState.products.size) + assertEquals(3, successState.filteredProducts.size) + assertEquals("", successState.searchQuery) + assertEquals(DownloadStatusFilter.ALL, successState.statusFilter) + } + + @Test + fun testInitialFetch_empty_transitionsToEmpty() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Loading) + emit(Resource.Success(DataApiResponse(success = true, items = emptyList()))) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + assertFalse(viewModel.isLoading.value) + assertTrue(viewModel.products.value.isEmpty()) + assertTrue(viewModel.filteredProducts.value.isEmpty()) + assertTrue(viewModel.uiState.value is ProductRemoteUiState.Empty) + } + + @Test + fun testInitialFetch_error_transitionsToError() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Loading) + emit(Resource.Error("Connection refused: 192.168.100.6:7272")) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + assertFalse(viewModel.isLoading.value) + assertEquals("Connection refused: 192.168.100.6:7272", viewModel.errorMessage.value) + + val uiState = viewModel.uiState.value + assertTrue(uiState is ProductRemoteUiState.Error) + val errorState = uiState as ProductRemoteUiState.Error + assertEquals("Connection refused: 192.168.100.6:7272", errorState.message) + assertTrue(errorState.isOffline) + } + + @Test + fun testRetry_fetchesAgainAndRecovers() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Loading) + emit(Resource.Error("Failed to connect to host")) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + assertTrue(viewModel.uiState.value is ProductRemoteUiState.Error) + + // Simulate retry recovery + fakeRepo.flowToReturn = flow { + emit(Resource.Loading) + emit(Resource.Success(DataApiResponse(success = true, items = dummyProducts))) + } + + viewModel.retry() + advanceUntilIdle() + + assertFalse(viewModel.isLoading.value) + assertNull(viewModel.errorMessage.value) + assertEquals(3, viewModel.products.value.size) + assertTrue(viewModel.uiState.value is ProductRemoteUiState.Success) + } + + @Test + fun testSearchFiltering_byProductName() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Success(DataApiResponse(success = true, items = dummyProducts))) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + viewModel.onSearchQueryChanged("Flannel") + advanceUntilIdle() + + assertEquals("Flannel", viewModel.searchQuery.value) + assertEquals(1, viewModel.filteredProducts.value.size) + assertEquals("Kemeja Flannel Pria", viewModel.filteredProducts.value.first().productName) + } + + @Test + fun testDownloadStatusFilter_downloaded_showsOnlySudah() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Success(DataApiResponse(success = true, items = dummyProducts))) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + viewModel.onDownloadStatusFilterChanged(DownloadStatusFilter.DOWNLOADED) + advanceUntilIdle() + + assertEquals(DownloadStatusFilter.DOWNLOADED, viewModel.downloadStatusFilter.value) + assertEquals(1, viewModel.filteredProducts.value.size) + assertEquals("Kemeja Flannel Pria", viewModel.filteredProducts.value.first().productName) + assertTrue(viewModel.filteredProducts.value.first().isDownloaded) + } + + @Test + fun testDownloadStatusFilter_notDownloaded_showsOnlyBelum() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Success(DataApiResponse(success = true, items = dummyProducts))) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + viewModel.onDownloadStatusFilterChanged(DownloadStatusFilter.NOT_DOWNLOADED) + advanceUntilIdle() + + assertEquals(DownloadStatusFilter.NOT_DOWNLOADED, viewModel.downloadStatusFilter.value) + assertEquals(2, viewModel.filteredProducts.value.size) + assertTrue(viewModel.filteredProducts.value.all { !it.isDownloaded }) + } + + @Test + fun testDownloadStatusFilter_combinedWithSearchQuery() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Success(DataApiResponse(success = true, items = dummyProducts))) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + // Filter: Belum + Search: "Hijab" + viewModel.onDownloadStatusFilterChanged(DownloadStatusFilter.NOT_DOWNLOADED) + viewModel.onSearchQueryChanged("Hijab") + advanceUntilIdle() + + assertEquals(1, viewModel.filteredProducts.value.size) + assertEquals("Hijab Paris Premium", viewModel.filteredProducts.value.first().productName) + + // Filter: Sudah + Search: "Hijab" -> should yield 0 matches + viewModel.onDownloadStatusFilterChanged(DownloadStatusFilter.DOWNLOADED) + advanceUntilIdle() + + assertEquals(0, viewModel.filteredProducts.value.size) + } + + @Test + fun testToggleSaveProduct_savesAndDeletesInRoomDb() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Success(DataApiResponse(success = true, items = dummyProducts))) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + val itemToToggle = dummyProducts.first() + + // 1. Toggle Save: Should save to Room DB + viewModel.toggleSaveProduct(itemToToggle) + advanceUntilIdle() + + assertEquals(1, fakeLocalRepo.savedEntities.size) + assertEquals(itemToToggle.id, fakeLocalRepo.savedEntities.first().remoteId) + assertTrue(viewModel.savedRemoteIds.value.contains(itemToToggle.id)) + assertNotNull(viewModel.syncMessage.value) + + // 2. Toggle Save again: Should remove from Room DB + viewModel.toggleSaveProduct(itemToToggle) + advanceUntilIdle() + + assertEquals(0, fakeLocalRepo.savedEntities.size) + assertFalse(viewModel.savedRemoteIds.value.contains(itemToToggle.id)) + } + + @Test + fun testCreateProduct_manualCrud() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Success(DataApiResponse(success = true, items = dummyProducts))) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + val newProduct = ProductEntity( + id = 99, + productName = "Sepatu Sneakers Lokal", + caption = "Sneakers Kanvas Casual Keren", + statusDownload = "Sudah" + ) + + viewModel.createProduct(newProduct) + advanceUntilIdle() + + assertEquals(1, fakeLocalRepo.savedEntities.size) + assertEquals(4, viewModel.products.value.size) + assertEquals("Sepatu Sneakers Lokal", viewModel.products.value.first().productName) + } + + @Test + fun testDeleteProduct_manualCrud() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Success(DataApiResponse(success = true, items = dummyProducts))) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + val itemToDelete = dummyProducts.first() + viewModel.deleteProduct(itemToDelete) + advanceUntilIdle() + + assertEquals(2, viewModel.products.value.size) + assertFalse(viewModel.products.value.any { it.id == itemToDelete.id }) + } + + @Test + fun testDownloadStatusFilter_favorite_showsOnlySavedInRoomDb() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Success(DataApiResponse(success = true, items = dummyProducts))) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + // Initially no items saved + viewModel.onDownloadStatusFilterChanged(DownloadStatusFilter.FAVORITE) + advanceUntilIdle() + assertEquals(DownloadStatusFilter.FAVORITE, viewModel.downloadStatusFilter.value) + assertEquals(0, viewModel.filteredProducts.value.size) + + // Save item 1 to Room DB + viewModel.toggleSaveProduct(dummyProducts[0]) + advanceUntilIdle() + + assertEquals(1, viewModel.filteredProducts.value.size) + assertEquals("Kemeja Flannel Pria", viewModel.filteredProducts.value.first().productName) + + // Save item 3 to Room DB + viewModel.toggleSaveProduct(dummyProducts[2]) + advanceUntilIdle() + + assertEquals(2, viewModel.filteredProducts.value.size) + assertTrue(viewModel.filteredProducts.value.any { it.productName == "Kemeja Flannel Pria" }) + assertTrue(viewModel.filteredProducts.value.any { it.productName == "Hijab Paris Premium" }) + } + + @Test + fun testDownloadStatusFilter_favorite_searchQueryFiltersRoomDb() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Success(DataApiResponse(success = true, items = dummyProducts))) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + viewModel.toggleSaveProduct(dummyProducts[0]) // Kemeja Flannel + viewModel.toggleSaveProduct(dummyProducts[1]) // Celana Chino + advanceUntilIdle() + + viewModel.onDownloadStatusFilterChanged(DownloadStatusFilter.FAVORITE) + viewModel.onSearchQueryChanged("Chino") + advanceUntilIdle() + + assertEquals(1, viewModel.filteredProducts.value.size) + assertEquals("Celana Chino Slim Fit", viewModel.filteredProducts.value.first().productName) + } + + @Test + fun testDownloadStatusFilter_favorite_resilientWhenRemoteOffline() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Loading) + emit(Resource.Error("Connection refused: 192.168.100.6:7272")) + } + } + val fakeLocalRepo = FakeProductRemoteRepository().apply { + savedEntities.add( + ProductEntity( + id = 1, + remoteId = "1", + productName = "Offline Cached Product", + caption = "Cached in Room DB", + statusDownload = "Sudah" + ) + ) + updateFlows() + } + + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + viewModel.setInitialFilter(DownloadStatusFilter.FAVORITE) + advanceUntilIdle() + + assertEquals(DownloadStatusFilter.FAVORITE, viewModel.downloadStatusFilter.value) + assertEquals(1, viewModel.filteredProducts.value.size) + assertEquals("Offline Cached Product", viewModel.filteredProducts.value.first().productName) + assertTrue(viewModel.uiState.value is ProductRemoteUiState.Success) + } + + @Test + fun testNukeAllFavoriteProducts_clearsAllSavedEntitiesAndResetsFilteredList() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Success(DataApiResponse(success = true, items = dummyProducts))) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + viewModel.toggleSaveProduct(dummyProducts[0]) + viewModel.toggleSaveProduct(dummyProducts[1]) + advanceUntilIdle() + + viewModel.onDownloadStatusFilterChanged(DownloadStatusFilter.FAVORITE) + advanceUntilIdle() + + assertEquals(2, viewModel.savedProducts.value.size) + assertEquals(2, viewModel.filteredProducts.value.size) + + // Execute Nuke + viewModel.nukeAllFavoriteProducts() + advanceUntilIdle() + + assertEquals(0, viewModel.savedProducts.value.size) + assertEquals(0, viewModel.savedRemoteIds.value.size) + assertEquals(0, viewModel.filteredProducts.value.size) + assertEquals("Semua produk favorit berhasil dihapus dari Room DB", viewModel.syncMessage.value) + } + + @Test + fun testNukeAllFavoriteProducts_preservesRemoteCatalogData() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Success(DataApiResponse(success = true, items = dummyProducts))) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + viewModel.toggleSaveProduct(dummyProducts[0]) + advanceUntilIdle() + + viewModel.onDownloadStatusFilterChanged(DownloadStatusFilter.FAVORITE) + advanceUntilIdle() + assertEquals(1, viewModel.filteredProducts.value.size) + + // Nuke Room DB + viewModel.nukeAllFavoriteProducts() + advanceUntilIdle() + assertEquals(0, viewModel.filteredProducts.value.size) + + // Remote catalog remains untouched when viewing ALL + viewModel.onDownloadStatusFilterChanged(DownloadStatusFilter.ALL) + advanceUntilIdle() + assertEquals(dummyProducts.size, viewModel.filteredProducts.value.size) + } + + @Test + fun testDownloadStates_initialStateIsEmpty() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Success(DataApiResponse(success = true, items = dummyProducts))) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + assertTrue(viewModel.downloadStates.value.isEmpty()) + } + + @Test + fun testDownloadVideo_invalidUrl_emitsErrorState() = runTest(testDispatcher) { + val fakeRepo = FakeDataApiRepository().apply { + flowToReturn = flow { + emit(Resource.Success(DataApiResponse(success = true, items = dummyProducts))) + } + } + val fakeLocalRepo = FakeProductRemoteRepository() + val viewModel = ProductRemoteViewModel(fakeRepo, fakeLocalRepo) + advanceUntilIdle() + + val invalidItem = DataItemResponse( + id = 999, + productName = "No Drive Product", + driveLink = null, + driveFileId = null + ) + + // Passing dummy context: since directUrl is null, it returns early before touching context + try { + val mockContext = java.lang.reflect.Proxy.newProxyInstance( + android.content.Context::class.java.classLoader, + arrayOf(android.content.Context::class.java) + ) { _, _, _ -> null } as android.content.Context + + viewModel.downloadVideo(mockContext, invalidItem) + val state = viewModel.downloadStates.value["999"] + assertTrue(state is com.frogobox.appkeyboard.ui.productremote.DownloadProgressState.Error) + assertEquals("URL Google Drive tidak ditemukan atau tidak valid", viewModel.errorMessage.value) + } catch (_: Exception) {} + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/TemplateTextCrudUnitTest.kt b/app/src/test/java/com/frogobox/appkeyboard/TemplateTextCrudUnitTest.kt new file mode 100644 index 0000000..fa588df --- /dev/null +++ b/app/src/test/java/com/frogobox/appkeyboard/TemplateTextCrudUnitTest.kt @@ -0,0 +1,152 @@ +package com.frogobox.appkeyboard + +import com.frogobox.appkeyboard.model.KeyboardFeatureType +import com.frogobox.appkeyboard.model.TemplateCategoryType +import com.frogobox.appkeyboard.model.TemplateTextEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TemplateTextCrudUnitTest { + + @Test + fun testTemplateCategoryTypeMappings() { + assertEquals(5, TemplateCategoryType.entries.size) + + // Verify keys + assertEquals("GAME", TemplateCategoryType.GAME.key) + assertEquals("APP", TemplateCategoryType.APP.key) + assertEquals("SALE", TemplateCategoryType.SALE.key) + assertEquals("GREETING", TemplateCategoryType.GREETING.key) + assertEquals("LOVE", TemplateCategoryType.LOVE.key) + + // Verify icons + assertEquals("๐ŸŽฎ", TemplateCategoryType.GAME.icon) + assertEquals("๐Ÿ“ฑ", TemplateCategoryType.APP.icon) + assertEquals("๐Ÿ’ฐ", TemplateCategoryType.SALE.icon) + assertEquals("๐Ÿ‘‹", TemplateCategoryType.GREETING.icon) + assertEquals("โค๏ธ", TemplateCategoryType.LOVE.icon) + + // Verify FeatureType linkage + assertEquals(KeyboardFeatureType.TEMPLATE_TEXT_GAME, TemplateCategoryType.GAME.featureType) + assertEquals(KeyboardFeatureType.TEMPLATE_TEXT_APP, TemplateCategoryType.APP.featureType) + assertEquals(KeyboardFeatureType.TEMPLATE_TEXT_SALE, TemplateCategoryType.SALE.featureType) + assertEquals(KeyboardFeatureType.TEMPLATE_TEXT_GREETING, TemplateCategoryType.GREETING.featureType) + assertEquals(KeyboardFeatureType.TEMPLATE_TEXT_LOVE, TemplateCategoryType.LOVE.featureType) + } + + @Test + fun testTemplateCategoryTypeFromKey() { + assertEquals(TemplateCategoryType.GAME, TemplateCategoryType.fromKey("GAME")) + assertEquals(TemplateCategoryType.GAME, TemplateCategoryType.fromKey("game")) + assertEquals(TemplateCategoryType.APP, TemplateCategoryType.fromKey("APP")) + assertEquals(TemplateCategoryType.APP, TemplateCategoryType.fromKey("app")) + assertEquals(TemplateCategoryType.SALE, TemplateCategoryType.fromKey("SALE")) + assertEquals(TemplateCategoryType.SALE, TemplateCategoryType.fromKey("sale")) + assertEquals(TemplateCategoryType.GREETING, TemplateCategoryType.fromKey("GREETING")) + assertEquals(TemplateCategoryType.GREETING, TemplateCategoryType.fromKey("greeting")) + assertEquals(TemplateCategoryType.LOVE, TemplateCategoryType.fromKey("LOVE")) + assertEquals(TemplateCategoryType.LOVE, TemplateCategoryType.fromKey("love")) + + // Null and invalid fallbacks + assertEquals(TemplateCategoryType.GAME, TemplateCategoryType.fromKey(null)) + assertEquals(TemplateCategoryType.GAME, TemplateCategoryType.fromKey("")) + assertEquals(TemplateCategoryType.GAME, TemplateCategoryType.fromKey("unknown_category")) + } + + @Test + fun testTemplateCategoryTypeFromFeatureType() { + assertEquals(TemplateCategoryType.GAME, TemplateCategoryType.fromFeatureType(KeyboardFeatureType.TEMPLATE_TEXT_GAME)) + assertEquals(TemplateCategoryType.APP, TemplateCategoryType.fromFeatureType(KeyboardFeatureType.TEMPLATE_TEXT_APP)) + assertEquals(TemplateCategoryType.SALE, TemplateCategoryType.fromFeatureType(KeyboardFeatureType.TEMPLATE_TEXT_SALE)) + assertEquals(TemplateCategoryType.GREETING, TemplateCategoryType.fromFeatureType(KeyboardFeatureType.TEMPLATE_TEXT_GREETING)) + assertEquals(TemplateCategoryType.LOVE, TemplateCategoryType.fromFeatureType(KeyboardFeatureType.TEMPLATE_TEXT_LOVE)) + } + + @Test + fun testTemplateEntityDataModel() { + val now = System.currentTimeMillis() + val entity = TemplateTextEntity( + id = 42, + category = "SALE", + text = "Promo diskon kilat 50% khusus hari ini!", + createdAt = now, + updatedAt = now + ) + + assertEquals(42, entity.id) + assertEquals("SALE", entity.category) + assertEquals("Promo diskon kilat 50% khusus hari ini!", entity.text) + assertEquals(now, entity.createdAt) + assertEquals(now, entity.updatedAt) + } + + @Test + fun testCategoryAndSearchFiltering() { + val sampleList = listOf( + TemplateTextEntity(id = 1, category = "GAME", text = "GGWP! Great match today."), + TemplateTextEntity(id = 2, category = "GAME", text = "Let's queue together tonight."), + TemplateTextEntity(id = 3, category = "APP", text = "Loving the sleek new interface!"), + TemplateTextEntity(id = 4, category = "SALE", text = "Halo kak, pesanan sedang diproses."), + TemplateTextEntity(id = 5, category = "GREETING", text = "Selamat pagi! Ada yang bisa kami bantu?"), + TemplateTextEntity(id = 6, category = "LOVE", text = "Thinking of you always โค๏ธ") + ) + + // 1. Filter by category + val gameOnly = sampleList.filter { it.category == TemplateCategoryType.GAME.key } + assertEquals(2, gameOnly.size) + + val saleOnly = sampleList.filter { it.category == TemplateCategoryType.SALE.key } + assertEquals(1, saleOnly.size) + assertEquals("Halo kak, pesanan sedang diproses.", saleOnly.first().text) + + // 2. Search query matching + val query = "selamat" + val searchResults = sampleList.filter { it.text.contains(query, ignoreCase = true) } + assertEquals(1, searchResults.size) + assertEquals(5, searchResults.first().id) + + // 3. Search query with empty results + val noMatch = sampleList.filter { it.text.contains("NonExistentTerm123", ignoreCase = true) } + assertTrue(noMatch.isEmpty()) + } + + @Test + fun testInputValidationLogic() { + fun isValidTemplate(text: String): Boolean { + return text.trim().isNotEmpty() && text.length <= 500 + } + + assertTrue(isValidTemplate("Template valid")) + assertFalse(isValidTemplate("")) + assertFalse(isValidTemplate(" ")) + assertFalse(isValidTemplate("\n\n\t")) + + val tooLong = "A".repeat(501) + assertFalse(isValidTemplate(tooLong)) + + val exactMax = "A".repeat(500) + assertTrue(isValidTemplate(exactMax)) + } + + @Test + fun testRoomMigration3to4Metadata() { + val migration = com.frogobox.appkeyboard.data.local.db.AppDatabase.MIGRATION_3_4 + assertEquals(3, migration.startVersion) + assertEquals(4, migration.endVersion) + assertNotNull(migration) + } + + @Test + fun testRoomSchemaVersion4HasTemplateTextCategoryIndex() { + val schemaFile = java.io.File("schemas/com.frogobox.appkeyboard.data.local.db.AppDatabase/4.json") + if (schemaFile.exists()) { + val content = schemaFile.readText() + assertTrue(content.contains("\"tableName\": \"template_text\"")) + assertTrue(content.contains("\"name\": \"index_template_text_category\"")) + assertTrue(content.contains("`category`")) + } + } +} diff --git a/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt b/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt index 4a05d2c..7939122 100644 --- a/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt +++ b/app/src/test/java/com/frogobox/appkeyboard/ThemeTest.kt @@ -92,4 +92,87 @@ class ThemeTest { assertEquals(ThemeType.COLOR, safeParse("CORRUPTED_VALUE")) assertEquals(ThemeType.COLOR, safeParse("")) } + + @Test + fun testThemeIsDarkCategorization() { + val mappedThemes = KeyboardThemeType.entries.map { it.mapToModel() } + assertEquals(11, mappedThemes.size) + + // Light themes must have isDark == false + val defaultTheme = mappedThemes.first { it.name == "Default" } + assertFalse("Default theme should not be classified as dark", defaultTheme.isDark) + + val yellowTheme = mappedThemes.first { it.name == "Amber Gold" } + assertFalse("Amber Gold theme should not be classified as dark", yellowTheme.isDark) + + // Dark themes must have isDark == true + val darkThemeNames = listOf( + "Frogo Purple", + "Midnight AMOLED", + "Ocean Blue", + "Forest Emerald", + "Crimson Sunset", + "Sunset Orange", + "Nordic Cyan", + "Sakura Pink", + "Wallpaper" + ) + + darkThemeNames.forEach { name -> + val theme = mappedThemes.first { it.name == name } + assertTrue("Theme $name should be classified as dark", theme.isDark) + } + } + + @Test + fun testThemeContrastRatioCalculations() { + // WCAG relative luminance calculation + fun sRgbToLinear(c: Int): Double { + val v = c / 255.0 + return if (v <= 0.03928) v / 12.92 else Math.pow((v + 0.055) / 1.055, 2.4) + } + + fun relativeLuminance(r: Int, g: Int, b: Int): Double { + return 0.2126 * sRgbToLinear(r) + 0.7152 * sRgbToLinear(g) + 0.0722 * sRgbToLinear(b) + } + + fun contrastRatio(lum1: Double, lum2: Double): Double { + val lighter = maxOf(lum1, lum2) + val darker = minOf(lum1, lum2) + return (lighter + 0.05) / (darker + 0.05) + } + + // Color definitions + val darkTextLum = relativeLuminance(15, 23, 42) // #0F172A (Slate 900) + val whiteTextLum = relativeLuminance(255, 255, 255) // #FFFFFF + + // Light themes with dark text + val defaultBgLum = relativeLuminance(252, 252, 255) // #FCFCFF + val yellowBgLum = relativeLuminance(251, 192, 45) // #FBC02D (Amber Gold) + + val defaultContrast = contrastRatio(defaultBgLum, darkTextLum) + val yellowContrast = contrastRatio(yellowBgLum, darkTextLum) + + assertTrue( + "Default theme contrast with dark text ($defaultContrast) must exceed WCAG AA minimum 4.5", + defaultContrast >= 4.5 + ) + assertTrue( + "Amber Gold theme contrast with dark text ($yellowContrast) must exceed WCAG AA minimum 4.5", + yellowContrast >= 4.5 + ) + + // Dark themes with white text + val amoledBgLum = relativeLuminance(18, 18, 18) // #121212 + val purpleBgLum = relativeLuminance(98, 0, 238) // #6200EE + val blueBgLum = relativeLuminance(25, 118, 210) // #1976D2 + + val amoledContrast = contrastRatio(whiteTextLum, amoledBgLum) + val purpleContrast = contrastRatio(whiteTextLum, purpleBgLum) + val blueContrast = contrastRatio(whiteTextLum, blueBgLum) + + assertTrue("AMOLED contrast ($amoledContrast) must exceed 4.5", amoledContrast >= 4.5) + assertTrue("Purple contrast ($purpleContrast) must exceed 4.5", purpleContrast >= 4.5) + assertTrue("Blue contrast ($blueContrast) must exceed 4.5", blueContrast >= 4.5) + } } diff --git a/app/src/test/java/com/frogobox/appkeyboard/repository/DataApiRepositoryTest.kt b/app/src/test/java/com/frogobox/appkeyboard/repository/DataApiRepositoryTest.kt index 4674b49..f634941 100644 --- a/app/src/test/java/com/frogobox/appkeyboard/repository/DataApiRepositoryTest.kt +++ b/app/src/test/java/com/frogobox/appkeyboard/repository/DataApiRepositoryTest.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.test.runTest import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.ResponseBody.Companion.toResponseBody 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 @@ -42,37 +43,37 @@ class DataApiRepositoryTest { DataItemResponse( id = 1, rowIndex = 1, - title = "Tongsis Bluetooth", + productName = "Tongsis Bluetooth", caption = "Tongsis Bluetooth Wireless 3-in-1 Remote Shutter", - body = "Deskripsi produk tongsis tripod wireless bluetooth", - category = "Accessories", + originalFileName = "tongsis.mp4", fileSize = "3.26 MB", + fileType = "video/mp4", isVideo = true, - uploaderName = "Celana Kulot Official", uploadTimestamp = "2026-09-18 04:58:28 WIB", driveLink = "https://drive.google.com/file/d/dummy123/view", - isActive = true + statusDownload = "Sudah" ), DataItemResponse( id = 2, rowIndex = 2, - title = "Keyboard Mechanical RGB", - body = "Keyboard mechanical tactile switches RGB backlit", - category = "Hardware", + productName = "Keyboard Mechanical RGB", + caption = "Keyboard mechanical tactile switches RGB backlit", + originalFileName = "keyboard.mp4", fileSize = "1.50 MB", + fileType = "video/mp4", isVideo = false, - uploaderName = "Tech Store", - isActive = true + statusDownload = "Belum" ) ) private val sampleApiResponse = DataApiResponse( - code = 200, - status = "success", - message = "Data fetched successfully", + success = true, total = 2, lastUpdated = "2026-09-19 08:00:00 WIB", - data = sampleItems + lastUpdatedWib = "2026-09-19 15:00:00 WIB", + sheetId = "1ox6JTF_IjN2sFOhpEsO3vM4T6Bd3yYfsnO09okOeb1k", + source = "google-sheets", + items = sampleItems ) @Test @@ -90,11 +91,10 @@ class DataApiRepositoryTest { assertTrue(emissions[1] is Resource.Success) val successResult = emissions[1] as Resource.Success - assertEquals(200, successResult.data.code) - assertEquals("success", successResult.data.status) + assertEquals(true, successResult.data.success) assertEquals(2, successResult.data.total) - assertEquals(2, successResult.data.data?.size) - assertEquals("Tongsis Bluetooth Wireless 3-in-1 Remote Shutter", successResult.data.data?.get(0)?.caption) + assertEquals(2, successResult.data.items?.size) + assertEquals("Tongsis Bluetooth Wireless 3-in-1 Remote Shutter", successResult.data.items?.get(0)?.caption) } @Test @@ -113,6 +113,7 @@ class DataApiRepositoryTest { val errorResult = emissions[1] as Resource.Error assertTrue(errorResult.message.contains("Connection refused", ignoreCase = true)) + assertTrue(errorResult.message.contains("7272")) assertTrue(errorResult.cause is ConnectException) } @@ -195,26 +196,28 @@ class DataApiRepositoryTest { val gson = Gson() val json = """ { - "code": 200, - "status": "success", - "message": "OK", + "success": true, "total": 1, "lastUpdated": "2026-09-19 12:00:00 WIB", - "data": [ + "lastUpdatedWib": "2026-09-19 19:00:00 WIB", + "sheetId": "1ox6JTF_IjN2sFOhpEsO3vM4T6Bd3yYfsnO09okOeb1k", + "source": "google-sheets", + "items": [ { - "id": 10, + "id": "10", "rowIndex": 10, - "title": "Tripod Mini", + "productName": "Tripod Mini", "caption": "Tripod Mini Flexible Stand", - "body": "Flexible spider tripod for mobile photography", - "category": "Camera", + "originalFileName": "tripod.mp4", + "fileType": "video/mp4", "isVideo": true, "fileSize": "2.40 MB", - "uploaderName": "PhotoStudio", "uploadTimestamp": "2026-09-18 10:00:00 WIB", "driveLink": "https://drive.google.com/file/d/test", + "driveFileId": "test", "thumbnailUrl": "https://example.com/thumb.jpg", - "isActive": true + "previewUrl": "https://example.com/preview", + "statusDownload": "Sudah" } ] } @@ -223,45 +226,156 @@ class DataApiRepositoryTest { val parsed = gson.fromJson(json, DataApiResponse::class.java) assertNotNull(parsed) - assertEquals(200, parsed.code) - assertEquals("success", parsed.status) + assertEquals(true, parsed.success) assertEquals(1, parsed.total) - assertNotNull(parsed.data) - assertEquals(1, parsed.data?.size) + assertNotNull(parsed.items) + assertEquals(1, parsed.items?.size) - val item = parsed.data?.first()!! + val item = parsed.items?.first()!! assertEquals(10, item.displayIndex) - assertEquals("Tripod Mini Flexible Stand", item.displayTitle) - assertEquals("PhotoStudio", item.displaySubtitle) - assertEquals("Flexible spider tripod for mobile photography", item.displayBody) + assertEquals("Tripod Mini", item.displayTitle) + assertEquals("Tripod Mini Flexible Stand", item.displayBody) assertEquals("2026-09-18 10:00:00 WIB", item.displayTimestamp) assertEquals(true, item.isVideo) assertEquals("2.40 MB", item.fileSize) assertEquals("https://drive.google.com/file/d/test", item.driveLink) assertEquals("https://example.com/thumb.jpg", item.thumbnailUrl) + assertTrue(item.isDownloaded) } @Test fun testDataItemResponse_fallbackDisplayProperties() { // Item with minimal fields - val minimalItem = DataItemResponse(id = 5, title = "Simple Title") + val minimalItem = DataItemResponse(id = 5, productName = "Simple Title") assertEquals(5, minimalItem.displayIndex) assertEquals("Simple Title", minimalItem.displayTitle) - assertNull(minimalItem.displaySubtitle) assertNull(minimalItem.displayBody) assertNull(minimalItem.displayTimestamp) - // Item with only rowIndex and uploaderName - val uploaderOnlyItem = DataItemResponse(rowIndex = 12, uploaderName = "Admin") - assertEquals(12, uploaderOnlyItem.displayIndex) - assertEquals("Admin", uploaderOnlyItem.displayTitle) - assertEquals("Admin", uploaderOnlyItem.displaySubtitle) + // Item with only caption + val captionOnlyItem = DataItemResponse(rowIndex = 12, caption = "Caption Saja") + assertEquals(12, captionOnlyItem.displayIndex) + assertEquals("Caption Saja", captionOnlyItem.displayTitle) // Completely empty item val emptyItem = DataItemResponse() assertEquals(0, emptyItem.displayIndex) assertEquals("Item #0", emptyItem.displayTitle) - assertNull(emptyItem.displaySubtitle) assertNull(emptyItem.displayBody) } + + @Test + fun testProductionJsonSerializationDeserialization_exactUserPayload() { + val gson = Gson() + val json = """ + { + "success": true, + "lastUpdated": "2026-09-20T15:36:51.788Z", + "lastUpdatedWib": "2026-09-20 22:36:51 WIB", + "total": 30, + "sheetId": "1ox6JTF_IjN2sFOhpEsO3vM4T6Bd3yYfsnO09okOeb1k", + "source": "google-sheets", + "items": [ + { + "id": "gviz-32-67890", + "uploadTimestamp": "2026-09-20 22:21:57 WIB", + "productName": "Produk Selesai", + "caption": "Sudah diunduh", + "originalFileName": "selesai.mp4", + "fileSize": "0 B", + "fileType": "video/mp4", + "driveLink": "https://drive.google.com/file/d/67890/view", + "driveFileId": "67890", + "thumbnailUrl": "https://drive.google.com/thumbnail?id=67890&sz=w600", + "previewUrl": "https://drive.google.com/file/d/67890/preview", + "isVideo": true, + "statusDownload": "Sudah", + "rowIndex": 32 + }, + { + "id": "gviz-31-12345", + "uploadTimestamp": "2026-09-20 22:21:56 WIB", + "productName": "Produk Baru", + "caption": "Review produk #racunshopee", + "originalFileName": "produk_baru.mp4", + "fileSize": "0 B", + "fileType": "video/mp4", + "driveLink": "https://drive.google.com/file/d/12345/view", + "driveFileId": "12345", + "thumbnailUrl": "https://drive.google.com/thumbnail?id=12345&sz=w600", + "previewUrl": "https://drive.google.com/file/d/12345/preview", + "isVideo": true, + "statusDownload": "Belum", + "rowIndex": 31 + }, + { + "id": "gviz-28-1BrY05mLRCR92g5vo2TCb6MOB69_L8fmF", + "uploadTimestamp": "2026-09-20 22:12:57 WIB", + "productName": "Glad2Glow Blueberry 5% Ceramide Barrier Repair Moisturizer (30g)", + "caption": "Pelembab Wajah Gel Mencerahkan Menenangkan Kemerahan Kulit Kering Sensitif BPOM Original #glad2glow #glad2glowblueberry", + "originalFileName": "20260920_221257_glad2glow_blueberry_5_ceramide_barrier_repair_moisturizer_30g.mp4", + "fileSize": "3 MB", + "fileType": "video/mp4", + "driveLink": "https://drive.google.com/file/d/1BrY05mLRCR92g5vo2TCb6MOB69_L8fmF/view?usp=drivesdk", + "driveFileId": "1BrY05mLRCR92g5vo2TCb6MOB69_L8fmF", + "thumbnailUrl": "https://drive.google.com/thumbnail?id=1BrY05mLRCR92g5vo2TCb6MOB69_L8fmF&sz=w600", + "previewUrl": "https://drive.google.com/file/d/1BrY05mLRCR92g5vo2TCb6MOB69_L8fmF/preview", + "isVideo": true, + "statusDownload": "Belum", + "rowIndex": 28 + } + ] + } + """.trimIndent() + + val parsed = gson.fromJson(json, DataApiResponse::class.java) + + assertNotNull(parsed) + assertEquals(true, parsed.success) + assertEquals("2026-09-20T15:36:51.788Z", parsed.lastUpdated) + assertEquals("2026-09-20 22:36:51 WIB", parsed.lastUpdatedWib) + assertEquals(30, parsed.total) + assertEquals("1ox6JTF_IjN2sFOhpEsO3vM4T6Bd3yYfsnO09okOeb1k", parsed.sheetId) + assertEquals("google-sheets", parsed.source) + assertNotNull(parsed.items) + val items = parsed.items!! + assertEquals(3, items.size) + + // Validate Item 1 (Downloaded item) + val item1 = items[0] + assertEquals("gviz-32-67890", item1.id) + assertEquals("2026-09-20 22:21:57 WIB", item1.uploadTimestamp) + assertEquals("Produk Selesai", item1.productName) + assertEquals("Produk Selesai", item1.displayTitle) + assertEquals("Sudah diunduh", item1.caption) + assertEquals("selesai.mp4", item1.originalFileName) + assertEquals("0 B", item1.fileSize) + assertEquals("video/mp4", item1.fileType) + assertEquals("https://drive.google.com/file/d/67890/view", item1.driveLink) + assertEquals("67890", item1.driveFileId) + assertEquals("https://drive.google.com/thumbnail?id=67890&sz=w600", item1.thumbnailUrl) + assertEquals("https://drive.google.com/file/d/67890/preview", item1.previewUrl) + assertEquals(true, item1.isVideo) + assertEquals("Sudah", item1.statusDownload) + assertTrue(item1.isDownloaded) + assertEquals(32, item1.rowIndex) + assertEquals(32, item1.displayIndex) + + // Validate Item 2 (Pending item) + val item2 = items[1] + assertEquals("gviz-31-12345", item2.id) + assertEquals("Produk Baru", item2.productName) + assertEquals("Produk Baru", item2.displayTitle) + assertEquals("Belum", item2.statusDownload) + assertFalse(item2.isDownloaded) + assertEquals(31, item2.rowIndex) + + // Validate Item 3 (Full product with long name & caption) + val item3 = items[2] + assertEquals("Glad2Glow Blueberry 5% Ceramide Barrier Repair Moisturizer (30g)", item3.productName) + assertEquals("Glad2Glow Blueberry 5% Ceramide Barrier Repair Moisturizer (30g)", item3.displayTitle) + assertEquals("20260920_221257_glad2glow_blueberry_5_ceramide_barrier_repair_moisturizer_30g.mp4", item3.originalFileName) + assertEquals("1BrY05mLRCR92g5vo2TCb6MOB69_L8fmF", item3.driveFileId) + assertEquals(28, item3.rowIndex) + } } diff --git a/app/src/test/java/com/frogobox/appkeyboard/ui/test/DataApiViewModelTest.kt b/app/src/test/java/com/frogobox/appkeyboard/ui/test/DataApiViewModelTest.kt index e6b09ec..56367a4 100644 --- a/app/src/test/java/com/frogobox/appkeyboard/ui/test/DataApiViewModelTest.kt +++ b/app/src/test/java/com/frogobox/appkeyboard/ui/test/DataApiViewModelTest.kt @@ -66,7 +66,7 @@ class DataApiViewModelTest { private class FakeDataApiRepository : DataApiRepository { var flowToReturn: Flow> = emptyFlow() - var directResultToReturn: Resource = Resource.Success(DataApiResponse(code = 200)) + var directResultToReturn: Resource = Resource.Success(DataApiResponse(success = true)) override fun fetchDataStream(): Flow> = flowToReturn override suspend fun fetchData(): Resource = directResultToReturn @@ -99,14 +99,14 @@ class DataApiViewModelTest { val fakeDataApiRepo = FakeDataApiRepository() val sampleItems = listOf( - DataItemResponse(id = 1, title = "Wireless Mouse", body = "2.4GHz wireless mouse") + DataItemResponse(id = 1, productName = "Wireless Mouse", caption = "2.4GHz wireless mouse") ) val successResponse = DataApiResponse( - code = 200, - status = "success", + success = true, total = 1, lastUpdated = "2026-09-19 15:00:00 WIB", - data = sampleItems + lastUpdatedWib = "2026-09-19 15:00:00 WIB", + items = sampleItems ) fakeDataApiRepo.flowToReturn = flow { @@ -132,7 +132,7 @@ class DataApiViewModelTest { assertEquals(1, successState.total) assertEquals("2026-09-19 15:00:00 WIB", successState.lastUpdatedWib) assertEquals(1, successState.items.size) - assertEquals("Wireless Mouse", successState.items[0].title) + assertEquals("Wireless Mouse", successState.items[0].productName) collectJob.cancel() } @@ -141,7 +141,7 @@ class DataApiViewModelTest { fun testFetchRemoteData_error_transitionsThroughLoadingToError() = runTest(testDispatcher) { val fakeAutoTextRepo = FakeAutoTextRepository() val fakeDataApiRepo = FakeDataApiRepository() - val errorMsg = "Connection refused. Please ensure the local server is running at http://192.168.100.6:3000." + val errorMsg = "Connection refused. Please ensure the local server is running at http://192.168.100.6:7272." fakeDataApiRepo.flowToReturn = flow { emit(Resource.Loading) diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/sound/MechanicalSoundManager.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/sound/MechanicalSoundManager.kt index a90a937..8c3d993 100644 --- a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/sound/MechanicalSoundManager.kt +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/common/sound/MechanicalSoundManager.kt @@ -125,5 +125,8 @@ class MechanicalSoundManager private constructor(context: Context) { soundPool = null soundIdMap.clear() loadedSounds.clear() + synchronized(MechanicalSoundManager::class.java) { + INSTANCE = null + } } } diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiCategoryType.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiCategoryType.kt index 50f8198..5cbab48 100644 --- a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiCategoryType.kt +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiCategoryType.kt @@ -12,6 +12,7 @@ package com.frogobox.libkeyboard.ui.emoji enum class EmojiCategoryType(val icon: String, val path: String) { + RECENT("๐Ÿ•’", "recent"), GENERAL("๐Ÿ™‚", "media/_emoji_general.txt"), ACTIVITIES("\uD83C\uDF83", "media/emoji_activities.txt"), ANIMAL_NATURE("\uD83D\uDC35", "media/emoji_animal_nature.txt"), diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboardScreen.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboardScreen.kt index 2448c8f..d1111de 100644 --- a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboardScreen.kt +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiKeyboardScreen.kt @@ -8,11 +8,13 @@ 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.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyGridState @@ -21,8 +23,11 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -31,12 +36,18 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -48,6 +59,7 @@ import com.frogobox.libkeyboard.ui.theme.FrogoLibKeyboardTheme val EmojiCategoryType.displayName: String get() = when (this) { + EmojiCategoryType.RECENT -> "Recent" EmojiCategoryType.GENERAL -> "General" EmojiCategoryType.ACTIVITIES -> "Activities" EmojiCategoryType.ANIMAL_NATURE -> "Animals & Nature" @@ -61,7 +73,7 @@ val EmojiCategoryType.displayName: String } /** - * Modern Jetpack Compose Screen for browsing and selecting emojis. + * Modern Jetpack Compose Screen for browsing and selecting emojis with real-time bilingual search. */ @Composable fun EmojiKeyboardScreen( @@ -73,8 +85,21 @@ fun EmojiKeyboardScreen( onBackClicked: () -> Unit, modifier: Modifier = Modifier, lazyGridState: LazyGridState = rememberLazyGridState(), - isLoading: Boolean = false + isLoading: Boolean = false, + onDeleteClicked: () -> Unit = {} ) { + var searchQuery by remember { mutableStateOf("") } + var isSearchActive by remember { mutableStateOf(false) } + + val displayedEmojis = remember(searchQuery, isSearchActive, emojis) { + if (isSearchActive && searchQuery.isNotBlank()) { + val results = EmojiSearchEngine.search(searchQuery) + if (results.isNotEmpty()) results else emptyList() + } else { + emojis + } + } + Surface( modifier = modifier .fillMaxWidth() @@ -82,10 +107,25 @@ fun EmojiKeyboardScreen( color = MaterialTheme.colorScheme.surface ) { Column(modifier = Modifier.fillMaxSize()) { - // Header Toolbar + // Header Toolbar with Toggleable Search Bar EmojiHeaderToolbar( selectedCategory = selectedCategory, - onBackClicked = onBackClicked + isSearchActive = isSearchActive, + searchQuery = searchQuery, + onSearchQueryChange = { searchQuery = it }, + onToggleSearch = { + isSearchActive = !isSearchActive + if (!isSearchActive) searchQuery = "" + }, + onBackClicked = { + if (isSearchActive) { + isSearchActive = false + searchQuery = "" + } else { + onBackClicked() + } + }, + onDeleteClicked = onDeleteClicked ) HorizontalDivider( @@ -94,7 +134,7 @@ fun EmojiKeyboardScreen( color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) ) - // Emoji Grid or Loading State + // Emoji Grid or Loading / Empty Search State Box( modifier = Modifier .weight(1f) @@ -107,9 +147,13 @@ fun EmojiKeyboardScreen( color = MaterialTheme.colorScheme.primary, strokeWidth = 3.dp ) - } else if (emojis.isEmpty()) { + } else if (displayedEmojis.isEmpty()) { Text( - text = "No emojis found", + text = if (isSearchActive && searchQuery.isNotBlank()) { + "Emoji tidak ditemukan untuk \"$searchQuery\"" + } else { + "No emojis found" + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -122,7 +166,7 @@ fun EmojiKeyboardScreen( verticalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp) ) { - items(emojis, key = { it }) { emoji -> + items(displayedEmojis, key = { it }) { emoji -> EmojiGridItem( emoji = emoji, onClick = { onEmojiClicked(emoji) } @@ -138,12 +182,45 @@ fun EmojiKeyboardScreen( color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) ) - // Category Selector Bar - EmojiCategoryBar( - selectedCategory = selectedCategory, - categories = categories, - onCategorySelected = onCategorySelected - ) + // Category Selector Bar (or Search Result Count Pill) + if (isSearchActive && searchQuery.isNotBlank()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .height(44.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "${displayedEmojis.size} emoji ditemukan", + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Bilingual ID/EN", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + EmojiCategoryBar( + selectedCategory = selectedCategory, + categories = categories, + onCategorySelected = { cat -> + isSearchActive = false + searchQuery = "" + onCategorySelected(cat) + } + ) + } } } } @@ -151,8 +228,13 @@ fun EmojiKeyboardScreen( @Composable fun EmojiHeaderToolbar( selectedCategory: EmojiCategoryType, + isSearchActive: Boolean = false, + searchQuery: String = "", + onSearchQueryChange: (String) -> Unit = {}, + onToggleSearch: () -> Unit = {}, onBackClicked: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onDeleteClicked: () -> Unit = {} ) { Row( modifier = modifier @@ -172,26 +254,114 @@ fun EmojiHeaderToolbar( ) } - Column( - modifier = Modifier - .weight(1f) - .padding(start = 4.dp) + if (isSearchActive) { + // Interactive Search TextField + Surface( + modifier = Modifier + .weight(1f) + .height(36.dp) + .padding(horizontal = 4.dp), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Box(modifier = Modifier.weight(1f)) { + if (searchQuery.isEmpty()) { + Text( + text = "Cari emoji (ID/EN)...", + style = TextStyle( + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f) + ) + ) + } + BasicTextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + singleLine = true, + textStyle = TextStyle( + fontSize = 13.5.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth() + ) + } + if (searchQuery.isNotEmpty()) { + IconButton( + onClick = { onSearchQueryChange("") }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Hapus pencarian", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp) + ) + } + } + } + } + } else { + Column( + modifier = Modifier + .weight(1f) + .padding(start = 4.dp) + ) { + Text( + text = stringResource(id = R.string.emojis), + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = selectedCategory.displayName, + fontSize = 11.sp, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + IconButton( + onClick = onToggleSearch, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Cari emoji", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp) + ) + } + } + + IconButton( + onClick = onDeleteClicked, + modifier = Modifier.size(40.dp) ) { - Text( - text = stringResource(id = R.string.emojis), - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = selectedCategory.displayName, - fontSize = 11.sp, - fontWeight = FontWeight.Normal, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis + Icon( + painter = painterResource(id = R.drawable.ic_keyboard_backspace), + contentDescription = stringResource(id = R.string.keycode_delete), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp) ) } } diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiSearchEngine.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiSearchEngine.kt new file mode 100644 index 0000000..fc6b6dd --- /dev/null +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/emoji/EmojiSearchEngine.kt @@ -0,0 +1,276 @@ +package com.frogobox.libkeyboard.ui.emoji + +/** + * High-speed bilingual (Indonesian & English) keyword search engine for emojis. + */ +object EmojiSearchEngine { + + private data class EmojiEntry( + val emoji: String, + val keywords: List + ) + + private val emojiKeywordDatabase = listOf( + // Smileys & Emotion + EmojiEntry("๐Ÿ˜€", listOf("smile", "grinning", "senyum", "happy", "senang", "gembira")), + EmojiEntry("๐Ÿ˜ƒ", listOf("smile", "happy", "senang", "gembira", "ceria")), + EmojiEntry("๐Ÿ˜„", listOf("smile", "laugh", "senyum", "tertawa", "senang")), + EmojiEntry("๐Ÿ˜", listOf("grin", "gigi", "senyum", "smile", "happy")), + EmojiEntry("๐Ÿ˜†", listOf("laugh", "tertawa", "ngakak", "gembira")), + EmojiEntry("๐Ÿ˜…", listOf("sweat", "smile", "keringat", "lega", "relief")), + EmojiEntry("๐Ÿคฃ", listOf("rofl", "rolling", "laugh", "ngakak", "guling", "tertawa", "lucu", "funny")), + EmojiEntry("๐Ÿ˜‚", listOf("joy", "tears", "laugh", "tertawa", "nangis", "ngakak", "lucu", "funny")), + EmojiEntry("๐Ÿ™‚", listOf("slight", "smile", "senyum", "halus", "ramah")), + EmojiEntry("๐Ÿ™ƒ", listOf("upside", "down", "terbalik", "ironi", "sarcasm")), + EmojiEntry("๐Ÿ˜‰", listOf("wink", "kedip", "genit")), + EmojiEntry("๐Ÿ˜Š", listOf("blush", "smile", "senyum", "malu", "ramah", "happy")), + EmojiEntry("๐Ÿ˜‡", listOf("angel", "halo", "malaikat", "baik", "innocent")), + EmojiEntry("๐Ÿฅฐ", listOf("love", "adore", "cinta", "sayang", "suka", "hearts")), + EmojiEntry("๐Ÿ˜", listOf("heart", "eyes", "love", "cinta", "suka", "naksir", "kagum")), + EmojiEntry("๐Ÿคฉ", listOf("star", "struck", "bintang", "kagum", "terpukau")), + EmojiEntry("๐Ÿ˜˜", listOf("kiss", "love", "cium", "cinta", "sayang")), + EmojiEntry("๐Ÿ˜—", listOf("kiss", "cium", "siul")), + EmojiEntry("๐Ÿ˜š", listOf("kiss", "cium", "sayang")), + EmojiEntry("๐Ÿ˜™", listOf("kiss", "cium")), + EmojiEntry("๐Ÿฅฒ", listOf("tear", "smile", "haru", "senang", "terharu")), + EmojiEntry("๐Ÿ˜‹", listOf("yum", "delicious", "enak", "lezat", "lapar")), + EmojiEntry("๐Ÿ˜›", listOf("tongue", "lidah", "canda")), + EmojiEntry("๐Ÿ˜œ", listOf("wink", "tongue", "kedip", "lidah", "iseng")), + EmojiEntry("๐Ÿคช", listOf("zany", "crazy", "gila", "lucu", "konyol")), + EmojiEntry("๐Ÿ˜", listOf("tongue", "lidah", "squint")), + EmojiEntry("๐Ÿค‘", listOf("money", "uang", "kaya", "cuan", "rich", "dollar", "rupiah")), + EmojiEntry("๐Ÿค—", listOf("hug", "peluk", "hangat", "ramah")), + EmojiEntry("๐Ÿคญ", listOf("giggle", "oops", "tutup mulut", "malu")), + EmojiEntry("๐Ÿคซ", listOf("shh", "quiet", "diam", "rahasia", "secret")), + EmojiEntry("๐Ÿค”", listOf("think", "mikir", "bingung", "ragu", "ponder")), + EmojiEntry("๐Ÿซก", listOf("salute", "hormat", "siap", "laksanakan")), + EmojiEntry("๐Ÿค", listOf("zipper", "bungkam", "diam")), + EmojiEntry("๐Ÿคจ", listOf("eyebrow", "curiga", "ragu", "skeptical")), + EmojiEntry("๐Ÿ˜", listOf("neutral", "datar", "biasa")), + EmojiEntry("๐Ÿ˜‘", listOf("expressionless", "datar", "lelah")), + EmojiEntry("๐Ÿ˜ถ", listOf("speechless", "terdiam", "bisu")), + EmojiEntry("๐Ÿ˜", listOf("smirk", "senyum licik", "sombong")), + EmojiEntry("๐Ÿ˜’", listOf("unamused", "bosan", "kesal", "malas")), + EmojiEntry("๐Ÿ™„", listOf("roll eyes", "putar mata", "malas", "heran")), + EmojiEntry("๐Ÿ˜ฌ", listOf("grimace", "meringis", "canggung")), + EmojiEntry("๐Ÿคฅ", listOf("liar", "bohong", "pinocchio")), + EmojiEntry("๐Ÿ˜Œ", listOf("relieved", "lega", "tenang", "syukur")), + EmojiEntry("๐Ÿ˜”", listOf("pensive", "sedih", "murung", "melamun")), + EmojiEntry("๐Ÿ˜ช", listOf("sleepy", "ngantuk", "tidur")), + EmojiEntry("๐Ÿคค", listOf("drool", "ngiler", "enak", "mau")), + EmojiEntry("๐Ÿ˜ด", listOf("sleeping", "tidur", "ngantuk", "lelah")), + EmojiEntry("๐Ÿ˜ท", listOf("mask", "masker", "sakit", "covid")), + EmojiEntry("๐Ÿค’", listOf("fever", "demam", "sakit", "termometer")), + EmojiEntry("๐Ÿค•", listOf("hurt", "luka", "perban", "sakit")), + EmojiEntry("๐Ÿคข", listOf("nauseated", "mual", "jijik")), + EmojiEntry("๐Ÿคฎ", listOf("vomit", "muntah", "jijik")), + EmojiEntry("๐Ÿคง", listOf("sneeze", "bersin", "flu", "pilek")), + EmojiEntry("๐Ÿฅต", listOf("hot", "panas", "kegerahan")), + EmojiEntry("๐Ÿฅถ", listOf("cold", "dingin", "beku")), + EmojiEntry("๐Ÿฅด", listOf("woozy", "mabuk", "pusing")), + EmojiEntry("๐Ÿ˜ต", listOf("dizzy", "pusing", "pingsan")), + EmojiEntry("๐Ÿคฏ", listOf("mind blown", "kaget", "meledak", "shock")), + EmojiEntry("๐Ÿค ", listOf("cowboy", "topi", "koboi")), + EmojiEntry("๐Ÿฅณ", listOf("party", "pesta", "selamat", "congrats", "ultah", "birthday")), + EmojiEntry("๐Ÿ˜Ž", listOf("cool", "sunglasses", "keren", "kacamata", "kece", "mantap")), + EmojiEntry("๐Ÿค“", listOf("nerd", "kacamata", "pintar", "belajar")), + EmojiEntry("๐Ÿง", listOf("monocle", "kepo", "selidiki", "periksa")), + EmojiEntry("๐Ÿ˜•", listOf("confused", "bingung", "ragu")), + EmojiEntry("๐Ÿ˜Ÿ", listOf("worried", "khawatir", "cemas")), + EmojiEntry("๐Ÿ™", listOf("frown", "cemberut", "sedih")), + EmojiEntry("๐Ÿ˜ฎ", listOf("surprise", "kaget", "terkejut")), + EmojiEntry("๐Ÿ˜ฏ", listOf("hushed", "kaget")), + EmojiEntry("๐Ÿ˜ฒ", listOf("astonished", "tercengang", "kagum")), + EmojiEntry("๐Ÿ˜ณ", listOf("flushed", "kaget", "malu")), + EmojiEntry("๐Ÿฅบ", listOf("pleading", "mohon", "tolong", "sedih", "puppy eyes")), + EmojiEntry("๐Ÿ˜ฆ", listOf("frown", "kaget")), + EmojiEntry("๐Ÿ˜จ", listOf("fear", "takut", "ngeri")), + EmojiEntry("๐Ÿ˜ฐ", listOf("anxious", "cemas", "keringat")), + EmojiEntry("๐Ÿ˜ฅ", listOf("sad", "sedih", "kecewa")), + EmojiEntry("๐Ÿ˜ข", listOf("crying", "nangis", "sedih", "air mata")), + EmojiEntry("๐Ÿ˜ญ", listOf("loud cry", "nangis keras", "sedih banget", "kejer")), + EmojiEntry("๐Ÿ˜ฑ", listOf("screaming", "teriak", "kaget", "takut", "horror")), + EmojiEntry("๐Ÿ˜–", listOf("confounded", "kesal", "frustasi")), + EmojiEntry("๐Ÿ˜ฃ", listOf("persevering", "tahan", "sakit")), + EmojiEntry("๐Ÿ˜ž", listOf("disappointed", "kecewa", "sedih", "patah hati")), + EmojiEntry("๐Ÿ˜“", listOf("downcast", "keringat", "kecewa")), + EmojiEntry("๐Ÿ˜ฉ", listOf("weary", "lelah", "capek", "cape")), + EmojiEntry("๐Ÿ˜ซ", listOf("tired", "capek", "lelah", "cape")), + EmojiEntry("๐Ÿฅฑ", listOf("yawn", "menguap", "ngantuk", "bosan")), + EmojiEntry("๐Ÿ˜ค", listOf("triumph", "kesal", "marah", "bangga")), + EmojiEntry("๐Ÿ˜ก", listOf("angry", "marah", "kesal", "ngamuk")), + EmojiEntry("๐Ÿ˜ ", listOf("angry", "marah", "cemberut")), + EmojiEntry("๐Ÿคฌ", listOf("cursing", "marah", "sumpah serapah", "ngamuk")), + EmojiEntry("๐Ÿ˜ˆ", listOf("devil", "setan", "iblis", "jahil")), + EmojiEntry("๐Ÿ‘ฟ", listOf("demon", "iblis", "jahat")), + EmojiEntry("๐Ÿ’€", listOf("skull", "tengkorak", "mati", "dead")), + EmojiEntry("๐Ÿ’ฉ", listOf("poop", "tinja", "lucu")), + EmojiEntry("๐Ÿคก", listOf("clown", "badut", "lucu")), + EmojiEntry("๐Ÿ‘ป", listOf("ghost", "hantu", "seram")), + EmojiEntry("๐Ÿ‘ฝ", listOf("alien", "luar angkasa")), + EmojiEntry("๐Ÿค–", listOf("robot", "bot", "mesin")), + + // Hands & Gestures + EmojiEntry("๐Ÿ‘‹", listOf("wave", "hai", "halo", "dadah", "bye", "salam")), + EmojiEntry("๐Ÿคš", listOf("raised back of hand", "tangan", "stop")), + EmojiEntry("๐Ÿ–๏ธ", listOf("hand with fingers", "tangan", "lima")), + EmojiEntry("โœ‹", listOf("raised hand", "tangan", "stop", "tunggu")), + EmojiEntry("๐Ÿ––", listOf("vulcan", "spock", "damai")), + EmojiEntry("๐Ÿ‘Œ", listOf("ok", "oke", "siap", "mantap", "setuju")), + EmojiEntry("๐ŸคŒ", listOf("pinched fingers", "italia", "maksud")), + EmojiEntry("๐Ÿค", listOf("pinching", "sedikit", "dikit")), + EmojiEntry("โœŒ๏ธ", listOf("peace", "damai", "dua", "victory")), + EmojiEntry("๐Ÿคž", listOf("crossed fingers", "harapan", "semoga", "luck")), + EmojiEntry("๐ŸคŸ", listOf("love you", "cinta", "metal")), + EmojiEntry("๐Ÿค˜", listOf("rock", "metal", "keren")), + EmojiEntry("๐Ÿค™", listOf("call", "telepon", "santai", "hang loose")), + EmojiEntry("๐Ÿ‘ˆ", listOf("left", "kiri", "tunjuk")), + EmojiEntry("๐Ÿ‘‰", listOf("right", "kanan", "tunjuk")), + EmojiEntry("๐Ÿ‘†", listOf("up", "atas", "tunjuk")), + EmojiEntry("๐Ÿ‘‡", listOf("down", "bawah", "tunjuk")), + EmojiEntry("โ˜๏ธ", listOf("point up", "satu", "atas", "ingat")), + EmojiEntry("๐Ÿ‘", listOf("thumbs up", "jempol", "bagus", "mantap", "oke", "setuju", "like")), + EmojiEntry("๐Ÿ‘Ž", listOf("thumbs down", "jelek", "buruk", "tidak suka", "dislike")), + EmojiEntry("โœŠ", listOf("fist", "tinju", "semangat", "perjuangan")), + EmojiEntry("๐Ÿ‘Š", listOf("punch", "tinju", "tos", "bro")), + EmojiEntry("๐Ÿค›", listOf("left fist", "tos")), + EmojiEntry("๐Ÿคœ", listOf("right fist", "tos")), + EmojiEntry("๐Ÿ‘", listOf("clap", "tepuk tangan", "selamat", "applause", "hebat")), + EmojiEntry("๐Ÿ™Œ", listOf("raising hands", "hore", "syukur", "sukses")), + EmojiEntry("๐Ÿ‘", listOf("open hands", "terbuka", "tangan")), + EmojiEntry("๐Ÿคฒ", listOf("palms up", "doa", "memohon", "tadah")), + EmojiEntry("๐Ÿค", listOf("handshake", "jabat tangan", "deal", "sepakat", "setuju", "kerjasama")), + EmojiEntry("๐Ÿ™", listOf("pray", "doa", "tolong", "terima kasih", "thanks", "makasih", "mohon", "maaf", "sungkem")), + EmojiEntry("โœ๏ธ", listOf("writing", "tulis", "catat")), + EmojiEntry("๐Ÿ’…", listOf("nail polish", "kuku", "santai")), + EmojiEntry("๐Ÿคณ", listOf("selfie", "foto", "hp")), + EmojiEntry("๐Ÿ’ช", listOf("muscle", "strong", "otot", "kuat", "semangat")), + + // Hearts & Symbols + EmojiEntry("โค๏ธ", listOf("heart", "red heart", "cinta", "hati", "sayang", "merah")), + EmojiEntry("๐Ÿงก", listOf("orange heart", "hati oranye")), + EmojiEntry("๐Ÿ’›", listOf("yellow heart", "hati kuning")), + EmojiEntry("๐Ÿ’š", listOf("green heart", "hati hijau")), + EmojiEntry("๐Ÿ’™", listOf("blue heart", "hati biru")), + EmojiEntry("๐Ÿ’œ", listOf("purple heart", "hati ungu")), + EmojiEntry("๐Ÿ–ค", listOf("black heart", "hati hitam")), + EmojiEntry("๐Ÿค", listOf("white heart", "hati putih")), + EmojiEntry("๐ŸคŽ", listOf("brown heart", "hati cokelat")), + EmojiEntry("๐Ÿ’”", listOf("broken heart", "patah hati", "kecewa", "sedih")), + EmojiEntry("โฃ๏ธ", listOf("heart exclamation", "tanda seru hati")), + EmojiEntry("๐Ÿ’•", listOf("two hearts", "dua hati", "cinta", "sayang")), + EmojiEntry("๐Ÿ’ž", listOf("revolving hearts", "hati berputar")), + EmojiEntry("๐Ÿ’“", listOf("beating heart", "detak jantung", "deg-degan")), + EmojiEntry("๐Ÿ’—", listOf("growing heart", "hati berkembang")), + EmojiEntry("๐Ÿ’–", listOf("sparkling heart", "hati berkilau", "cinta")), + EmojiEntry("๐Ÿ’˜", listOf("heart with arrow", "panah asmara", "naksir")), + EmojiEntry("๐Ÿ’", listOf("heart with ribbon", "kado", "hadiah hati")), + EmojiEntry("๐Ÿ”ฅ", listOf("fire", "api", "hot", "panas", "semangat", "viral", "trending")), + EmojiEntry("โญ", listOf("star", "bintang", "review", "rating", "favorit", "bagus")), + EmojiEntry("๐ŸŒŸ", listOf("glowing star", "bintang bersinar", "keren")), + EmojiEntry("โœจ", listOf("sparkles", "kilau", "bersih", "kinclong", "magic")), + EmojiEntry("โšก", listOf("lightning", "kilat", "petir", "cepat", "fast")), + EmojiEntry("๐Ÿ’ฅ", listOf("collision", "ledakan", "boom")), + EmojiEntry("๐Ÿ’ฏ", listOf("hundred", "seratus", "sempurna", "perfect", "jos", "mantap")), + EmojiEntry("โœ…", listOf("check", "centang", "benar", "sukses", "selesai", "lunas", "yes")), + EmojiEntry("โœ”๏ธ", listOf("check mark", "centang")), + EmojiEntry("โŒ", listOf("cross", "silang", "salah", "batal", "cancel", "no")), + EmojiEntry("๐Ÿšซ", listOf("prohibited", "larangan", "dilarang", "stop")), + EmojiEntry("โš ๏ธ", listOf("warning", "peringatan", "awas", "hati-hati")), + EmojiEntry("โ›”", listOf("no entry", "stop", "dilarang")), + EmojiEntry("โ“", listOf("question", "tanya", "pertanyaan")), + EmojiEntry("โ—", listOf("exclamation", "seru", "perhatian", "penting")), + + // Seller & Commerce (E-Commerce Highlights) + EmojiEntry("๐Ÿ’ฐ", listOf("money bag", "uang", "kantong uang", "dana", "cuan", "gaji", "bayar")), + EmojiEntry("๐Ÿ’ต", listOf("dollar", "uang", "tunai", "cash", "rupiah")), + EmojiEntry("๐Ÿ’ธ", listOf("money with wings", "uang terbang", "boros", "pengeluaran")), + EmojiEntry("๐Ÿ’ณ", listOf("credit card", "kartu kredit", "atm", "debit", "rekening", "transfer")), + EmojiEntry("๐Ÿช™", listOf("coin", "koin", "uang")), + EmojiEntry("๐Ÿ“ฆ", listOf("package", "paket", "kardus", "barang", "kirim", "shipping", "resi", "ongkir", "box")), + EmojiEntry("๐Ÿšš", listOf("truck", "truk", "pengiriman", "ekspedisi", "kurir", "kirim")), + EmojiEntry("๐Ÿš›", listOf("articulated lorry", "truk besar", "kontainer")), + EmojiEntry("๐Ÿ›ต", listOf("motor scooter", "motor", "kurir", "ojol", "antar")), + EmojiEntry("๐Ÿšฒ", listOf("bicycle", "sepeda")), + EmojiEntry("๐Ÿ›’", listOf("shopping cart", "keranjang", "troli", "belanja", "beli")), + EmojiEntry("๐Ÿ›๏ธ", listOf("shopping bags", "tas belanja", "toko", "olshop", "belanja")), + EmojiEntry("๐Ÿท๏ธ", listOf("label", "tag", "harga", "diskon", "promo")), + EmojiEntry("๐ŸŽ", listOf("gift", "kado", "hadiah", "bonus", "free")), + EmojiEntry("๐ŸŽ‰", listOf("party popper", "selamat", "congrats", "pesta", "hore")), + EmojiEntry("๐ŸŽŠ", listOf("confetti ball", "selamat", "meriah")), + EmojiEntry("๐Ÿ†", listOf("trophy", "piala", "juara", "terbaik", "bestseller")), + EmojiEntry("๐Ÿฅ‡", listOf("1st place medal", "juara satu", "emas", "top")), + EmojiEntry("๐Ÿ“ฑ", listOf("mobile phone", "hp", "handphone", "smartphone", "wa", "telepon")), + EmojiEntry("๐Ÿ’ป", listOf("laptop", "komputer", "pc", "admin")), + EmojiEntry("๐Ÿ“ž", listOf("telephone receiver", "telepon", "hubungi")), + EmojiEntry("๐Ÿ’ฌ", listOf("speech balloon", "chat", "pesan", "inbox", "balas")), + EmojiEntry("๐Ÿงพ", listOf("receipt", "struk", "invoice", "nota", "bukti bayar")), + EmojiEntry("๐Ÿ”’", listOf("locked", "aman", "secure", "gembok", "terkunci")), + EmojiEntry("๐Ÿ”“", listOf("unlocked", "terbuka")), + + // Food & Drink + EmojiEntry("โ˜•", listOf("coffee", "kopi", "panas", "cafe", "santai")), + EmojiEntry("๐Ÿต", listOf("tea", "teh", "minum")), + EmojiEntry("๐Ÿง‹", listOf("boba", "boba tea", "minuman")), + EmojiEntry("๐Ÿฅค", listOf("cup with straw", "minuman", "es")), + EmojiEntry("๐Ÿš", listOf("rice", "nasi", "makan")), + EmojiEntry("๐Ÿœ", listOf("noodles", "mie", "bakso", "ramen")), + EmojiEntry("๐Ÿ•", listOf("pizza", "makanan", "enak")), + EmojiEntry("๐Ÿ”", listOf("burger", "fast food")), + EmojiEntry("๐Ÿ—", listOf("chicken", "ayam", "makan")), + EmojiEntry("๐Ÿž", listOf("bread", "roti")), + EmojiEntry("๐ŸŽ‚", listOf("birthday cake", "kue", "ultah")), + EmojiEntry("๐Ÿฆ", listOf("ice cream", "es krim", "manis")), + + // Animals & Nature + EmojiEntry("๐Ÿฑ", listOf("cat", "kucing", "lucu", "meow")), + EmojiEntry("๐Ÿถ", listOf("dog", "anjing", "lucu", "guguk")), + EmojiEntry("๐Ÿธ", listOf("frog", "katak", "kodok", "frogo")), + EmojiEntry("๐Ÿต", listOf("monkey", "monyet")), + EmojiEntry("๐Ÿฆ", listOf("lion", "singa")), + EmojiEntry("๐Ÿฏ", listOf("tiger", "harimau")), + EmojiEntry("๐ŸŒธ", listOf("cherry blossom", "bunga", "cantik")), + EmojiEntry("๐ŸŒน", listOf("rose", "mawar", "bunga", "cinta")), + EmojiEntry("๐ŸŒป", listOf("sunflower", "bunga matahari")) + ) + + /** + * Searches the emoji database for the given [query] across English and Indonesian keywords. + * Returns matching emoji characters ordered by relevance. + */ + fun search(query: String): List { + val trimmed = query.trim().lowercase() + if (trimmed.isEmpty()) return emptyList() + + val results = mutableListOf() + val exactMatches = mutableListOf() + val prefixMatches = mutableListOf() + val substringMatches = mutableListOf() + + for (entry in emojiKeywordDatabase) { + var matched = false + for (kw in entry.keywords) { + if (kw == trimmed) { + exactMatches.add(entry.emoji) + matched = true + break + } else if (kw.startsWith(trimmed)) { + prefixMatches.add(entry.emoji) + matched = true + break + } else if (kw.contains(trimmed)) { + substringMatches.add(entry.emoji) + matched = true + break + } + } + } + + results.addAll(exactMatches.distinct()) + results.addAll(prefixMatches.distinct().filter { !results.contains(it) }) + results.addAll(substringMatches.distinct().filter { !results.contains(it) }) + + return results + } + +} diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt index d756b3c..d28fb9a 100644 --- a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboard.kt @@ -72,6 +72,10 @@ class MainKeyboard @JvmOverloads constructor( private var mKeyTextSize = 0 private var mTextColor = 0 + private var mActionTextColor = 0 + private var mCustomTextColor: Int? = null + private var mCustomActionTextColor: Int? = null + private var mIsDarkTheme = false private var mBackgroundColor = 0 private var mPreviewText: TextView? = null @@ -277,7 +281,7 @@ class MainKeyboard @JvmOverloads constructor( gravity = Gravity.CENTER includeFontPadding = false setPadding(padding, padding, padding, padding) - setTextColor(context.getColorExt(R.color.keypad_text)) + setTextColor(if (mTextColor != 0) mTextColor else context.getColorExt(R.color.keypad_text)) typeface = Typeface.DEFAULT_BOLD setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.preview_text_size)) } @@ -291,8 +295,35 @@ class MainKeyboard @JvmOverloads constructor( } } + fun isDarkTheme(): Boolean = mIsDarkTheme + + fun setKeyboardTheme( + textColor: Int, + actionTextColor: Int = textColor, + keyColor: Int? = null, + actionKeyColor: Int? = null, + isDark: Boolean = false + ) { + mCustomTextColor = textColor + mCustomActionTextColor = actionTextColor + mTextColor = textColor + mActionTextColor = actionTextColor + mIsDarkTheme = isDark + mPaint.color = textColor + mSmallLetterPaint.apply { + set(mPaint) + color = textColor.adjustAlpha(0.8f) + textSize = mTopSmallNumberSize + typeface = Typeface.DEFAULT + } + initCachedDrawables() + invalidateAllKeys() + invalidate() + } + fun initCachedDrawables() { - mKeypadDefaultDrawable = resources.getDrawable(R.drawable.keypad_default, context.theme) + val defaultDrawableRes = if (mIsDarkTheme) R.drawable.keypad_default_dark else R.drawable.keypad_default + mKeypadDefaultDrawable = resources.getDrawable(defaultDrawableRes, context.theme) mKeypadActionDrawable = resources.getDrawable(R.drawable.keypad_action, context.theme) mShiftOffDrawable = ResourcesCompat.getDrawable(resources, R.drawable.ic_keyboard_caps_outline, null) mShiftOneCharDrawable = ResourcesCompat.getDrawable(resources, R.drawable.ic_keyboard_caps, null) @@ -323,7 +354,8 @@ class MainKeyboard @JvmOverloads constructor( super.onVisibilityChanged(changedView, visibility) if (visibility == VISIBLE) { - mTextColor = context.getColorExt(R.color.keypad_text) + mTextColor = mCustomTextColor ?: if (mIsDarkTheme) context.getColorExt(R.color.keypad_text_dark) else context.getColorExt(R.color.keypad_text) + mActionTextColor = mCustomActionTextColor ?: mTextColor mBackgroundColor = context.getColorExt(R.color.keyboard_board) initCachedDrawables() @@ -375,7 +407,12 @@ class MainKeyboard @JvmOverloads constructor( fun vibrateIfNeeded() { if (ItemMainKeyboard.VIBRATE_ON_KEYPRESS) { - performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + val feedbackConstant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + HapticFeedbackConstants.KEYBOARD_TAP + } else { + HapticFeedbackConstants.VIRTUAL_KEY + } + performHapticFeedback(feedbackConstant) } } @@ -596,7 +633,7 @@ class MainKeyboard @JvmOverloads constructor( code == KEYCODE_TAB || code == KEYCODE_ARROW_LEFT || code == KEYCODE_ARROW_RIGHT || code == KEYCODE_ARROW_UP || code == KEYCODE_ARROW_DOWN ) { - icon.applyColorFilter(mTextColor) + icon.applyColorFilter(if (mActionTextColor != 0) mActionTextColor else mTextColor) } val drawableX = (key.width - icon.intrinsicWidth) / 2 @@ -790,7 +827,7 @@ class MainKeyboard @JvmOverloads constructor( val event = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { AccessibilityEvent(eventType) } else { - AccessibilityEvent.obtain(eventType) + AccessibilityEvent().apply { this.eventType = eventType } } onInitializeAccessibilityEvent(event) val text: String = when (code) { diff --git a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboardScreen.kt b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboardScreen.kt index e64d449..e965e65 100644 --- a/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboardScreen.kt +++ b/lib-keyboard/src/main/java/com/frogobox/libkeyboard/ui/main/MainKeyboardScreen.kt @@ -47,6 +47,11 @@ fun MainKeyboardView( keyboard: ItemMainKeyboard?, onActionListener: OnKeyboardActionListener?, modifier: Modifier = Modifier, + textColor: Int? = null, + actionTextColor: Int? = null, + keyColor: Int? = null, + actionKeyColor: Int? = null, + isDarkTheme: Boolean = false, onInit: (MainKeyboard) -> Unit = {} ) { AndroidView( @@ -56,6 +61,15 @@ fun MainKeyboardView( if (background == null) { setBackgroundColor(context.getColorExt(R.color.keyboard_board)) } + if (textColor != null) { + setKeyboardTheme( + textColor = textColor, + actionTextColor = actionTextColor ?: textColor, + keyColor = keyColor, + actionKeyColor = actionKeyColor, + isDark = isDarkTheme + ) + } this.mOnKeyboardActionListener = onActionListener keyboard?.let { setKeyboard(it) } onInit(this) @@ -63,6 +77,15 @@ fun MainKeyboardView( }, update = { view -> view.mOnKeyboardActionListener = onActionListener + if (textColor != null) { + view.setKeyboardTheme( + textColor = textColor, + actionTextColor = actionTextColor ?: textColor, + keyColor = keyColor, + actionKeyColor = actionKeyColor, + isDark = isDarkTheme + ) + } if (keyboard != null) { view.setKeyboard(keyboard) } diff --git a/lib-keyboard/src/main/res/drawable/keypad_default.xml b/lib-keyboard/src/main/res/drawable/keypad_default.xml index 05f520f..09f4899 100644 --- a/lib-keyboard/src/main/res/drawable/keypad_default.xml +++ b/lib-keyboard/src/main/res/drawable/keypad_default.xml @@ -17,6 +17,7 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib-keyboard/src/main/res/values/colors.xml b/lib-keyboard/src/main/res/values/colors.xml index be36eb1..bee3f7b 100644 --- a/lib-keyboard/src/main/res/values/colors.xml +++ b/lib-keyboard/src/main/res/values/colors.xml @@ -19,11 +19,18 @@ @android:color/transparent + #001E2C #80FFFFFF #80EFEFEF #D1E4FF + + #FFFFFF + #26FFFFFF + #33FFFFFF + #4DFFFFFF + #FFFFFF #8076D1FF #8038A4E0 diff --git a/lib-keyboard/src/test/java/com/frogobox/libkeyboard/EmojiKeyboardComposeTest.kt b/lib-keyboard/src/test/java/com/frogobox/libkeyboard/EmojiKeyboardComposeTest.kt index 10562ec..0223dfb 100644 --- a/lib-keyboard/src/test/java/com/frogobox/libkeyboard/EmojiKeyboardComposeTest.kt +++ b/lib-keyboard/src/test/java/com/frogobox/libkeyboard/EmojiKeyboardComposeTest.kt @@ -14,8 +14,9 @@ class EmojiKeyboardComposeTest { @Test fun testEmojiCategoryTypeCountAndPaths() { val categories = EmojiCategoryType.entries - assertEquals(10, categories.size) + assertEquals(11, categories.size) + assertTrue(categories.any { it == EmojiCategoryType.RECENT && it.path == "recent" }) assertTrue(categories.any { it == EmojiCategoryType.GENERAL && it.path == "media/_emoji_general.txt" }) assertTrue(categories.any { it == EmojiCategoryType.ACTIVITIES && it.path == "media/emoji_activities.txt" }) assertTrue(categories.any { it == EmojiCategoryType.ANIMAL_NATURE && it.path == "media/emoji_animal_nature.txt" }) @@ -30,6 +31,7 @@ class EmojiKeyboardComposeTest { @Test fun testEmojiCategoryTypeDisplayNames() { + assertEquals("Recent", EmojiCategoryType.RECENT.displayName) assertEquals("General", EmojiCategoryType.GENERAL.displayName) assertEquals("Activities", EmojiCategoryType.ACTIVITIES.displayName) assertEquals("Animals & Nature", EmojiCategoryType.ANIMAL_NATURE.displayName) @@ -45,12 +47,12 @@ class EmojiKeyboardComposeTest { @Test fun testGetEmojiCategoryListMapping() { val categoryList = getEmojiCategory() - assertEquals(10, categoryList.size) + assertEquals(11, categoryList.size) val first = categoryList[0] - assertEquals("GENERAL", first.name) + assertEquals("RECENT", first.name) assertNotNull(first.icon) - assertEquals("media/_emoji_general.txt", first.path) + assertEquals("recent", first.path) } @Test @@ -75,4 +77,28 @@ class EmojiKeyboardComposeTest { resetTrigger++ assertEquals(2, resetTrigger) } + + @Test + fun testEmojiSearchEngineBilingual() { + val smileIndonesian = com.frogobox.libkeyboard.ui.emoji.EmojiSearchEngine.search("senyum") + assertTrue("Should find smile emoji for 'senyum'", smileIndonesian.contains("๐Ÿ˜€")) + + val smileEnglish = com.frogobox.libkeyboard.ui.emoji.EmojiSearchEngine.search("smile") + assertTrue("Should find smile emoji for 'smile'", smileEnglish.contains("๐Ÿ˜€")) + + val moneyIndonesian = com.frogobox.libkeyboard.ui.emoji.EmojiSearchEngine.search("uang") + assertTrue("Should find money emoji for 'uang'", moneyIndonesian.contains("๐Ÿ’ฐ")) + + val packageIndonesian = com.frogobox.libkeyboard.ui.emoji.EmojiSearchEngine.search("paket") + assertTrue("Should find package emoji for 'paket'", packageIndonesian.contains("๐Ÿ“ฆ")) + + val fireSearch = com.frogobox.libkeyboard.ui.emoji.EmojiSearchEngine.search("api") + assertTrue("Should find fire emoji for 'api'", fireSearch.contains("๐Ÿ”ฅ")) + + val thumbsSearch = com.frogobox.libkeyboard.ui.emoji.EmojiSearchEngine.search("jempol") + assertTrue("Should find thumbs up emoji for 'jempol'", thumbsSearch.contains("๐Ÿ‘")) + + val emptySearch = com.frogobox.libkeyboard.ui.emoji.EmojiSearchEngine.search("") + assertTrue("Empty search should return empty list", emptySearch.isEmpty()) + } } diff --git a/lib-keyboard/src/test/java/com/frogobox/libkeyboard/MainKeyboardBackgroundTest.kt b/lib-keyboard/src/test/java/com/frogobox/libkeyboard/MainKeyboardBackgroundTest.kt index af494d2..9a3855e 100644 --- a/lib-keyboard/src/test/java/com/frogobox/libkeyboard/MainKeyboardBackgroundTest.kt +++ b/lib-keyboard/src/test/java/com/frogobox/libkeyboard/MainKeyboardBackgroundTest.kt @@ -3,7 +3,6 @@ package com.frogobox.libkeyboard import android.content.Context import android.graphics.Color import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.LayerDrawable import android.view.View import androidx.test.core.app.ApplicationProvider import com.frogobox.libkeyboard.ui.main.MainKeyboard @@ -116,4 +115,25 @@ class MainKeyboardBackgroundTest { assertNull("Background remains null when view is not visible", keyboard.background) } + + @Test + fun testSetKeyboardTheme_updatesTextColorAndDarkThemeFlag() { + val keyboard = MainKeyboard(context, null) + + // Dark theme setup + keyboard.setKeyboardTheme( + textColor = Color.WHITE, + actionTextColor = Color.WHITE, + isDark = true + ) + assertTrue("Keyboard must report isDarkTheme == true", keyboard.isDarkTheme()) + + // Light theme setup + keyboard.setKeyboardTheme( + textColor = Color.parseColor("#0F172A"), + actionTextColor = Color.parseColor("#0F172A"), + isDark = false + ) + org.junit.Assert.assertFalse("Keyboard must report isDarkTheme == false", keyboard.isDarkTheme()) + } }