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