diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..a7e5c08
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,37 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{kt,kts}]
+indent_size = 4
+indent_style = space
+max_line_length = 140
+ij_kotlin_allow_trailing_comma = true
+ij_kotlin_allow_trailing_comma_on_call_site = true
+ktlint_function_naming_ignore_when_annotated_with = Composable
+ktlint_standard_function-naming = disabled
+ktlint_standard_no-wildcard-imports = disabled
+ktlint_standard_property-naming = disabled
+
+[*.xml]
+indent_size = 2
+indent_style = space
+
+[*.gradle]
+indent_size = 4
+indent_style = space
+
+[*.gradle.kts]
+indent_size = 4
+indent_style = space
+
+[*.json]
+indent_size = 2
+indent_style = space
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/app/build.gradle b/app/build.gradle
index 228d38f..a8351dc 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -7,6 +7,7 @@ plugins {
id "com.google.devtools.ksp"
id "org.jetbrains.kotlin.plugin.compose"
id "com.github.triplet.play"
+ id "org.jlleitschuh.gradle.ktlint"
}
android {
@@ -132,7 +133,8 @@ dependencies {
playImplementation "com.google.android.play:app-update-ktx:2.1.0"
playImplementation "com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1"
- def composeBom = platform('androidx.compose:compose-bom:2025.07.00')
+ // Compose dependencies
+ def composeBom = platform('androidx.compose:compose-bom:2024.09.00')
implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.compose.material3:material3'
@@ -144,6 +146,10 @@ dependencies {
implementation 'androidx.activity:activity-compose:1.10.1'
implementation 'me.zhanghai.compose.preference:library:1.1.1'
implementation "androidx.navigation:navigation-compose:2.9.3"
+ implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2"
+ implementation "androidx.compose.runtime:runtime-livedata"
+ implementation "sh.calvin.reorderable:reorderable:2.3.3"
+
}
def playCredentialsJSON = rootProject.file("service-account-credentials.json")
@@ -199,4 +205,16 @@ def getVersionProps(String propName) {
}
}
return ""
+}
+
+ktlint {
+ android = false
+ version = "1.0.1"
+ verbose = true
+ outputToConsole = true
+ reporters {
+ reporter "plain"
+ reporter "checkstyle"
+ reporter "html"
+ }
}
\ No newline at end of file
diff --git a/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/1.json b/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/1.json
index ec8282d..b7cac4c 100644
--- a/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/1.json
+++ b/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/1.json
@@ -2,11 +2,11 @@
"formatVersion": 1,
"database": {
"version": 1,
- "identityHash": "b7bfa362ec191b0a18660e615da81e46",
+ "identityHash": "24de05fe91b147c75b870f91b2f4871b",
"entities": [
{
"tableName": "profiles",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `name` TEXT NOT NULL, `typed` BLOB NOT NULL)",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `name` TEXT NOT NULL, `icon` TEXT, `typed` BLOB NOT NULL)",
"fields": [
{
"fieldPath": "id",
@@ -26,6 +26,11 @@
"affinity": "TEXT",
"notNull": true
},
+ {
+ "fieldPath": "icon",
+ "columnName": "icon",
+ "affinity": "TEXT"
+ },
{
"fieldPath": "typed",
"columnName": "typed",
@@ -38,15 +43,12 @@
"columnNames": [
"id"
]
- },
- "indices": [],
- "foreignKeys": []
+ }
}
],
- "views": [],
"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, 'b7bfa362ec191b0a18660e615da81e46')"
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '24de05fe91b147c75b870f91b2f4871b')"
]
}
}
\ No newline at end of file
diff --git a/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/2.json b/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/2.json
new file mode 100644
index 0000000..bd414a0
--- /dev/null
+++ b/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/2.json
@@ -0,0 +1,55 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 2,
+ "identityHash": "dc5fb65e389df8c8391b3435652f4c64",
+ "entities": [
+ {
+ "tableName": "profiles",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `name` TEXT NOT NULL, `icon` TEXT DEFAULT NULL, `typed` BLOB NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "userOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "icon",
+ "columnName": "icon",
+ "affinity": "TEXT",
+ "defaultValue": "NULL"
+ },
+ {
+ "fieldPath": "typed",
+ "columnName": "typed",
+ "affinity": "BLOB",
+ "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, 'dc5fb65e389df8c8391b3435652f4c64')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 04b6a73..1f159e8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -21,6 +21,10 @@
+
+
+
@@ -41,18 +45,10 @@
android:resource="@xml/shortcuts" />
-
-
-
-
+ android:theme="@style/AppTheme.Translucent">
@@ -99,6 +95,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/io/nekohasekai/sfa/Application.kt b/app/src/main/java/io/nekohasekai/sfa/Application.kt
index 8c3144b..b3ed09e 100644
--- a/app/src/main/java/io/nekohasekai/sfa/Application.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/Application.kt
@@ -16,6 +16,7 @@ import io.nekohasekai.libbox.SetupOptions
import io.nekohasekai.sfa.bg.AppChangeReceiver
import io.nekohasekai.sfa.bg.UpdateProfileWork
import io.nekohasekai.sfa.constant.Bugs
+import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@@ -24,7 +25,6 @@ import java.util.Locale
import io.nekohasekai.sfa.Application as BoxApplication
class Application : Application() {
-
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
application = this
@@ -42,11 +42,17 @@ class Application : Application() {
UpdateProfileWork.reconfigureUpdater()
}
- registerReceiver(AppChangeReceiver(), IntentFilter().apply {
- addAction(Intent.ACTION_PACKAGE_ADDED)
- addDataScheme("package")
- })
-
+ // Only register AppChangeReceiver if Per-app Proxy is available
+ // This receiver needs QUERY_ALL_PACKAGES permission to function
+ if (Vendor.isPerAppProxyAvailable()) {
+ registerReceiver(
+ AppChangeReceiver(),
+ IntentFilter().apply {
+ addAction(Intent.ACTION_PACKAGE_ADDED)
+ addDataScheme("package")
+ },
+ )
+ }
}
private fun initialize() {
@@ -56,12 +62,16 @@ class Application : Application() {
workingDir.mkdirs()
val tempDir = cacheDir
tempDir.mkdirs()
- Libbox.setup(SetupOptions().also {
- it.basePath = baseDir.path
- it.workingPath = workingDir.path
- it.tempPath = tempDir.path
- it.fixAndroidStack = Bugs.fixAndroidStack
- })
+ Libbox.setup(
+ SetupOptions().also {
+ it.basePath = baseDir.path
+ it.workingPath = workingDir.path
+ it.tempPath = tempDir.path
+ it.fixAndroidStack = Bugs.fixAndroidStack
+ it.logMaxLines = 3000
+ it.debug = BuildConfig.DEBUG
+ },
+ )
Libbox.redirectStderr(File(workingDir, "stderr.log").path)
}
@@ -75,5 +85,4 @@ class Application : Application() {
val wifiManager by lazy { application.getSystemService()!! }
val clipboard by lazy { application.getSystemService()!! }
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/LauncherActivity.kt b/app/src/main/java/io/nekohasekai/sfa/LauncherActivity.kt
new file mode 100644
index 0000000..0d5b6e2
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/LauncherActivity.kt
@@ -0,0 +1,40 @@
+package io.nekohasekai.sfa
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import io.nekohasekai.sfa.compose.ComposeActivity
+import io.nekohasekai.sfa.database.Settings
+import io.nekohasekai.sfa.ui.MainActivity
+import kotlinx.coroutines.runBlocking
+
+class LauncherActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val useComposeUI =
+ runBlocking {
+ Settings.useComposeUI
+ }
+
+ val targetActivity =
+ if (useComposeUI) {
+ ComposeActivity::class.java
+ } else {
+ MainActivity::class.java
+ }
+
+ val launchIntent =
+ Intent(this, targetActivity).apply {
+ // Transfer any intent data from launcher
+ intent?.let {
+ action = it.action
+ data = it.data
+ it.extras?.let { extras -> putExtras(extras) }
+ }
+ }
+
+ startActivity(launchIntent)
+ finish()
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt b/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt
index 3d251b4..0abf505 100644
--- a/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt
@@ -8,12 +8,14 @@ import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity
class AppChangeReceiver : BroadcastReceiver() {
-
companion object {
private const val TAG = "AppChangeReceiver"
}
- override fun onReceive(context: Context, intent: Intent) {
+ override fun onReceive(
+ context: Context,
+ intent: Intent,
+ ) {
Log.d(TAG, "onReceive: ${intent.action}")
checkUpdate(intent)
}
@@ -47,5 +49,4 @@ class AppChangeReceiver : BroadcastReceiver() {
Log.d(TAG, "removed from list")
}
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt b/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt
index f4dc8f0..7e30618 100644
--- a/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt
@@ -11,9 +11,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class BootReceiver : BroadcastReceiver() {
-
@OptIn(DelicateCoroutinesApi::class)
- override fun onReceive(context: Context, intent: Intent) {
+ override fun onReceive(
+ context: Context,
+ intent: Intent,
+ ) {
when (intent.action) {
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> {
}
@@ -28,5 +30,4 @@ class BootReceiver : BroadcastReceiver() {
}
}
}
-
}
diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt
index 1206ea5..0431e1c 100644
--- a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt
@@ -13,16 +13,17 @@ import android.os.Build
import android.os.IBinder
import android.os.ParcelFileDescriptor
import android.os.PowerManager
+import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.MutableLiveData
import go.Seq
-import io.nekohasekai.libbox.BoxService
import io.nekohasekai.libbox.CommandServer
import io.nekohasekai.libbox.CommandServerHandler
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.Notification
+import io.nekohasekai.libbox.OverrideOptions
import io.nekohasekai.libbox.PlatformInterface
import io.nekohasekai.libbox.SystemProxyStatus
import io.nekohasekai.sfa.Application
@@ -34,6 +35,7 @@ import io.nekohasekai.sfa.database.ProfileManager
import io.nekohasekai.sfa.database.Settings
import io.nekohasekai.sfa.ktx.hasPermission
import io.nekohasekai.sfa.ui.MainActivity
+import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@@ -43,25 +45,28 @@ import kotlinx.coroutines.withContext
import java.io.File
class BoxService(
- private val service: Service, private val platformInterface: PlatformInterface
+ private val service: Service,
+ private val platformInterface: PlatformInterface,
) : CommandServerHandler {
-
companion object {
+ private const val PROFILE_UPDATE_INTERVAL = 15L * 60 * 1000 // 15 minutes in milliseconds
+ private const val TAG = "BoxService"
fun start() {
- val intent = runBlocking {
- withContext(Dispatchers.IO) {
- Intent(Application.application, Settings.serviceClass())
+ val intent =
+ runBlocking {
+ withContext(Dispatchers.IO) {
+ Intent(Application.application, Settings.serviceClass())
+ }
}
- }
ContextCompat.startForegroundService(Application.application, intent)
}
fun stop() {
Application.application.sendBroadcast(
Intent(Action.SERVICE_CLOSE).setPackage(
- Application.application.packageName
- )
+ Application.application.packageName,
+ ),
)
}
}
@@ -71,33 +76,37 @@ class BoxService(
private val status = MutableLiveData(Status.Stopped)
private val binder = ServiceBinder(status)
private val notification = ServiceNotification(status, service)
- private var boxService: BoxService? = null
- private var commandServer: CommandServer? = null
+ private lateinit var commandServer: CommandServer
+
private var receiverRegistered = false
- private val receiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- when (intent.action) {
- Action.SERVICE_CLOSE -> {
- stopService()
- }
+ private val receiver =
+ object : BroadcastReceiver() {
+ override fun onReceive(
+ context: Context,
+ intent: Intent,
+ ) {
+ when (intent.action) {
+ Action.SERVICE_CLOSE -> {
+ stopService()
+ }
-
- PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED -> {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- serviceUpdateIdleMode()
+ PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED -> {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ serviceUpdateIdleMode()
+ }
}
}
}
}
- }
private fun startCommandServer() {
- val commandServer = CommandServer(this, 300)
+ val commandServer = CommandServer(this, platformInterface)
commandServer.start()
this.commandServer = commandServer
}
private var lastProfileName = ""
+
private suspend fun startService() {
try {
withContext(Dispatchers.Main) {
@@ -130,30 +139,42 @@ class BoxService(
DefaultNetworkMonitor.start()
Libbox.setMemoryLimit(!Settings.disableMemoryLimit)
- val newService = try {
- Libbox.newService(content, platformInterface)
+ try {
+ commandServer.startOrReloadService(
+ content,
+ OverrideOptions().apply {
+ autoRedirect = Settings.autoRedirect
+ if (Vendor.isPerAppProxyAvailable() && Settings.perAppProxyEnabled) {
+ val appList = Settings.perAppProxyList
+ if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) {
+ includePackage =
+ PlatformInterfaceWrapper.StringArray(appList.iterator())
+ } else {
+ excludePackage =
+ PlatformInterfaceWrapper.StringArray(appList.iterator())
+ }
+ }
+ },
+ )
} catch (e: Exception) {
stopAndAlert(Alert.CreateService, e.message)
return
}
- newService.start()
-
- if (newService.needWIFIState()) {
- val wifiPermission = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
- android.Manifest.permission.ACCESS_FINE_LOCATION
- } else {
- android.Manifest.permission.ACCESS_BACKGROUND_LOCATION
- }
+ if (commandServer.needWIFIState()) {
+ val wifiPermission =
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ android.Manifest.permission.ACCESS_FINE_LOCATION
+ } else {
+ android.Manifest.permission.ACCESS_BACKGROUND_LOCATION
+ }
if (!service.hasPermission(wifiPermission)) {
- newService.close()
+ closeService()
stopAndAlert(Alert.RequestLocationPermission)
return
}
}
- boxService = newService
- commandServer?.setService(boxService)
status.postValue(Status.Started)
withContext(Dispatchers.Main) {
notification.show(lastProfileName, R.string.status_started)
@@ -165,7 +186,7 @@ class BoxService(
}
}
- override fun serviceReload() {
+ override fun serviceStop() {
notification.close()
status.postValue(Status.Starting)
val pfd = fileDescriptor
@@ -173,27 +194,70 @@ class BoxService(
pfd.close()
fileDescriptor = null
}
- boxService?.apply {
- runCatching {
- close()
- }.onFailure {
- writeLog("service: error when closing: $it")
- }
- Seq.destroyRef(refnum)
- }
- commandServer?.setService(null)
- commandServer?.resetLog()
- boxService = null
+ closeService()
+ }
+
+ override fun serviceReload() {
runBlocking {
- startService()
+ serviceReload0()
}
}
- override fun postServiceClose() {
- // Not used on Android
+ suspend fun serviceReload0() {
+ val selectedProfileId = Settings.selectedProfile
+ if (selectedProfileId == -1L) {
+ stopAndAlert(Alert.EmptyConfiguration)
+ return
+ }
+
+ val profile = ProfileManager.get(selectedProfileId)
+ if (profile == null) {
+ stopAndAlert(Alert.EmptyConfiguration)
+ return
+ }
+
+ val content = File(profile.typed.path).readText()
+ if (content.isBlank()) {
+ stopAndAlert(Alert.EmptyConfiguration)
+ return
+ }
+ lastProfileName = profile.name
+ try {
+ commandServer.startOrReloadService(
+ content,
+ OverrideOptions().apply {
+ autoRedirect = Settings.autoRedirect
+ if (Vendor.isPerAppProxyAvailable() && Settings.perAppProxyEnabled) {
+ val appList = Settings.perAppProxyList
+ if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) {
+ includePackage = PlatformInterfaceWrapper.StringArray(appList.iterator())
+ } else {
+ excludePackage = PlatformInterfaceWrapper.StringArray(appList.iterator())
+ }
+ }
+ },
+ )
+ } catch (e: Exception) {
+ stopAndAlert(Alert.CreateService, e.message)
+ return
+ }
+
+ if (commandServer.needWIFIState()) {
+ val wifiPermission =
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ android.Manifest.permission.ACCESS_FINE_LOCATION
+ } else {
+ android.Manifest.permission.ACCESS_BACKGROUND_LOCATION
+ }
+ if (!service.hasPermission(wifiPermission)) {
+ closeService()
+ stopAndAlert(Alert.RequestLocationPermission)
+ return
+ }
+ }
}
- override fun getSystemProxyStatus(): SystemProxyStatus {
+ override fun getSystemProxyStatus(): SystemProxyStatus? {
val status = SystemProxyStatus()
if (service is VPNService) {
status.available = service.systemProxyAvailable
@@ -209,9 +273,9 @@ class BoxService(
@RequiresApi(Build.VERSION_CODES.M)
private fun serviceUpdateIdleMode() {
if (Application.powerManager.isDeviceIdleMode) {
- boxService?.pause()
+ commandServer.pause()
} else {
- boxService?.wake()
+ commandServer.wake()
}
}
@@ -230,23 +294,12 @@ class BoxService(
pfd.close()
fileDescriptor = null
}
- boxService?.apply {
- runCatching {
- close()
- }.onFailure {
- writeLog("service: error when closing: $it")
- }
- Seq.destroyRef(refnum)
- }
- commandServer?.setService(null)
- boxService = null
DefaultNetworkMonitor.stop()
-
- commandServer?.apply {
+ closeService()
+ commandServer.apply {
close()
Seq.destroyRef(refnum)
}
- commandServer = null
Settings.startedByUser = false
withContext(Dispatchers.Main) {
status.value = Status.Stopped
@@ -255,7 +308,18 @@ class BoxService(
}
}
- private suspend fun stopAndAlert(type: Alert, message: String? = null) {
+ private fun closeService() {
+ runCatching {
+ commandServer.closeService()
+ }.onFailure {
+ commandServer.setError("android: close service: ${it.message}")
+ }
+ }
+
+ private suspend fun stopAndAlert(
+ type: Alert,
+ message: String? = null,
+ ) {
Settings.startedByUser = false
withContext(Dispatchers.Main) {
if (receiverRegistered) {
@@ -277,12 +341,17 @@ class BoxService(
status.value = Status.Starting
if (!receiverRegistered) {
- ContextCompat.registerReceiver(service, receiver, IntentFilter().apply {
- addAction(Action.SERVICE_CLOSE)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
- }
- }, ContextCompat.RECEIVER_NOT_EXPORTED)
+ ContextCompat.registerReceiver(
+ service,
+ receiver,
+ IntentFilter().apply {
+ addAction(Action.SERVICE_CLOSE)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
+ }
+ },
+ ContextCompat.RECEIVER_NOT_EXPORTED,
+ )
receiverRegistered = true
}
@@ -311,20 +380,13 @@ class BoxService(
stopService()
}
- internal fun writeLog(message: String) {
- commandServer?.writeMessage(message)
- }
-
internal fun sendNotification(notification: Notification) {
val builder =
NotificationCompat.Builder(service, notification.identifier).setShowWhen(false)
- .setContentTitle(notification.title)
- .setContentText(notification.body)
- .setOnlyAlertOnce(true)
- .setSmallIcon(R.drawable.ic_menu)
+ .setContentTitle(notification.title).setContentText(notification.body)
+ .setOnlyAlertOnce(true).setSmallIcon(R.drawable.ic_menu)
.setCategory(NotificationCompat.CATEGORY_EVENT)
- .setPriority(NotificationCompat.PRIORITY_HIGH)
- .setAutoCancel(true)
+ .setPriority(NotificationCompat.PRIORITY_HIGH).setAutoCancel(true)
if (!notification.subtitle.isNullOrBlank()) {
builder.setContentInfo(notification.subtitle)
}
@@ -334,13 +396,14 @@ class BoxService(
service,
0,
Intent(
- service, MainActivity::class.java
+ service,
+ MainActivity::class.java,
).apply {
setAction(Action.OPEN_URL).setData(Uri.parse(notification.openURL))
setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
},
ServiceNotification.flags,
- )
+ ),
)
}
GlobalScope.launch(Dispatchers.Main) {
@@ -349,11 +412,15 @@ class BoxService(
NotificationChannel(
notification.identifier,
notification.typeName,
- NotificationManager.IMPORTANCE_HIGH
- )
+ NotificationManager.IMPORTANCE_HIGH,
+ ),
)
}
Application.notification.notify(notification.typeID, builder.build())
}
}
-}
\ No newline at end of file
+
+ override fun writeDebugMessage(message: String?) {
+ Log.d("sing-box", message!!)
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkListener.kt b/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkListener.kt
index 239daae..60c0a8a 100644
--- a/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkListener.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkListener.kt
@@ -40,6 +40,7 @@ import kotlinx.coroutines.runBlocking
object DefaultNetworkListener {
private sealed class NetworkMessage {
class Start(val key: Any, val listener: (Network?) -> Unit) : NetworkMessage()
+
class Get : NetworkMessage() {
val response = CompletableDeferred()
}
@@ -47,64 +48,79 @@ object DefaultNetworkListener {
class Stop(val key: Any) : NetworkMessage()
class Put(val network: Network) : NetworkMessage()
+
class Update(val network: Network) : NetworkMessage()
+
class Lost(val network: Network) : NetworkMessage()
}
@OptIn(DelicateCoroutinesApi::class, ObsoleteCoroutinesApi::class)
- private val networkActor = GlobalScope.actor(Dispatchers.Unconfined) {
- val listeners = mutableMapOf Unit>()
- var network: Network? = null
- val pendingRequests = arrayListOf()
- for (message in channel) when (message) {
- is NetworkMessage.Start -> {
- if (listeners.isEmpty()) register()
- listeners[message.key] = message.listener
- if (network != null) message.listener(network)
- }
+ private val networkActor =
+ GlobalScope.actor(Dispatchers.Unconfined) {
+ val listeners = mutableMapOf Unit>()
+ var network: Network? = null
+ val pendingRequests = arrayListOf()
+ for (message in channel) when (message) {
+ is NetworkMessage.Start -> {
+ if (listeners.isEmpty()) register()
+ listeners[message.key] = message.listener
+ if (network != null) message.listener(network)
+ }
- is NetworkMessage.Get -> {
- check(listeners.isNotEmpty()) { "Getting network without any listeners is not supported" }
- if (network == null) pendingRequests += message else message.response.complete(
- network
- )
- }
+ is NetworkMessage.Get -> {
+ check(listeners.isNotEmpty()) { "Getting network without any listeners is not supported" }
+ if (network == null) {
+ pendingRequests += message
+ } else {
+ message.response.complete(
+ network,
+ )
+ }
+ }
- is NetworkMessage.Stop -> if (listeners.isNotEmpty() && // was not empty
- listeners.remove(message.key) != null && listeners.isEmpty()
- ) {
- network = null
- unregister()
- }
+ is NetworkMessage.Stop ->
+ if (listeners.isNotEmpty() && // was not empty
+ listeners.remove(message.key) != null && listeners.isEmpty()
+ ) {
+ network = null
+ unregister()
+ }
- is NetworkMessage.Put -> {
- network = message.network
- pendingRequests.forEach { it.response.complete(message.network) }
- pendingRequests.clear()
- listeners.values.forEach { it(network) }
- }
+ is NetworkMessage.Put -> {
+ network = message.network
+ pendingRequests.forEach { it.response.complete(message.network) }
+ pendingRequests.clear()
+ listeners.values.forEach { it(network) }
+ }
- is NetworkMessage.Update -> if (network == message.network) listeners.values.forEach {
- it(
- network
- )
- }
+ is NetworkMessage.Update ->
+ if (network == message.network) {
+ listeners.values.forEach {
+ it(
+ network,
+ )
+ }
+ }
- is NetworkMessage.Lost -> if (network == message.network) {
- network = null
- listeners.values.forEach { it(null) }
+ is NetworkMessage.Lost ->
+ if (network == message.network) {
+ network = null
+ listeners.values.forEach { it(null) }
+ }
}
}
- }
- suspend fun start(key: Any, listener: (Network?) -> Unit) = networkActor.send(
+ suspend fun start(
+ key: Any,
+ listener: (Network?) -> Unit,
+ ) = networkActor.send(
NetworkMessage.Start(
key,
- listener
- )
+ listener,
+ ),
)
- suspend fun get() = if (fallback) @TargetApi(23) {
+ suspend fun get(): Network = if (fallback) @TargetApi(23) {
Application.connectivity.activeNetwork
?: error("missing default network") // failed to listen, return current if available
} else NetworkMessage.Get().run {
@@ -116,40 +132,43 @@ object DefaultNetworkListener {
// NB: this runs in ConnectivityThread, and this behavior cannot be changed until API 26
private object Callback : ConnectivityManager.NetworkCallback() {
- override fun onAvailable(network: Network) = runBlocking {
- networkActor.send(
- NetworkMessage.Put(
- network
+ override fun onAvailable(network: Network) =
+ runBlocking {
+ networkActor.send(
+ NetworkMessage.Put(
+ network,
+ ),
)
- )
- }
+ }
override fun onCapabilitiesChanged(
network: Network,
- networkCapabilities: NetworkCapabilities
+ networkCapabilities: NetworkCapabilities,
) {
// it's a good idea to refresh capabilities
runBlocking { networkActor.send(NetworkMessage.Update(network)) }
}
- override fun onLost(network: Network) = runBlocking {
- networkActor.send(
- NetworkMessage.Lost(
- network
+ override fun onLost(network: Network) =
+ runBlocking {
+ networkActor.send(
+ NetworkMessage.Lost(
+ network,
+ ),
)
- )
- }
+ }
}
private var fallback = false
- private val request = NetworkRequest.Builder().apply {
- addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
- addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
- if (Build.VERSION.SDK_INT == 23) { // workarounds for OEM bugs
- removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
- removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)
- }
- }.build()
+ private val request =
+ NetworkRequest.Builder().apply {
+ addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
+ if (Build.VERSION.SDK_INT == 23) { // workarounds for OEM bugs
+ removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+ removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)
+ }
+ }.build()
private val mainHandler = Handler(Looper.getMainLooper())
/**
@@ -164,33 +183,42 @@ object DefaultNetworkListener {
*/
private fun register() {
when (Build.VERSION.SDK_INT) {
- in 31..Int.MAX_VALUE -> @TargetApi(31) {
- Application.connectivity.registerBestMatchingNetworkCallback(
- request,
- Callback,
- mainHandler
- )
- }
+ in 31..Int.MAX_VALUE ->
+ @TargetApi(31)
+ {
+ Application.connectivity.registerBestMatchingNetworkCallback(
+ request,
+ Callback,
+ mainHandler,
+ )
+ }
- in 28 until 31 -> @TargetApi(28) { // we want REQUEST here instead of LISTEN
- Application.connectivity.requestNetwork(request, Callback, mainHandler)
- }
+ in 28 until 31 ->
+ @TargetApi(28)
+ { // we want REQUEST here instead of LISTEN
+ Application.connectivity.requestNetwork(request, Callback, mainHandler)
+ }
- in 26 until 28 -> @TargetApi(26) {
- Application.connectivity.registerDefaultNetworkCallback(Callback, mainHandler)
- }
+ in 26 until 28 ->
+ @TargetApi(26)
+ {
+ Application.connectivity.registerDefaultNetworkCallback(Callback, mainHandler)
+ }
- in 24 until 26 -> @TargetApi(24) {
- Application.connectivity.registerDefaultNetworkCallback(Callback)
- }
+ in 24 until 26 ->
+ @TargetApi(24)
+ {
+ Application.connectivity.registerDefaultNetworkCallback(Callback)
+ }
- else -> try {
- fallback = false
- Application.connectivity.requestNetwork(request, Callback)
- } catch (e: RuntimeException) {
- fallback =
- true // known bug on API 23: https://stackoverflow.com/a/33509180/2245107
- }
+ else ->
+ try {
+ fallback = false
+ Application.connectivity.requestNetwork(request, Callback)
+ } catch (e: RuntimeException) {
+ fallback =
+ true // known bug on API 23: https://stackoverflow.com/a/33509180/2245107
+ }
}
}
@@ -199,4 +227,4 @@ object DefaultNetworkListener {
Application.connectivity.unregisterNetworkCallback(Callback)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt b/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt
index 9b5c874..9c44237 100644
--- a/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt
@@ -4,10 +4,6 @@ import android.net.Network
import android.os.Build
import io.nekohasekai.libbox.InterfaceUpdateListener
import io.nekohasekai.sfa.Application
-import io.nekohasekai.sfa.constant.Bugs
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.launch
import java.net.NetworkInterface
object DefaultNetworkMonitor {
@@ -59,22 +55,10 @@ object DefaultNetworkMonitor {
Thread.sleep(100)
continue
}
- if (Bugs.fixAndroidStack) {
- GlobalScope.launch(Dispatchers.IO) {
- listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false)
- }
- } else {
- listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false)
- }
+ listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false)
}
} else {
- if (Bugs.fixAndroidStack) {
- GlobalScope.launch(Dispatchers.IO) {
- listener.updateDefaultInterface("", -1, false, false)
- }
- } else {
- listener.updateDefaultInterface("", -1, false, false)
- }
+ listener.updateDefaultInterface("", -1, false, false)
}
}
diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt b/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt
index 5db814e..42d14ad 100644
--- a/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt
@@ -17,7 +17,6 @@ import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
object LocalResolver : LocalDNSTransport {
-
private const val RCODE_NXDOMAIN = 3
override fun raw(): Boolean {
@@ -25,58 +24,23 @@ object LocalResolver : LocalDNSTransport {
}
@RequiresApi(Build.VERSION_CODES.Q)
- override fun exchange(ctx: ExchangeContext, message: ByteArray) {
+ override fun exchange(
+ ctx: ExchangeContext,
+ message: ByteArray,
+ ) {
return runBlocking {
val defaultNetwork = DefaultNetworkMonitor.require()
suspendCoroutine { continuation ->
val signal = CancellationSignal()
ctx.onCancel(signal::cancel)
- val callback = object : DnsResolver.Callback {
- override fun onAnswer(answer: ByteArray, rcode: Int) {
- if (rcode == 0) {
- ctx.rawSuccess(answer)
- } else {
- ctx.errorCode(rcode)
- }
- continuation.resume(Unit)
- }
-
- override fun onError(error: DnsResolver.DnsException) {
- when (val cause = error.cause) {
- is ErrnoException -> {
- ctx.errnoCode(cause.errno)
- continuation.resume(Unit)
- return
- }
- }
- continuation.tryResumeWithException(error)
- }
- }
- DnsResolver.getInstance().rawQuery(
- defaultNetwork,
- message,
- DnsResolver.FLAG_NO_RETRY,
- Dispatchers.IO.asExecutor(),
- signal,
- callback
- )
- }
- }
- }
-
- override fun lookup(ctx: ExchangeContext, network: String, domain: String) {
- return runBlocking {
- val defaultNetwork = DefaultNetworkMonitor.require()
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- suspendCoroutine { continuation ->
- val signal = CancellationSignal()
- ctx.onCancel(signal::cancel)
- val callback = object : DnsResolver.Callback> {
- @Suppress("ThrowableNotThrown")
- override fun onAnswer(answer: Collection, rcode: Int) {
+ val callback =
+ object : DnsResolver.Callback {
+ override fun onAnswer(
+ answer: ByteArray,
+ rcode: Int,
+ ) {
if (rcode == 0) {
- ctx.success((answer as Collection).mapNotNull { it?.hostAddress }
- .joinToString("\n"))
+ ctx.rawSuccess(answer)
} else {
ctx.errorCode(rcode)
}
@@ -94,11 +58,64 @@ object LocalResolver : LocalDNSTransport {
continuation.tryResumeWithException(error)
}
}
- val type = when {
- network.endsWith("4") -> DnsResolver.TYPE_A
- network.endsWith("6") -> DnsResolver.TYPE_AAAA
- else -> null
- }
+ DnsResolver.getInstance().rawQuery(
+ defaultNetwork,
+ message,
+ DnsResolver.FLAG_NO_RETRY,
+ Dispatchers.IO.asExecutor(),
+ signal,
+ callback,
+ )
+ }
+ }
+ }
+
+ override fun lookup(
+ ctx: ExchangeContext,
+ network: String,
+ domain: String,
+ ) {
+ return runBlocking {
+ val defaultNetwork = DefaultNetworkMonitor.require()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ suspendCoroutine { continuation ->
+ val signal = CancellationSignal()
+ ctx.onCancel(signal::cancel)
+ val callback =
+ object : DnsResolver.Callback> {
+ @Suppress("ThrowableNotThrown")
+ override fun onAnswer(
+ answer: Collection,
+ rcode: Int,
+ ) {
+ if (rcode == 0) {
+ ctx.success(
+ (answer as Collection).mapNotNull { it?.hostAddress }
+ .joinToString("\n"),
+ )
+ } else {
+ ctx.errorCode(rcode)
+ }
+ continuation.resume(Unit)
+ }
+
+ override fun onError(error: DnsResolver.DnsException) {
+ when (val cause = error.cause) {
+ is ErrnoException -> {
+ ctx.errnoCode(cause.errno)
+ continuation.resume(Unit)
+ return
+ }
+ }
+ continuation.tryResumeWithException(error)
+ }
+ }
+ val type =
+ when {
+ network.endsWith("4") -> DnsResolver.TYPE_A
+ network.endsWith("6") -> DnsResolver.TYPE_AAAA
+ else -> null
+ }
if (type != null) {
DnsResolver.getInstance().query(
defaultNetwork,
@@ -107,7 +124,7 @@ object LocalResolver : LocalDNSTransport {
DnsResolver.FLAG_NO_RETRY,
Dispatchers.IO.asExecutor(),
signal,
- callback
+ callback,
)
} else {
DnsResolver.getInstance().query(
@@ -116,19 +133,20 @@ object LocalResolver : LocalDNSTransport {
DnsResolver.FLAG_NO_RETRY,
Dispatchers.IO.asExecutor(),
signal,
- callback
+ callback,
)
}
}
} else {
- val answer = try {
- defaultNetwork.getAllByName(domain)
- } catch (e: UnknownHostException) {
- ctx.errorCode(RCODE_NXDOMAIN)
- return@runBlocking
- }
+ val answer =
+ try {
+ defaultNetwork.getAllByName(domain)
+ } catch (e: UnknownHostException) {
+ ctx.errorCode(RCODE_NXDOMAIN)
+ return@runBlocking
+ }
ctx.success(answer.mapNotNull { it.hostAddress }.joinToString("\n"))
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt b/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt
index 068a529..1455e2a 100644
--- a/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt
@@ -27,7 +27,6 @@ import kotlin.io.encoding.ExperimentalEncodingApi
import io.nekohasekai.libbox.NetworkInterface as LibboxNetworkInterface
interface PlatformInterfaceWrapper : PlatformInterface {
-
override fun usePlatformAutoDetectInterfaceControl(): Boolean {
return true
}
@@ -49,14 +48,15 @@ interface PlatformInterfaceWrapper : PlatformInterface {
sourceAddress: String,
sourcePort: Int,
destinationAddress: String,
- destinationPort: Int
+ destinationPort: Int,
): Int {
try {
- val uid = Application.connectivity.getConnectionOwnerUid(
- ipProtocol,
- InetSocketAddress(sourceAddress, sourcePort),
- InetSocketAddress(destinationAddress, destinationPort)
- )
+ val uid =
+ Application.connectivity.getConnectionOwnerUid(
+ ipProtocol,
+ InetSocketAddress(sourceAddress, sourcePort),
+ InetSocketAddress(destinationAddress, destinationPort),
+ )
if (uid == Process.INVALID_UID) error("android: connection owner not found")
return uid
} catch (e: Exception) {
@@ -77,7 +77,8 @@ interface PlatformInterfaceWrapper : PlatformInterface {
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Application.packageManager.getPackageUid(
- packageName, PackageManager.PackageInfoFlags.of(0)
+ packageName,
+ PackageManager.PackageInfoFlags.of(0),
)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Application.packageManager.getPackageUid(packageName, 0)
@@ -111,23 +112,28 @@ interface PlatformInterfaceWrapper : PlatformInterface {
networkInterfaces.find { it.name == boxInterface.name } ?: continue
boxInterface.dnsServer =
StringArray(linkProperties.dnsServers.mapNotNull { it.hostAddress }.iterator())
- boxInterface.type = when {
- networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Libbox.InterfaceTypeWIFI
- networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> Libbox.InterfaceTypeCellular
- networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> Libbox.InterfaceTypeEthernet
- else -> Libbox.InterfaceTypeOther
- }
+ boxInterface.type =
+ when {
+ networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Libbox.InterfaceTypeWIFI
+ networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> Libbox.InterfaceTypeCellular
+ networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> Libbox.InterfaceTypeEthernet
+ else -> Libbox.InterfaceTypeOther
+ }
boxInterface.index = networkInterface.index
runCatching {
boxInterface.mtu = networkInterface.mtu
}.onFailure {
Log.e(
- "PlatformInterface", "failed to get mtu for interface ${boxInterface.name}", it
+ "PlatformInterface",
+ "failed to get mtu for interface ${boxInterface.name}",
+ it,
)
}
boxInterface.addresses =
- StringArray(networkInterface.interfaceAddresses.mapTo(mutableListOf()) { it.toPrefix() }
- .iterator())
+ StringArray(
+ networkInterface.interfaceAddresses.mapTo(mutableListOf()) { it.toPrefix() }
+ .iterator(),
+ )
var dumpFlags = 0
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
dumpFlags = OsConstants.IFF_UP or OsConstants.IFF_RUNNING
@@ -161,7 +167,8 @@ interface PlatformInterfaceWrapper : PlatformInterface {
}
override fun readWIFIState(): WIFIState? {
- @Suppress("DEPRECATION") val wifiInfo =
+ @Suppress("DEPRECATION")
+ val wifiInfo =
Application.wifiManager.connectionInfo ?: return null
var ssid = wifiInfo.ssid
if (ssid == "") {
@@ -182,12 +189,12 @@ interface PlatformInterfaceWrapper : PlatformInterface {
val certificates = mutableListOf()
val keyStore = KeyStore.getInstance("AndroidCAStore")
if (keyStore != null) {
- keyStore.load(null, null);
+ keyStore.load(null, null)
val aliases = keyStore.aliases()
while (aliases.hasMoreElements()) {
val cert = keyStore.getCertificate(aliases.nextElement())
certificates.add(
- "-----BEGIN CERTIFICATE-----\n" + Base64.encode(cert.encoded) + "\n-----END CERTIFICATE-----"
+ "-----BEGIN CERTIFICATE-----\n" + Base64.encode(cert.encoded) + "\n-----END CERTIFICATE-----",
)
}
}
@@ -196,7 +203,6 @@ interface PlatformInterfaceWrapper : PlatformInterface {
private class InterfaceArray(private val iterator: Iterator) :
NetworkInterfaceIterator {
-
override fun hasNext(): Boolean {
return iterator.hasNext()
}
@@ -204,11 +210,9 @@ interface PlatformInterfaceWrapper : PlatformInterface {
override fun next(): LibboxNetworkInterface {
return iterator.next()
}
-
}
- private class StringArray(private val iterator: Iterator) : StringIterator {
-
+ class StringArray(private val iterator: Iterator) : StringIterator {
override fun len(): Int {
// not used by core
return 0
@@ -225,15 +229,16 @@ interface PlatformInterfaceWrapper : PlatformInterface {
private fun InterfaceAddress.toPrefix(): String {
return if (address is Inet6Address) {
- "${Inet6Address.getByAddress(address.address).hostAddress}/${networkPrefixLength}"
+ "${Inet6Address.getByAddress(address.address).hostAddress}/$networkPrefixLength"
} else {
- "${address.hostAddress}/${networkPrefixLength}"
+ "${address.hostAddress}/$networkPrefixLength"
}
}
private val NetworkInterface.flags: Int
- @SuppressLint("SoonBlockedPrivateApi") get() {
+ @SuppressLint("SoonBlockedPrivateApi")
+ get() {
val getFlagsMethod = NetworkInterface::class.java.getDeclaredMethod("getFlags")
return getFlagsMethod.invoke(this) as Int
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt
index 6087d49..a7dc553 100644
--- a/app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt
@@ -5,18 +5,17 @@ import android.content.Intent
import io.nekohasekai.libbox.Notification
class ProxyService : Service(), PlatformInterfaceWrapper {
-
private val service = BoxService(this, this)
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) =
- service.onStartCommand()
+ override fun onStartCommand(
+ intent: Intent?,
+ flags: Int,
+ startId: Int,
+ ) = service.onStartCommand()
override fun onBind(intent: Intent) = service.onBind()
+
override fun onDestroy() = service.onDestroy()
- override fun writeLog(message: String) = service.writeLog(message)
-
- override fun sendNotification(notification: Notification) =
- service.sendNotification(notification)
-
-}
\ No newline at end of file
+ override fun sendNotification(notification: Notification) = service.sendNotification(notification)
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt
index 0f8a605..7e133a3 100644
--- a/app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt
@@ -58,4 +58,4 @@ class ServiceBinder(private val status: MutableLiveData) : IService.Stub
fun close() {
callbacks.kill()
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt
index c9d31f9..fbd17b8 100644
--- a/app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt
@@ -23,7 +23,6 @@ class ServiceConnection(
callback: Callback,
private val register: Boolean = true,
) : ServiceConnection {
-
companion object {
private const val TAG = "ServiceConnection"
}
@@ -34,11 +33,12 @@ class ServiceConnection(
val status get() = service?.status?.let { Status.values()[it] } ?: Status.Stopped
fun connect() {
- val intent = runBlocking {
- withContext(Dispatchers.IO) {
- Intent(context, Settings.serviceClass()).setAction(Action.SERVICE)
+ val intent =
+ runBlocking {
+ withContext(Dispatchers.IO) {
+ Intent(context, Settings.serviceClass()).setAction(Action.SERVICE)
+ }
}
- }
context.bindService(intent, this, AppCompatActivity.BIND_AUTO_CREATE)
Log.d(TAG, "request connect")
}
@@ -56,16 +56,20 @@ class ServiceConnection(
context.unbindService(this)
} catch (_: IllegalArgumentException) {
}
- val intent = runBlocking {
- withContext(Dispatchers.IO) {
- Intent(context, Settings.serviceClass()).setAction(Action.SERVICE)
+ val intent =
+ runBlocking {
+ withContext(Dispatchers.IO) {
+ Intent(context, Settings.serviceClass()).setAction(Action.SERVICE)
+ }
}
- }
context.bindService(intent, this, AppCompatActivity.BIND_AUTO_CREATE)
Log.d(TAG, "request reconnect")
}
- override fun onServiceConnected(name: ComponentName, binder: IBinder) {
+ override fun onServiceConnected(
+ name: ComponentName,
+ binder: IBinder,
+ ) {
val service = IService.Stub.asInterface(binder)
this.service = service
try {
@@ -93,7 +97,12 @@ class ServiceConnection(
interface Callback {
fun onServiceStatusChanged(status: Status)
- fun onServiceAlert(type: Alert, message: String?) {}
+
+ fun onServiceAlert(
+ type: Alert,
+ message: String?,
+ ) {
+ }
}
class ServiceCallback(private val callback: Callback) : IServiceCallback.Stub() {
@@ -101,8 +110,11 @@ class ServiceConnection(
callback.onServiceStatusChanged(Status.values()[status])
}
- override fun onServiceAlert(type: Int, message: String?) {
+ override fun onServiceAlert(
+ type: Int,
+ message: String?,
+ ) {
callback.onServiceAlert(Alert.values()[type], message)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt
index 7a8b0f0..4f07ca4 100644
--- a/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt
@@ -16,11 +16,11 @@ import androidx.lifecycle.MutableLiveData
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.libbox.StatusMessage
import io.nekohasekai.sfa.Application
+import io.nekohasekai.sfa.LauncherActivity
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.constant.Action
import io.nekohasekai.sfa.constant.Status
import io.nekohasekai.sfa.database.Settings
-import io.nekohasekai.sfa.ui.MainActivity
import io.nekohasekai.sfa.utils.CommandClient
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -28,7 +28,8 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.withContext
class ServiceNotification(
- private val status: MutableLiveData, private val service: Service
+ private val status: MutableLiveData,
+ private val service: Service,
) : BroadcastReceiver(), CommandClient.Handler {
companion object {
private const val notificationId = 1
@@ -60,37 +61,45 @@ class ServiceNotification(
0,
Intent(
service,
- MainActivity::class.java
+ LauncherActivity::class.java,
).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
- flags
- )
+ flags,
+ ),
)
.setPriority(NotificationCompat.PRIORITY_LOW).apply {
addAction(
NotificationCompat.Action.Builder(
- 0, service.getText(R.string.stop), PendingIntent.getBroadcast(
+ 0,
+ service.getText(R.string.stop),
+ PendingIntent.getBroadcast(
service,
0,
Intent(Action.SERVICE_CLOSE).setPackage(service.packageName),
- flags
- )
- ).build()
+ flags,
+ ),
+ ).build(),
)
}
}
- fun show(lastProfileName: String, @StringRes contentTextId: Int) {
+ fun show(
+ lastProfileName: String,
+ @StringRes contentTextId: Int,
+ ) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Application.notification.createNotificationChannel(
NotificationChannel(
- notificationChannel, "Service Notifications", NotificationManager.IMPORTANCE_LOW
- )
+ notificationChannel,
+ "Service Notifications",
+ NotificationManager.IMPORTANCE_LOW,
+ ),
)
}
service.startForeground(
- notificationId, notificationBuilder
+ notificationId,
+ notificationBuilder
.setContentTitle(lastProfileName.takeIf { it.isNotBlank() } ?: "sing-box")
- .setContentText(service.getString(contentTextId)).build()
+ .setContentText(service.getString(contentTextId)).build(),
)
}
@@ -104,10 +113,13 @@ class ServiceNotification(
}
private fun registerReceiver() {
- service.registerReceiver(this, IntentFilter().apply {
- addAction(Intent.ACTION_SCREEN_ON)
- addAction(Intent.ACTION_SCREEN_OFF)
- })
+ service.registerReceiver(
+ this,
+ IntentFilter().apply {
+ addAction(Intent.ACTION_SCREEN_ON)
+ addAction(Intent.ACTION_SCREEN_OFF)
+ },
+ )
receiverRegistered = true
}
@@ -116,11 +128,14 @@ class ServiceNotification(
Libbox.formatBytes(status.uplink) + "/s ↑\t" + Libbox.formatBytes(status.downlink) + "/s ↓"
Application.notificationManager.notify(
notificationId,
- notificationBuilder.setContentText(content).build()
+ notificationBuilder.setContentText(content).build(),
)
}
- override fun onReceive(context: Context, intent: Intent) {
+ override fun onReceive(
+ context: Context,
+ intent: Intent,
+ ) {
when (intent.action) {
Intent.ACTION_SCREEN_ON -> {
commandClient.connect()
@@ -140,4 +155,4 @@ class ServiceNotification(
receiverRegistered = false
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt
index 30fa930..4174a53 100644
--- a/app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt
@@ -9,16 +9,16 @@ import io.nekohasekai.sfa.constant.Status
@RequiresApi(24)
class TileService : TileService(), ServiceConnection.Callback {
-
private val connection = ServiceConnection(this, this)
override fun onServiceStatusChanged(status: Status) {
qsTile?.apply {
- state = when (status) {
- Status.Started -> Tile.STATE_ACTIVE
- Status.Stopped -> Tile.STATE_INACTIVE
- else -> Tile.STATE_UNAVAILABLE
- }
+ state =
+ when (status) {
+ Status.Started -> Tile.STATE_ACTIVE
+ Status.Stopped -> Tile.STATE_INACTIVE
+ else -> Tile.STATE_UNAVAILABLE
+ }
updateTile()
}
}
@@ -51,5 +51,4 @@ class TileService : TileService(), ServiceConnection.Callback {
else -> {}
}
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt b/app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt
index 9dee852..797a776 100644
--- a/app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt
@@ -19,7 +19,6 @@ import java.util.Date
import java.util.concurrent.TimeUnit
class UpdateProfileWork {
-
companion object {
private const val WORK_NAME = "UpdateProfile"
private const val TAG = "UpdateProfileWork"
@@ -33,8 +32,9 @@ class UpdateProfileWork {
}
private suspend fun reconfigureUpdater0() {
- val remoteProfiles = ProfileManager.list()
- .filter { it.typed.type == TypedProfile.Type.Remote && it.typed.autoUpdate }
+ val remoteProfiles =
+ ProfileManager.list()
+ .filter { it.typed.type == TypedProfile.Type.Remote && it.typed.autoUpdate }
if (remoteProfiles.isEmpty()) {
WorkManager.getInstance(Application.application).cancelUniqueWork(WORK_NAME)
return
@@ -54,19 +54,20 @@ class UpdateProfileWork {
if (minInitDelay > 0) setInitialDelay(minInitDelay, TimeUnit.SECONDS)
setBackoffCriteria(BackoffPolicy.LINEAR, 15, TimeUnit.MINUTES)
}
- .build()
+ .build(),
)
}
-
}
class UpdateTask(
- appContext: Context, params: WorkerParameters
+ appContext: Context,
+ params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
var selectedProfileUpdated = false
- val remoteProfiles = ProfileManager.list()
- .filter { it.typed.type == TypedProfile.Type.Remote && it.typed.autoUpdate }
+ val remoteProfiles =
+ ProfileManager.list()
+ .filter { it.typed.type == TypedProfile.Type.Remote && it.typed.autoUpdate }
if (remoteProfiles.isEmpty()) return Result.success()
var success = true
val selectedProfile = Settings.selectedProfile
@@ -104,8 +105,5 @@ class UpdateProfileWork {
Result.retry()
}
}
-
}
-
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt
index 4973c70..8827062 100644
--- a/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt
@@ -16,15 +16,17 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
class VPNService : VpnService(), PlatformInterfaceWrapper {
-
companion object {
private const val TAG = "VPNService"
}
private val service = BoxService(this, this)
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) =
- service.onStartCommand()
+ override fun onStartCommand(
+ intent: Intent?,
+ flags: Int,
+ startId: Int,
+ ) = service.onStartCommand()
override fun onBind(intent: Intent): IBinder {
val binder = super.onBind(intent)
@@ -56,9 +58,10 @@ class VPNService : VpnService(), PlatformInterfaceWrapper {
override fun openTun(options: TunOptions): Int {
if (prepare(this) != null) error("android: missing vpn permission")
- val builder = Builder()
- .setSession("sing-box")
- .setMtu(options.mtu)
+ val builder =
+ Builder()
+ .setSession("sing-box")
+ .setMtu(options.mtu)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
builder.setMetered(false)
@@ -125,42 +128,22 @@ class VPNService : VpnService(), PlatformInterfaceWrapper {
}
}
- if (Settings.perAppProxyEnabled) {
- val appList = Settings.perAppProxyList
- if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) {
- appList.forEach {
- try {
- builder.addAllowedApplication(it)
- } catch (_: NameNotFoundException) {
- }
- }
- builder.addAllowedApplication(packageName)
- } else {
- appList.forEach {
- try {
- builder.addDisallowedApplication(it)
- } catch (_: NameNotFoundException) {
- }
- }
- }
- } else {
- val includePackage = options.includePackage
- if (includePackage.hasNext()) {
- while (includePackage.hasNext()) {
- try {
- builder.addAllowedApplication(includePackage.next())
- } catch (_: NameNotFoundException) {
- }
+ val includePackage = options.includePackage
+ if (includePackage.hasNext()) {
+ while (includePackage.hasNext()) {
+ try {
+ builder.addAllowedApplication(includePackage.next())
+ } catch (_: NameNotFoundException) {
}
}
+ }
- val excludePackage = options.excludePackage
- if (excludePackage.hasNext()) {
- while (excludePackage.hasNext()) {
- try {
- builder.addDisallowedApplication(excludePackage.next())
- } catch (_: NameNotFoundException) {
- }
+ val excludePackage = options.excludePackage
+ if (excludePackage.hasNext()) {
+ while (excludePackage.hasNext()) {
+ try {
+ builder.addDisallowedApplication(excludePackage.next())
+ } catch (_: NameNotFoundException) {
}
}
}
@@ -169,13 +152,15 @@ class VPNService : VpnService(), PlatformInterfaceWrapper {
if (options.isHTTPProxyEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
systemProxyAvailable = true
systemProxyEnabled = Settings.systemProxyEnabled
- if (systemProxyEnabled) builder.setHttpProxy(
- ProxyInfo.buildDirectProxy(
- options.httpProxyServer,
- options.httpProxyServerPort,
- options.httpProxyBypassDomain.toList()
+ if (systemProxyEnabled) {
+ builder.setHttpProxy(
+ ProxyInfo.buildDirectProxy(
+ options.httpProxyServer,
+ options.httpProxyServerPort,
+ options.httpProxyBypassDomain.toList(),
+ ),
)
- )
+ }
} else {
systemProxyAvailable = false
systemProxyEnabled = false
@@ -187,9 +172,5 @@ class VPNService : VpnService(), PlatformInterfaceWrapper {
return pfd.fd
}
- override fun writeLog(message: String) = service.writeLog(message)
-
- override fun sendNotification(notification: Notification) =
- service.sendNotification(notification)
-
-}
\ No newline at end of file
+ override fun sendNotification(notification: Notification) = service.sendNotification(notification)
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt
new file mode 100644
index 0000000..a77e906
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt
@@ -0,0 +1,611 @@
+package io.nekohasekai.sfa.compose
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.net.VpnService
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.ExpandLess
+import androidx.compose.material.icons.filled.Folder
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavDestination.Companion.hierarchy
+import androidx.navigation.NavGraph.Companion.findStartDestination
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import io.nekohasekai.libbox.Libbox
+import io.nekohasekai.sfa.Application
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.bg.ServiceConnection
+import io.nekohasekai.sfa.bg.ServiceNotification
+import io.nekohasekai.sfa.compose.base.GlobalEventBus
+import io.nekohasekai.sfa.compose.base.UiEvent
+import io.nekohasekai.sfa.compose.navigation.SFANavHost
+import io.nekohasekai.sfa.compose.navigation.Screen
+import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens
+import io.nekohasekai.sfa.compose.screen.dashboard.CardGroup
+import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel
+import io.nekohasekai.sfa.compose.screen.log.LogViewModel
+import io.nekohasekai.sfa.compose.theme.SFATheme
+import io.nekohasekai.sfa.constant.Alert
+import io.nekohasekai.sfa.constant.ServiceMode
+import io.nekohasekai.sfa.constant.Status
+import io.nekohasekai.sfa.database.Settings
+import io.nekohasekai.sfa.ktx.hasPermission
+import io.nekohasekai.sfa.ktx.launchCustomTab
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class ComposeActivity : ComponentActivity(), ServiceConnection.Callback {
+ private val connection = ServiceConnection(this, this)
+ private lateinit var dashboardViewModel: DashboardViewModel
+ private var currentServiceStatus by mutableStateOf(Status.Stopped)
+ private var currentAlert by mutableStateOf?>(null)
+ private var showLocationPermissionDialog by mutableStateOf(false)
+ private var showBackgroundLocationDialog by mutableStateOf(false)
+
+ private val notificationPermissionLauncher =
+ registerForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) { isGranted ->
+ if (Settings.dynamicNotification && !isGranted) {
+ onServiceAlert(Alert.RequestNotificationPermission, null)
+ } else {
+ startService0()
+ }
+ }
+
+ private val locationPermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) {
+ if (it) {
+ if (it && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ requestBackgroundLocationPermission()
+ } else {
+ startService()
+ }
+ }
+ }
+
+ private val backgroundLocationPermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) {
+ if (it) {
+ startService()
+ }
+ }
+
+ private val prepareLauncher =
+ registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult(),
+ ) { result ->
+ if (result.resultCode == RESULT_OK) {
+ startService0()
+ } else {
+ onServiceAlert(Alert.RequestVPNPermission, null)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ connection.reconnect()
+
+ setContent {
+ SFATheme {
+ SFAApp()
+ }
+ }
+ }
+
+ @SuppressLint("NewApi")
+ fun startService() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !ServiceNotification.checkPermission()) {
+ notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ return
+ }
+ startService0()
+ }
+
+ private fun startService0() {
+ lifecycleScope.launch(Dispatchers.IO) {
+ if (Settings.rebuildServiceMode()) {
+ connection.reconnect()
+ }
+ if (Settings.serviceMode == ServiceMode.VPN) {
+ if (prepare()) {
+ return@launch
+ }
+ }
+ val intent = Intent(Application.application, Settings.serviceClass())
+ withContext(Dispatchers.Main) {
+ ContextCompat.startForegroundService(this@ComposeActivity, intent)
+ }
+ Settings.startedByUser = true
+ }
+ }
+
+ private suspend fun prepare() =
+ withContext(Dispatchers.Main) {
+ try {
+ val intent = VpnService.prepare(this@ComposeActivity)
+ if (intent != null) {
+ prepareLauncher.launch(intent)
+ true
+ } else {
+ false
+ }
+ } catch (e: Exception) {
+ onServiceAlert(Alert.RequestVPNPermission, e.message)
+ true
+ }
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ fun SFAApp() {
+ val navController = rememberNavController()
+ val navBackStackEntry by navController.currentBackStackEntryAsState()
+ val currentDestination = navBackStackEntry?.destination
+ val scope = rememberCoroutineScope()
+
+ // Snackbar state
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ // Error dialog state for UiEvent.ShowError
+ var showErrorDialog by remember { mutableStateOf(false) }
+ var errorMessage by remember { mutableStateOf("") }
+
+ // Handle service alerts
+ currentAlert?.let { (alertType, message) ->
+ ServiceAlertDialog(
+ alertType = alertType,
+ message = message,
+ onDismiss = { currentAlert = null },
+ )
+ }
+
+ // Handle UiEvent.ShowError dialog
+ if (showErrorDialog) {
+ AlertDialog(
+ onDismissRequest = { showErrorDialog = false },
+ title = { Text(stringResource(R.string.error_title)) },
+ text = { Text(errorMessage) },
+ confirmButton = {
+ TextButton(onClick = { showErrorDialog = false }) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ )
+ }
+
+ // Handle location permission dialogs
+ if (showLocationPermissionDialog) {
+ LocationPermissionDialog(onConfirm = {
+ showLocationPermissionDialog = false
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
+ }
+ }, onDismiss = { showLocationPermissionDialog = false })
+ }
+
+ if (showBackgroundLocationDialog) {
+ BackgroundLocationPermissionDialog(onConfirm = {
+ showBackgroundLocationDialog = false
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ backgroundLocationPermissionLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
+ }
+ }, onDismiss = { showBackgroundLocationDialog = false })
+ }
+
+ // Initialize the dashboard view model and store reference
+ val dashboardViewModel: DashboardViewModel = viewModel()
+ if (!::dashboardViewModel.isInitialized) {
+ this.dashboardViewModel = dashboardViewModel
+ }
+ val dashboardUiState by dashboardViewModel.uiState.collectAsState()
+
+ // Determine current screen title
+ val currentScreen =
+ bottomNavigationScreens.find { screen ->
+ currentDestination?.route == screen.route
+ } ?: bottomNavigationScreens[0]
+
+ // Check if we're in a settings sub-screen
+ val isSettingsSubScreen = currentDestination?.route?.startsWith("settings/") == true
+ val settingsScreenTitle =
+ when (currentDestination?.route) {
+ "settings/core" -> stringResource(R.string.core)
+ "settings/service" -> stringResource(R.string.service)
+ "settings/profile_override" -> stringResource(R.string.profile_override)
+ else -> null
+ }
+
+ // Get LogViewModel instance if we're on the Log screen
+ val logViewModel: LogViewModel? =
+ if (currentScreen == Screen.Log) {
+ viewModel()
+ } else {
+ null
+ }
+
+ // Collect all UI events from GlobalEventBus
+ LaunchedEffect(Unit) {
+ GlobalEventBus.events.collect { event ->
+ when (event) {
+ is UiEvent.ErrorMessage -> {
+ errorMessage = event.message
+ showErrorDialog = true
+ }
+
+ is UiEvent.OpenUrl -> {
+ this@ComposeActivity.launchCustomTab(event.url)
+ }
+
+ is UiEvent.RequestStartService -> {
+ startService()
+ }
+
+ is UiEvent.RequestReconnectService -> {
+ connection.reconnect()
+ }
+
+ is UiEvent.EditProfile -> {
+ val intent =
+ Intent(this@ComposeActivity, EditProfileComposeActivity::class.java)
+ intent.putExtra("profile_id", event.profileId)
+ startActivity(intent)
+ }
+
+ is UiEvent.RestartToTakeEffect -> {
+ if (currentServiceStatus == Status.Started) {
+ scope.launch {
+ val result =
+ snackbarHostState.showSnackbar(
+ message = "Restart to take effect",
+ actionLabel = "Restart",
+ duration = androidx.compose.material3.SnackbarDuration.Short,
+ )
+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
+ withContext(Dispatchers.IO) {
+ Libbox.newStandaloneCommandClient().serviceReload()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ if (isSettingsSubScreen && settingsScreenTitle != null) {
+ settingsScreenTitle
+ } else {
+ stringResource(currentScreen.titleRes)
+ },
+ )
+ },
+ navigationIcon = {
+ if (isSettingsSubScreen) {
+ IconButton(onClick = { navController.navigateUp() }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Default.ArrowBack,
+ contentDescription = stringResource(R.string.content_description_back),
+ )
+ }
+ }
+ },
+ actions = {
+ // Show Groups and Others menu for Dashboard screen (but not in settings sub-screens)
+ if (currentScreen == Screen.Dashboard && !isSettingsSubScreen) {
+ // Groups button - only show when service is running, groups exist, and Groups card is disabled
+ if ((currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting) &&
+ dashboardUiState.hasGroups &&
+ !dashboardUiState.visibleCards.contains(CardGroup.Groups)
+ ) {
+ IconButton(onClick = {
+ val intent =
+ Intent(
+ this@ComposeActivity,
+ GroupsComposeActivity::class.java,
+ )
+ startActivity(intent)
+ }) {
+ Icon(
+ imageVector = Icons.Filled.Folder,
+ contentDescription = stringResource(R.string.title_groups),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+
+ // More options button
+ IconButton(onClick = { dashboardViewModel.toggleCardSettingsDialog() }) {
+ Icon(
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = stringResource(R.string.title_others),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+
+ // Show actions only for Log screen and when logs are not empty
+ if (currentScreen == Screen.Log && logViewModel != null) {
+ val logUiState by logViewModel.uiState.collectAsState()
+
+ // Only show toolbar actions if logs are not empty and not in selection mode
+ if (logUiState.logs.isNotEmpty() && !logUiState.isSelectionMode) {
+ // Pause/Resume button
+ IconButton(onClick = { logViewModel.togglePause() }) {
+ Icon(
+ imageVector =
+ if (logUiState.isPaused) {
+ Icons.Default.PlayArrow
+ } else {
+ Icons.Default.Pause
+ },
+ contentDescription =
+ if (logUiState.isPaused) {
+ stringResource(
+ R.string.content_description_resume_logs,
+ )
+ } else {
+ stringResource(R.string.content_description_pause_logs)
+ },
+ )
+ }
+
+ // Search button
+ IconButton(onClick = { logViewModel.toggleSearch() }) {
+ Icon(
+ imageVector =
+ if (logUiState.isSearchActive) {
+ Icons.Default.ExpandLess
+ } else {
+ Icons.Default.Search
+ },
+ contentDescription =
+ if (logUiState.isSearchActive) {
+ stringResource(
+ R.string.content_description_collapse_search,
+ )
+ } else {
+ stringResource(R.string.content_description_search_logs)
+ },
+ tint =
+ if (logUiState.isSearchActive) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ )
+ }
+
+ // Options menu button
+ IconButton(onClick = { logViewModel.toggleOptionsMenu() }) {
+ Icon(
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = stringResource(R.string.more_options),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ } // End of logs.isNotEmpty() check
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(),
+ )
+ },
+ bottomBar = {
+ // Only show bottom bar when not in settings sub-screens
+ if (!isSettingsSubScreen) {
+ NavigationBar {
+ bottomNavigationScreens.forEach { screen ->
+ NavigationBarItem(
+ icon = { Icon(screen.icon, contentDescription = null) },
+ selected =
+ currentDestination?.hierarchy?.any {
+ it.route == screen.route
+ } == true,
+ onClick = {
+ navController.navigate(screen.route) {
+ // Pop up to the start destination of the graph to
+ // avoid building up a large stack of destinations
+ popUpTo(navController.graph.findStartDestination().id) {
+ saveState = true
+ }
+ // Avoid multiple copies of the same destination
+ launchSingleTop = true
+ // Restore state when reselecting a previously selected item
+ restoreState = true
+ }
+ },
+ )
+ }
+ }
+ }
+ },
+ ) { paddingValues ->
+ SFANavHost(
+ navController = navController,
+ serviceStatus = currentServiceStatus,
+ dashboardViewModel = dashboardViewModel,
+ logViewModel = logViewModel,
+ modifier = Modifier.padding(paddingValues),
+ )
+ }
+ }
+
+ override fun onServiceStatusChanged(status: Status) {
+ currentServiceStatus = status
+ // Update service status in ViewModels
+ if (::dashboardViewModel.isInitialized) {
+ dashboardViewModel.updateServiceStatus(status)
+ }
+ }
+
+ fun reconnect() {
+ connection.reconnect()
+ }
+
+ override fun onServiceAlert(
+ type: Alert,
+ message: String?,
+ ) {
+ when (type) {
+ Alert.RequestLocationPermission -> {
+ return requestLocationPermission()
+ }
+
+ else -> {
+ currentAlert = Pair(type, message)
+ }
+ }
+ }
+
+ private fun requestLocationPermission() {
+ if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
+ requestFineLocationPermission()
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ requestBackgroundLocationPermission()
+ }
+ }
+
+ private fun requestFineLocationPermission() {
+ // Show location permission dialog in Compose UI
+ showLocationPermissionDialog = true
+ }
+
+ private fun requestBackgroundLocationPermission() {
+ // Show background location permission dialog in Compose UI
+ showBackgroundLocationDialog = true
+ }
+
+ override fun onDestroy() {
+ connection.disconnect()
+ super.onDestroy()
+ }
+
+ @Composable
+ private fun ServiceAlertDialog(
+ alertType: Alert,
+ message: String?,
+ onDismiss: () -> Unit,
+ ) {
+ val title =
+ when (alertType) {
+ Alert.RequestNotificationPermission -> stringResource(R.string.notification_permission_title)
+ Alert.StartCommandServer -> stringResource(R.string.service_error_title_start_command_server)
+ Alert.CreateService -> stringResource(R.string.service_error_title_create_service)
+ Alert.StartService -> stringResource(R.string.service_error_title_start_service)
+ else -> null
+ }
+
+ val dialogMessage =
+ when (alertType) {
+ Alert.RequestVPNPermission -> stringResource(R.string.service_error_missing_permission)
+ Alert.RequestNotificationPermission -> stringResource(R.string.notification_permission_required_description)
+ Alert.EmptyConfiguration -> stringResource(R.string.service_error_empty_configuration)
+ else -> message
+ }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = title?.let { { Text(text = it) } },
+ text = dialogMessage?.let { { Text(text = it) } },
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ )
+ }
+
+ @Composable
+ private fun LocationPermissionDialog(
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ ) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.location_permission_title)) },
+ text = { Text(stringResource(R.string.location_permission_description)) },
+ confirmButton = {
+ TextButton(onClick = onConfirm) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.no_thanks))
+ }
+ },
+ )
+ }
+
+ @Composable
+ private fun BackgroundLocationPermissionDialog(
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ ) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.location_permission_title)) },
+ text = { Text(stringResource(R.string.location_permission_background_description)) },
+ confirmButton = {
+ TextButton(onClick = onConfirm) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.no_thanks))
+ }
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/EditProfileComposeActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/EditProfileComposeActivity.kt
new file mode 100644
index 0000000..7e9191d
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/EditProfileComposeActivity.kt
@@ -0,0 +1,207 @@
+package io.nekohasekai.sfa.compose
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import io.nekohasekai.sfa.compose.screen.profile.EditProfileContentScreen
+import io.nekohasekai.sfa.compose.screen.profile.EditProfileScreen
+import io.nekohasekai.sfa.compose.screen.profile.EditProfileViewModel
+import io.nekohasekai.sfa.compose.screen.profile.IconSelectionScreen
+import io.nekohasekai.sfa.compose.theme.SFATheme
+
+class EditProfileComposeActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val profileId = intent.getLongExtra("profile_id", -1L)
+ if (profileId == -1L) {
+ finish()
+ return
+ }
+
+ setContent {
+ SFATheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ val navController = rememberNavController()
+
+ // Create a shared ViewModel at the activity level
+ val sharedViewModel: EditProfileViewModel = viewModel()
+
+ // Initialize the ViewModel with the profile ID
+ LaunchedEffect(profileId) {
+ sharedViewModel.loadProfile(profileId)
+ }
+
+ NavHost(
+ navController = navController,
+ startDestination = "edit_profile",
+ ) {
+ composable(
+ route = "edit_profile",
+ enterTransition = {
+ slideIntoContainer(
+ AnimatedContentTransitionScope.SlideDirection.Left,
+ animationSpec = tween(300),
+ )
+ },
+ exitTransition = {
+ slideOutOfContainer(
+ AnimatedContentTransitionScope.SlideDirection.Left,
+ animationSpec = tween(300),
+ )
+ },
+ popEnterTransition = {
+ slideIntoContainer(
+ AnimatedContentTransitionScope.SlideDirection.Right,
+ animationSpec = tween(300),
+ )
+ },
+ popExitTransition = {
+ slideOutOfContainer(
+ AnimatedContentTransitionScope.SlideDirection.Right,
+ animationSpec = tween(300),
+ )
+ },
+ ) {
+ EditProfileScreen(
+ profileId = profileId,
+ onNavigateBack = { finish() },
+ onNavigateToIconSelection = { currentIconId ->
+ navController.navigate("icon_selection/${currentIconId ?: "null"}") {
+ launchSingleTop = true
+ }
+ },
+ onNavigateToEditContent = { profileName, isReadOnly ->
+ navController.navigate("edit_content/$profileName/$isReadOnly") {
+ launchSingleTop = true
+ }
+ },
+ viewModel = sharedViewModel,
+ )
+ }
+
+ composable(
+ route = "icon_selection/{currentIconId}",
+ arguments =
+ listOf(
+ navArgument("currentIconId") {
+ type = NavType.StringType
+ nullable = true
+ },
+ ),
+ enterTransition = {
+ slideIntoContainer(
+ AnimatedContentTransitionScope.SlideDirection.Left,
+ animationSpec = tween(300),
+ )
+ },
+ exitTransition = {
+ slideOutOfContainer(
+ AnimatedContentTransitionScope.SlideDirection.Left,
+ animationSpec = tween(300),
+ )
+ },
+ popEnterTransition = {
+ slideIntoContainer(
+ AnimatedContentTransitionScope.SlideDirection.Right,
+ animationSpec = tween(300),
+ )
+ },
+ popExitTransition = {
+ slideOutOfContainer(
+ AnimatedContentTransitionScope.SlideDirection.Right,
+ animationSpec = tween(300),
+ )
+ },
+ ) { backStackEntry ->
+ val currentIconId =
+ backStackEntry.arguments?.getString("currentIconId")
+ ?.takeIf { it != "null" }
+
+ IconSelectionScreen(
+ currentIconId = currentIconId,
+ onIconSelected = { iconId ->
+ // Update the shared ViewModel directly
+ sharedViewModel.updateIcon(iconId)
+ navController.popBackStack("edit_profile", inclusive = false)
+ },
+ onNavigateBack = {
+ navController.popBackStack("edit_profile", inclusive = false)
+ },
+ )
+ }
+
+ composable(
+ route = "edit_content/{profileName}/{isReadOnly}",
+ arguments =
+ listOf(
+ navArgument("profileName") {
+ type = NavType.StringType
+ defaultValue = ""
+ },
+ navArgument("isReadOnly") {
+ type = NavType.BoolType
+ defaultValue = false
+ },
+ ),
+ enterTransition = {
+ slideIntoContainer(
+ AnimatedContentTransitionScope.SlideDirection.Left,
+ animationSpec = tween(300),
+ )
+ },
+ exitTransition = {
+ slideOutOfContainer(
+ AnimatedContentTransitionScope.SlideDirection.Left,
+ animationSpec = tween(300),
+ )
+ },
+ popEnterTransition = {
+ slideIntoContainer(
+ AnimatedContentTransitionScope.SlideDirection.Right,
+ animationSpec = tween(300),
+ )
+ },
+ popExitTransition = {
+ slideOutOfContainer(
+ AnimatedContentTransitionScope.SlideDirection.Right,
+ animationSpec = tween(300),
+ )
+ },
+ ) { backStackEntry ->
+ val profileName = backStackEntry.arguments?.getString("profileName") ?: ""
+ val isReadOnly = backStackEntry.arguments?.getBoolean("isReadOnly") ?: false
+
+ EditProfileContentScreen(
+ profileId = profileId,
+ onNavigateBack = {
+ navController.popBackStack("edit_profile", inclusive = false)
+ },
+ profileName = profileName,
+ isReadOnly = isReadOnly,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/GroupsComposeActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/GroupsComposeActivity.kt
new file mode 100644
index 0000000..ece44ec
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/GroupsComposeActivity.kt
@@ -0,0 +1,125 @@
+package io.nekohasekai.sfa.compose
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.UnfoldLess
+import androidx.compose.material.icons.filled.UnfoldMore
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.viewmodel.compose.viewModel
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.bg.ServiceConnection
+import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard
+import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
+import io.nekohasekai.sfa.compose.theme.SFATheme
+import io.nekohasekai.sfa.constant.Alert
+import io.nekohasekai.sfa.constant.Status
+
+class GroupsComposeActivity : ComponentActivity(), ServiceConnection.Callback {
+ private val connection = ServiceConnection(this, this)
+ private var currentServiceStatus by mutableStateOf(Status.Stopped)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ connection.reconnect()
+
+ setContent {
+ SFATheme {
+ GroupsApp()
+ }
+ }
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ fun GroupsApp() {
+ val viewModel: GroupsViewModel = viewModel()
+ val uiState by viewModel.uiState.collectAsState()
+ val allCollapsed = uiState.expandedGroups.isEmpty()
+
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.title_groups)) },
+ navigationIcon = {
+ IconButton(onClick = { finish() }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.content_description_back),
+ )
+ }
+ },
+ actions = {
+ if (uiState.groups.isNotEmpty()) {
+ IconButton(onClick = { viewModel.toggleAllGroups() }) {
+ Icon(
+ imageVector =
+ if (allCollapsed) {
+ Icons.Default.UnfoldMore
+ } else {
+ Icons.Default.UnfoldLess
+ },
+ contentDescription =
+ if (allCollapsed) {
+ stringResource(R.string.expand_all)
+ } else {
+ stringResource(R.string.collapse_all)
+ },
+ )
+ }
+ }
+ },
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ titleContentColor = MaterialTheme.colorScheme.onSurface,
+ ),
+ )
+ },
+ ) { paddingValues ->
+ GroupsCard(
+ serviceStatus = currentServiceStatus,
+ isCardMode = false,
+ modifier = Modifier.padding(paddingValues),
+ )
+ }
+ }
+
+ override fun onServiceStatusChanged(status: Status) {
+ currentServiceStatus = status
+ }
+
+ override fun onServiceAlert(
+ type: Alert,
+ message: String?,
+ ) {
+ // Handle alerts if needed
+ }
+
+ override fun onDestroy() {
+ connection.disconnect()
+ super.onDestroy()
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/LineChart.kt b/app/src/main/java/io/nekohasekai/sfa/compose/LineChart.kt
new file mode 100644
index 0000000..6df820a
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/LineChart.kt
@@ -0,0 +1,131 @@
+package io.nekohasekai.sfa.compose
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.unit.dp
+import kotlin.math.max
+
+@Composable
+fun LineChart(
+ data: List,
+ modifier: Modifier = Modifier,
+ lineColor: Color = MaterialTheme.colorScheme.primary,
+ gridColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
+ animate: Boolean = true,
+) {
+ val animationProgress = remember { Animatable(if (animate) 0f else 1f) }
+
+ LaunchedEffect(data) {
+ if (animate) {
+ animationProgress.animateTo(
+ targetValue = 1f,
+ animationSpec = tween(durationMillis = 300),
+ )
+ }
+ }
+
+ Canvas(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .height(80.dp),
+ ) {
+ val width = size.width
+ val height = size.height
+ val maxValue = max(data.maxOrNull() ?: 1f, 1f) * 1.2f // Add 20% padding
+ val pointCount = data.size
+
+ // Draw horizontal grid lines
+ val gridLineCount = 3
+ for (i in 0..gridLineCount) {
+ val y = height * i / gridLineCount
+ drawLine(
+ color = gridColor,
+ start = Offset(0f, y),
+ end = Offset(width, y),
+ strokeWidth = 1.dp.toPx(),
+ pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f),
+ )
+ }
+
+ if (pointCount > 1) {
+ val path = Path()
+ val spacing = width / (pointCount - 1).toFloat()
+
+ // Calculate points
+ val points =
+ data.mapIndexed { index, value ->
+ val x = index * spacing
+ val normalizedValue = (value / maxValue).coerceIn(0f, 1f)
+ val y = height * (1 - normalizedValue)
+ Offset(x, y)
+ }
+
+ // Build the path
+ path.moveTo(points[0].x, points[0].y)
+ for (i in 1 until points.size) {
+ val progress = if (animate) animationProgress.value else 1f
+ val pointIndex = (i * progress).toInt().coerceAtMost(points.size - 1)
+
+ if (i <= pointIndex) {
+ val prev = points[i - 1]
+ val current = points[i]
+
+ // Simple line connection
+ path.lineTo(current.x, current.y)
+ }
+ }
+
+ // Draw the line
+ drawPath(
+ path = path,
+ color = lineColor,
+ style =
+ Stroke(
+ width = 2.dp.toPx(),
+ cap = StrokeCap.Round,
+ join = StrokeJoin.Round,
+ ),
+ )
+
+ // Draw gradient fill under the line
+ val fillPath = Path()
+ fillPath.addPath(path)
+
+ // Complete the fill area
+ if (points.isNotEmpty()) {
+ val progressIndex = ((points.size - 1) * animationProgress.value).toInt()
+ val lastPoint =
+ if (progressIndex >= 0 && progressIndex < points.size) {
+ points[progressIndex]
+ } else {
+ points.last()
+ }
+
+ fillPath.lineTo(lastPoint.x, height)
+ fillPath.lineTo(0f, height)
+ fillPath.lineTo(points[0].x, points[0].y)
+
+ drawPath(
+ path = fillPath,
+ color = lineColor.copy(alpha = 0.1f),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/NewProfileComposeActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/NewProfileComposeActivity.kt
new file mode 100644
index 0000000..d211a10
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/NewProfileComposeActivity.kt
@@ -0,0 +1,52 @@
+package io.nekohasekai.sfa.compose
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.ui.Modifier
+import io.nekohasekai.sfa.compose.screen.configuration.NewProfileScreen
+import io.nekohasekai.sfa.compose.theme.SFATheme
+
+class NewProfileComposeActivity : ComponentActivity() {
+ companion object {
+ const val EXTRA_PROFILE_ID = "profile_id"
+ const val EXTRA_IMPORT_NAME = "import_name"
+ const val EXTRA_IMPORT_URL = "import_url"
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val importName = intent.getStringExtra(EXTRA_IMPORT_NAME)
+ val importUrl = intent.getStringExtra(EXTRA_IMPORT_URL)
+
+ setContent {
+ SFATheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ NewProfileScreen(
+ importName = importName,
+ importUrl = importUrl,
+ onNavigateBack = { finish() },
+ onProfileCreated = { profileId ->
+ val resultIntent =
+ Intent().apply {
+ putExtra(EXTRA_PROFILE_ID, profileId)
+ }
+ setResult(RESULT_OK, resultIntent)
+ finish()
+ },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/base/BaseViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/base/BaseViewModel.kt
new file mode 100644
index 0000000..c896377
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/base/BaseViewModel.kt
@@ -0,0 +1,77 @@
+package io.nekohasekai.sfa.compose.base
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+abstract class BaseViewModel : ViewModel() {
+ private val _uiState: MutableStateFlow by lazy { MutableStateFlow(createInitialState()) }
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _events = MutableSharedFlow()
+ val events: SharedFlow = _events.asSharedFlow()
+
+ abstract fun createInitialState(): State
+
+ protected val currentState: State
+ get() = _uiState.value
+
+ protected fun updateState(reducer: State.() -> State) {
+ _uiState.value = _uiState.value.reducer()
+ }
+
+ /**
+ * Send an event that will be handled locally by the screen.
+ * For global events, use sendGlobalEvent() instead.
+ */
+ protected fun sendEvent(event: Event) {
+ viewModelScope.launch {
+ _events.emit(event)
+ }
+ }
+
+ /**
+ * Send a global UI event that will be handled by ComposeActivity.
+ * This is a convenience method for sending UiEvents to the global bus.
+ */
+ fun sendGlobalEvent(event: UiEvent) {
+ viewModelScope.launch {
+ GlobalEventBus.emit(event)
+ }
+ }
+
+ /**
+ * Send an error event to be displayed as a dialog.
+ * This is a convenience method for the common error handling case.
+ */
+ protected fun sendErrorMessage(message: String) {
+ sendGlobalEvent(UiEvent.ErrorMessage(message))
+ }
+
+ protected fun launch(
+ onError: ((Throwable) -> Unit)? = null,
+ block: suspend CoroutineScope.() -> Unit,
+ ) {
+ val errorHandler =
+ CoroutineExceptionHandler { _, throwable ->
+ onError?.invoke(throwable) ?: sendError(throwable)
+ }
+
+ viewModelScope.launch(errorHandler, block = block)
+ }
+
+ /**
+ * Convenience method to handle exceptions with a custom fallback message
+ */
+ protected fun sendError(throwable: Throwable) {
+ sendErrorMessage(throwable.message ?: "An unknown error occurred")
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/base/GlobalEventBus.kt b/app/src/main/java/io/nekohasekai/sfa/compose/base/GlobalEventBus.kt
new file mode 100644
index 0000000..92747f9
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/base/GlobalEventBus.kt
@@ -0,0 +1,35 @@
+package io.nekohasekai.sfa.compose.base
+
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+
+/**
+ * Global event bus that aggregates events from all ViewModels.
+ * This allows ComposeActivity to handle all events in a centralized manner.
+ */
+object GlobalEventBus {
+ private val _events =
+ MutableSharedFlow(
+ replay = 0,
+ extraBufferCapacity = 10,
+ )
+
+ val events: SharedFlow = _events.asSharedFlow()
+
+ /**
+ * Emit an event to the global event bus.
+ * This should be called by ViewModels to send events that need global handling.
+ */
+ suspend fun emit(event: UiEvent) {
+ _events.emit(event)
+ }
+
+ /**
+ * Try to emit an event without suspending.
+ * Returns true if the event was emitted successfully.
+ */
+ fun tryEmit(event: UiEvent): Boolean {
+ return _events.tryEmit(event)
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt b/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt
new file mode 100644
index 0000000..6b7467a
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt
@@ -0,0 +1,43 @@
+package io.nekohasekai.sfa.compose.base
+
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+
+/**
+ * Base sealed class for all UI events in the application.
+ * These are one-time events that should trigger UI actions.
+ */
+sealed class UiEvent {
+ data class ErrorMessage(val message: String) : UiEvent()
+
+ data class OpenUrl(val url: String) : UiEvent()
+
+ data class EditProfile(val profileId: Long) : UiEvent()
+
+ object RequestStartService : UiEvent()
+
+ object RequestReconnectService : UiEvent()
+
+ object RestartToTakeEffect : UiEvent()
+}
+
+/**
+ * Interface for screen-specific events that don't need global handling
+ */
+interface ScreenEvent
+
+interface EventHandler {
+ val events: SharedFlow
+
+ suspend fun sendEvent(event: T)
+}
+
+class UiEventHandler : EventHandler {
+ private val _events = MutableSharedFlow()
+ override val events: SharedFlow = _events.asSharedFlow()
+
+ override suspend fun sendEvent(event: T) {
+ _events.emit(event)
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/base/UiState.kt b/app/src/main/java/io/nekohasekai/sfa/compose/base/UiState.kt
new file mode 100644
index 0000000..55e36dc
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/base/UiState.kt
@@ -0,0 +1,15 @@
+package io.nekohasekai.sfa.compose.base
+
+sealed class UiState {
+ object Loading : UiState()
+
+ data class Success(val data: T) : UiState()
+
+ data class Error(val exception: Throwable, val message: String? = null) : UiState()
+}
+
+data class BaseUiState(
+ val isLoading: Boolean = false,
+ val data: T? = null,
+ val error: String? = null,
+)
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt
new file mode 100644
index 0000000..7f93697
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt
@@ -0,0 +1,40 @@
+package io.nekohasekai.sfa.compose.navigation
+
+import androidx.annotation.StringRes
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.TextSnippet
+import androidx.compose.material.icons.filled.Dashboard
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.ui.graphics.vector.ImageVector
+import io.nekohasekai.sfa.R
+
+sealed class Screen(
+ val route: String,
+ @StringRes val titleRes: Int,
+ val icon: ImageVector,
+) {
+ object Dashboard : Screen(
+ route = "dashboard",
+ titleRes = R.string.title_dashboard,
+ icon = Icons.Default.Dashboard,
+ )
+
+ object Log : Screen(
+ route = "log",
+ titleRes = R.string.title_log,
+ icon = Icons.AutoMirrored.Default.TextSnippet,
+ )
+
+ object Settings : Screen(
+ route = "settings",
+ titleRes = R.string.title_settings,
+ icon = Icons.Default.Settings,
+ )
+}
+
+val bottomNavigationScreens =
+ listOf(
+ Screen.Dashboard,
+ Screen.Log,
+ Screen.Settings,
+ )
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt
new file mode 100644
index 0000000..c2a4f75
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt
@@ -0,0 +1,150 @@
+package io.nekohasekai.sfa.compose.navigation
+
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.core.tween
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import io.nekohasekai.sfa.compose.screen.dashboard.DashboardScreen
+import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel
+import io.nekohasekai.sfa.compose.screen.log.LogScreen
+import io.nekohasekai.sfa.compose.screen.log.LogViewModel
+import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen
+import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen
+import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen
+import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen
+import io.nekohasekai.sfa.constant.Status
+
+@Composable
+fun SFANavHost(
+ navController: NavHostController,
+ serviceStatus: Status = Status.Stopped,
+ dashboardViewModel: DashboardViewModel? = null,
+ logViewModel: LogViewModel? = null,
+ modifier: Modifier = Modifier,
+) {
+ NavHost(
+ navController = navController,
+ startDestination = Screen.Dashboard.route,
+ modifier = modifier,
+ ) {
+ composable(Screen.Dashboard.route) {
+ if (dashboardViewModel != null) {
+ DashboardScreen(
+ serviceStatus = serviceStatus,
+ viewModel = dashboardViewModel,
+ )
+ } else {
+ DashboardScreen(serviceStatus = serviceStatus)
+ }
+ }
+
+ composable(Screen.Log.route) {
+ if (logViewModel != null) {
+ LogScreen(
+ serviceStatus = serviceStatus,
+ viewModel = logViewModel,
+ )
+ } else {
+ LogScreen(serviceStatus = serviceStatus)
+ }
+ }
+
+ composable(Screen.Settings.route) {
+ SettingsScreen(navController = navController)
+ }
+
+ // Settings subscreens with slide animations
+ composable(
+ route = "settings/core",
+ enterTransition = {
+ slideIntoContainer(
+ AnimatedContentTransitionScope.SlideDirection.Left,
+ animationSpec = tween(300),
+ )
+ },
+ exitTransition = {
+ slideOutOfContainer(
+ AnimatedContentTransitionScope.SlideDirection.Right,
+ animationSpec = tween(300),
+ )
+ },
+ popEnterTransition = {
+ slideIntoContainer(
+ AnimatedContentTransitionScope.SlideDirection.Left,
+ animationSpec = tween(300),
+ )
+ },
+ popExitTransition = {
+ slideOutOfContainer(
+ AnimatedContentTransitionScope.SlideDirection.Right,
+ animationSpec = tween(300),
+ )
+ },
+ ) {
+ CoreSettingsScreen(navController = navController)
+ }
+
+ composable(
+ route = "settings/service",
+ enterTransition = {
+ slideIntoContainer(
+ AnimatedContentTransitionScope.SlideDirection.Left,
+ animationSpec = tween(300),
+ )
+ },
+ exitTransition = {
+ slideOutOfContainer(
+ AnimatedContentTransitionScope.SlideDirection.Left,
+ animationSpec = tween(300),
+ )
+ },
+ popEnterTransition = {
+ slideIntoContainer(
+ AnimatedContentTransitionScope.SlideDirection.Right,
+ animationSpec = tween(300),
+ )
+ },
+ popExitTransition = {
+ slideOutOfContainer(
+ AnimatedContentTransitionScope.SlideDirection.Right,
+ animationSpec = tween(300),
+ )
+ },
+ ) {
+ ServiceSettingsScreen(navController = navController)
+ }
+
+ composable(
+ route = "settings/profile_override",
+ enterTransition = {
+ slideIntoContainer(
+ AnimatedContentTransitionScope.SlideDirection.Left,
+ animationSpec = tween(300),
+ )
+ },
+ exitTransition = {
+ slideOutOfContainer(
+ AnimatedContentTransitionScope.SlideDirection.Left,
+ animationSpec = tween(300),
+ )
+ },
+ popEnterTransition = {
+ slideIntoContainer(
+ AnimatedContentTransitionScope.SlideDirection.Right,
+ animationSpec = tween(300),
+ )
+ },
+ popExitTransition = {
+ slideOutOfContainer(
+ AnimatedContentTransitionScope.SlideDirection.Right,
+ animationSpec = tween(300),
+ )
+ },
+ ) {
+ ProfileOverrideScreen(navController = navController)
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt
new file mode 100644
index 0000000..d2b3980
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt
@@ -0,0 +1,588 @@
+package io.nekohasekai.sfa.compose.screen.configuration
+
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.BorderStroke
+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.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.CloudDownload
+import androidx.compose.material.icons.filled.CreateNewFolder
+import androidx.compose.material.icons.filled.FileUpload
+import androidx.compose.material.icons.filled.Save
+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.ExperimentalMaterial3Api
+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.OutlinedCard
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+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.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import io.nekohasekai.sfa.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NewProfileScreen(
+ importName: String? = null,
+ importUrl: String? = null,
+ onNavigateBack: () -> Unit,
+ onProfileCreated: (profileId: Long) -> Unit,
+ viewModel: NewProfileViewModel = viewModel(),
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+
+ LaunchedEffect(importName, importUrl) {
+ viewModel.initializeFromQRImport(importName, importUrl)
+ }
+
+ // File picker launcher
+ val filePickerLauncher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent(),
+ ) { uri: Uri? ->
+ uri?.let {
+ val fileName =
+ context.contentResolver.query(it, null, null, null, null)?.use { cursor ->
+ val nameIndex = cursor.getColumnIndexOrThrow("_display_name")
+ cursor.moveToFirst()
+ cursor.getString(nameIndex)
+ }
+ viewModel.setImportUri(it, fileName)
+ }
+ }
+
+ // Error dialog state
+ var showErrorDialog by remember { mutableStateOf(false) }
+
+ // Handle success
+ LaunchedEffect(uiState.isSuccess, uiState.createdProfile) {
+ if (uiState.isSuccess) {
+ uiState.createdProfile?.let { profile ->
+ onProfileCreated(profile.id)
+ onNavigateBack()
+ }
+ }
+ }
+
+ // Show error dialog when there's an error message
+ LaunchedEffect(uiState.errorMessage) {
+ if (uiState.errorMessage != null) {
+ showErrorDialog = true
+ }
+ }
+
+ // Error dialog
+ if (showErrorDialog) {
+ AlertDialog(
+ onDismissRequest = {
+ showErrorDialog = false
+ viewModel.clearError()
+ },
+ title = { Text(stringResource(R.string.error_title)) },
+ text = { Text(uiState.errorMessage ?: "") },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showErrorDialog = false
+ viewModel.clearError()
+ },
+ ) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ )
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.title_new_profile)) },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.content_description_back),
+ )
+ }
+ },
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ )
+ },
+ bottomBar = {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 3.dp,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets.navigationBars)
+ .padding(16.dp),
+ ) {
+ Button(
+ onClick = { viewModel.validateAndCreateProfile() },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !uiState.isSaving,
+ ) {
+ if (uiState.isSaving) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(18.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary,
+ )
+ } else {
+ Icon(
+ Icons.Default.Save,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(R.string.profile_create))
+ }
+ }
+ }
+ }
+ },
+ ) { paddingValues ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ // Profile Name
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
+ ),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.basic_information),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ OutlinedTextField(
+ value = uiState.name,
+ onValueChange = viewModel::updateName,
+ label = { Text(stringResource(R.string.profile_name)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ isError = uiState.nameError != null,
+ supportingText = {
+ uiState.nameError?.let { error ->
+ Text(
+ text = error,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ },
+ )
+ }
+ }
+
+ // Profile Type Selection
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
+ ),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.profile_type),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy((-1).dp), // Overlap borders
+ ) {
+ OutlinedButton(
+ onClick = { viewModel.updateProfileType(ProfileType.Local) },
+ modifier = Modifier.weight(1f),
+ shape =
+ RoundedCornerShape(
+ topStart = 12.dp,
+ bottomStart = 12.dp,
+ topEnd = 0.dp,
+ bottomEnd = 0.dp,
+ ),
+ colors =
+ if (uiState.profileType == ProfileType.Local) {
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ } else {
+ ButtonDefaults.outlinedButtonColors()
+ },
+ border =
+ BorderStroke(
+ 1.dp,
+ if (uiState.profileType == ProfileType.Local) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.outline
+ },
+ ),
+ ) {
+ Text(stringResource(R.string.profile_type_local))
+ }
+ OutlinedButton(
+ onClick = { viewModel.updateProfileType(ProfileType.Remote) },
+ modifier = Modifier.weight(1f),
+ shape =
+ RoundedCornerShape(
+ topStart = 0.dp,
+ bottomStart = 0.dp,
+ topEnd = 12.dp,
+ bottomEnd = 12.dp,
+ ),
+ colors =
+ if (uiState.profileType == ProfileType.Remote) {
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ } else {
+ ButtonDefaults.outlinedButtonColors()
+ },
+ border =
+ BorderStroke(
+ 1.dp,
+ if (uiState.profileType == ProfileType.Remote) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.outline
+ },
+ ),
+ ) {
+ Text(stringResource(R.string.profile_type_remote))
+ }
+ }
+ }
+ }
+
+ // Local Profile Options
+ AnimatedVisibility(
+ visible = uiState.profileType == ProfileType.Local,
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically(),
+ ) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f),
+ ),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.profile_source),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.secondary,
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy((-1).dp), // Overlap borders
+ ) {
+ OutlinedButton(
+ onClick = { viewModel.updateProfileSource(ProfileSource.CreateNew) },
+ modifier = Modifier.weight(1f),
+ shape =
+ RoundedCornerShape(
+ topStart = 12.dp,
+ bottomStart = 12.dp,
+ topEnd = 0.dp,
+ bottomEnd = 0.dp,
+ ),
+ colors =
+ if (uiState.profileSource == ProfileSource.CreateNew) {
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+ } else {
+ ButtonDefaults.outlinedButtonColors()
+ },
+ border =
+ BorderStroke(
+ 1.dp,
+ if (uiState.profileSource == ProfileSource.CreateNew) {
+ MaterialTheme.colorScheme.secondary
+ } else {
+ MaterialTheme.colorScheme.outline
+ },
+ ),
+ ) {
+ Icon(
+ Icons.Default.CreateNewFolder,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(stringResource(R.string.profile_source_create_new))
+ }
+ OutlinedButton(
+ onClick = { viewModel.updateProfileSource(ProfileSource.Import) },
+ modifier = Modifier.weight(1f),
+ shape =
+ RoundedCornerShape(
+ topStart = 0.dp,
+ bottomStart = 0.dp,
+ topEnd = 12.dp,
+ bottomEnd = 12.dp,
+ ),
+ colors =
+ if (uiState.profileSource == ProfileSource.Import) {
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+ } else {
+ ButtonDefaults.outlinedButtonColors()
+ },
+ border =
+ BorderStroke(
+ 1.dp,
+ if (uiState.profileSource == ProfileSource.Import) {
+ MaterialTheme.colorScheme.secondary
+ } else {
+ MaterialTheme.colorScheme.outline
+ },
+ ),
+ ) {
+ Icon(
+ Icons.Default.FileUpload,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(stringResource(R.string.profile_source_import))
+ }
+ }
+
+ AnimatedVisibility(
+ visible = uiState.profileSource == ProfileSource.Import,
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ OutlinedCard(
+ onClick = { filePickerLauncher.launch("*/*") },
+ modifier = Modifier.fillMaxWidth(),
+ border =
+ BorderStroke(
+ 1.dp,
+ if (uiState.importError != null) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.outline
+ },
+ ),
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Icon(
+ Icons.Default.FileUpload,
+ contentDescription = null,
+ tint =
+ if (uiState.importError != null) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.primary
+ },
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = uiState.importFileName ?: stringResource(R.string.profile_import_file),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ if (uiState.importFileName != null) {
+ Text(
+ text = stringResource(R.string.group_selected_title),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+ uiState.importError?.let { error ->
+ Text(
+ text = error,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(start = 16.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Remote Profile Options
+ AnimatedVisibility(
+ visible = uiState.profileType == ProfileType.Remote,
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically(),
+ ) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
+ ),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ Icons.Default.CloudDownload,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.size(20.dp),
+ )
+ Text(
+ text = stringResource(R.string.remote_configuration),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.tertiary,
+ )
+ }
+
+ OutlinedTextField(
+ value = uiState.remoteUrl,
+ onValueChange = viewModel::updateRemoteUrl,
+ label = { Text(stringResource(R.string.profile_url)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ isError = uiState.remoteUrlError != null,
+ supportingText = {
+ uiState.remoteUrlError?.let { error ->
+ Text(
+ text = error,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ },
+ )
+
+ HorizontalDivider()
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = stringResource(R.string.profile_auto_update),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Switch(
+ checked = uiState.autoUpdate,
+ onCheckedChange = viewModel::updateAutoUpdate,
+ )
+ }
+
+ AnimatedVisibility(visible = uiState.autoUpdate) {
+ OutlinedTextField(
+ value = uiState.autoUpdateInterval.toString(),
+ onValueChange = viewModel::updateAutoUpdateInterval,
+ label = { Text(stringResource(R.string.profile_auto_update_interval)) },
+ supportingText = { Text(stringResource(R.string.profile_auto_update_interval_minimum_hint)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileViewModel.kt
new file mode 100644
index 0000000..5c6b92f
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileViewModel.kt
@@ -0,0 +1,315 @@
+package io.nekohasekai.sfa.compose.screen.configuration
+
+import android.app.Application
+import android.net.Uri
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import io.nekohasekai.libbox.Libbox
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.bg.UpdateProfileWork
+import io.nekohasekai.sfa.database.Profile
+import io.nekohasekai.sfa.database.ProfileManager
+import io.nekohasekai.sfa.database.Settings
+import io.nekohasekai.sfa.database.TypedProfile
+import io.nekohasekai.sfa.utils.HTTPClient
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.InputStream
+import java.util.Date
+
+data class NewProfileUiState(
+ val name: String = "",
+ val profileType: ProfileType = ProfileType.Local,
+ val profileSource: ProfileSource = ProfileSource.CreateNew,
+ // Remote profile fields
+ val remoteUrl: String = "",
+ val autoUpdate: Boolean = true,
+ val autoUpdateInterval: Int = 60,
+ // File import
+ val importUri: Uri? = null,
+ val importFileName: String? = null,
+ // State
+ val isLoading: Boolean = false,
+ val isSaving: Boolean = false,
+ val errorMessage: String? = null,
+ val isSuccess: Boolean = false,
+ val createdProfile: Profile? = null,
+ // Field errors
+ val nameError: String? = null,
+ val remoteUrlError: String? = null,
+ val importError: String? = null,
+)
+
+enum class ProfileType {
+ Local,
+ Remote,
+}
+
+enum class ProfileSource {
+ CreateNew,
+ Import,
+}
+
+class NewProfileViewModel(application: Application) : AndroidViewModel(application) {
+ private val _uiState = MutableStateFlow(NewProfileUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun initializeFromQRImport(name: String?, url: String?) {
+ if (name != null && url != null) {
+ _uiState.update {
+ it.copy(
+ name = name,
+ profileType = ProfileType.Remote,
+ remoteUrl = url,
+ )
+ }
+ }
+ }
+
+ fun updateName(name: String) {
+ _uiState.update {
+ it.copy(
+ name = name,
+ nameError = if (name.isNotBlank()) null else it.nameError,
+ )
+ }
+ }
+
+ fun updateProfileType(type: ProfileType) {
+ _uiState.update { it.copy(profileType = type) }
+ }
+
+ fun updateProfileSource(source: ProfileSource) {
+ _uiState.update {
+ it.copy(
+ profileSource = source,
+ importError = null, // Clear import error when changing source
+ )
+ }
+ }
+
+ fun updateRemoteUrl(url: String) {
+ _uiState.update {
+ it.copy(
+ remoteUrl = url,
+ remoteUrlError = if (url.isNotBlank()) null else it.remoteUrlError,
+ )
+ }
+ }
+
+ fun updateAutoUpdate(enabled: Boolean) {
+ _uiState.update { it.copy(autoUpdate = enabled) }
+ }
+
+ fun updateAutoUpdateInterval(interval: String) {
+ val intValue = interval.toIntOrNull() ?: 60
+ _uiState.update { it.copy(autoUpdateInterval = intValue.coerceAtLeast(15)) }
+ }
+
+ fun setImportUri(
+ uri: Uri,
+ fileName: String?,
+ ) {
+ _uiState.update {
+ it.copy(
+ importUri = uri,
+ importFileName = fileName,
+ importError = null, // Clear error when file is selected
+ name =
+ if (it.name.isEmpty()) {
+ fileName?.substringBeforeLast(".") ?: "Imported Profile"
+ } else {
+ it.name
+ },
+ )
+ }
+ }
+
+ fun clearError() {
+ _uiState.update { it.copy(errorMessage = null) }
+ }
+
+ fun validateAndCreateProfile(): Boolean {
+ val state = _uiState.value
+ val context = getApplication()
+
+ // Clear previous errors
+ _uiState.update {
+ it.copy(
+ nameError = null,
+ remoteUrlError = null,
+ importError = null,
+ )
+ }
+
+ var hasError = false
+
+ // Validate name
+ if (state.name.isBlank()) {
+ _uiState.update { it.copy(nameError = context.getString(R.string.profile_input_required)) }
+ hasError = true
+ }
+
+ // Validate based on profile type
+ when (state.profileType) {
+ ProfileType.Local -> {
+ if (state.profileSource == ProfileSource.Import && state.importUri == null) {
+ _uiState.update { it.copy(importError = context.getString(R.string.profile_input_required)) }
+ hasError = true
+ }
+ }
+ ProfileType.Remote -> {
+ if (state.remoteUrl.isBlank()) {
+ _uiState.update { it.copy(remoteUrlError = context.getString(R.string.profile_input_required)) }
+ hasError = true
+ }
+ }
+ }
+
+ if (hasError) {
+ return false
+ }
+
+ // If validation passes, create the profile
+ createProfile()
+ return true
+ }
+
+ private fun createProfile() {
+ viewModelScope.launch {
+ val state = _uiState.value
+ _uiState.update { it.copy(isSaving = true, errorMessage = null) }
+
+ try {
+ val profile =
+ withContext(Dispatchers.IO) {
+ when (state.profileType) {
+ ProfileType.Local -> createLocalProfile(state)
+ ProfileType.Remote -> createRemoteProfile(state)
+ }
+ }
+
+ _uiState.update {
+ it.copy(
+ isSaving = false,
+ isSuccess = true,
+ createdProfile = profile,
+ )
+ }
+ } catch (e: Exception) {
+ _uiState.update {
+ it.copy(
+ isSaving = false,
+ errorMessage = e.message ?: "Unknown error",
+ )
+ }
+ }
+ }
+ }
+
+ private suspend fun createLocalProfile(state: NewProfileUiState): Profile {
+ val context = getApplication()
+ val typedProfile =
+ TypedProfile().apply {
+ type = TypedProfile.Type.Local
+ }
+
+ val profile =
+ Profile(name = state.name, typed = typedProfile).apply {
+ userOrder = ProfileManager.nextOrder()
+ }
+
+ val fileID = ProfileManager.nextFileID()
+ val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() }
+ val configFile = File(configDirectory, "$fileID.json")
+ typedProfile.path = configFile.path
+
+ // Get config content
+ val configContent =
+ when (state.profileSource) {
+ ProfileSource.CreateNew -> "{}"
+ ProfileSource.Import -> {
+ state.importUri?.let { uri ->
+ val sourceURL = uri.toString()
+ when {
+ sourceURL.startsWith("content://") -> {
+ val inputStream = context.contentResolver.openInputStream(uri) as InputStream
+ inputStream.use { it.bufferedReader().readText() }
+ }
+ sourceURL.startsWith("file://") -> {
+ File(Uri.parse(sourceURL).path!!).readText()
+ }
+ sourceURL.startsWith("http://") || sourceURL.startsWith("https://") -> {
+ HTTPClient().use { it.getString(sourceURL) }
+ }
+ else -> throw Exception("Unsupported source: $sourceURL")
+ }
+ } ?: "{}"
+ }
+ }
+
+ // Validate config
+ Libbox.checkConfig(configContent)
+ configFile.writeText(configContent)
+
+ // Create profile in database
+ ProfileManager.create(profile)
+
+ // If no profile is currently selected, select this one
+ if (Settings.selectedProfile == -1L) {
+ Settings.selectedProfile = profile.id
+ }
+
+ return profile
+ }
+
+ private suspend fun createRemoteProfile(state: NewProfileUiState): Profile {
+ val context = getApplication()
+ val typedProfile =
+ TypedProfile().apply {
+ type = TypedProfile.Type.Remote
+ remoteURL = state.remoteUrl
+ autoUpdate = state.autoUpdate
+ autoUpdateInterval = state.autoUpdateInterval
+ lastUpdated = Date()
+ }
+
+ val profile =
+ Profile(name = state.name, typed = typedProfile).apply {
+ userOrder = ProfileManager.nextOrder()
+ }
+
+ val fileID = ProfileManager.nextFileID()
+ val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() }
+ val configFile = File(configDirectory, "$fileID.json")
+ typedProfile.path = configFile.path
+
+ // Fetch initial config - this MUST succeed for remote profiles
+ val content = HTTPClient().use { it.getString(state.remoteUrl) }
+ Libbox.checkConfig(content)
+ val configContent = content
+
+ configFile.writeText(configContent)
+
+ // Create profile in database
+ ProfileManager.create(profile)
+
+ // If no profile is currently selected, select this one
+ if (Settings.selectedProfile == -1L) {
+ Settings.selectedProfile = profile.id
+ }
+
+ // Reconfigure updater if auto-update is enabled
+ if (state.autoUpdate) {
+ UpdateProfileWork.reconfigureUpdater()
+ }
+
+ return profile
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt
new file mode 100644
index 0000000..e203866
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt
@@ -0,0 +1,335 @@
+package io.nekohasekai.sfa.compose.screen.configuration
+
+import android.content.Context
+import android.net.Uri
+import android.provider.OpenableColumns
+import io.nekohasekai.libbox.Libbox
+import io.nekohasekai.libbox.ProfileContent
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.database.Profile
+import io.nekohasekai.sfa.database.ProfileManager
+import io.nekohasekai.sfa.database.Settings
+import io.nekohasekai.sfa.database.TypedProfile
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.io.File
+import java.util.Date
+
+class ProfileImportHandler(private val context: Context) {
+ sealed class ImportResult {
+ data class Success(val profile: Profile) : ImportResult()
+
+ data class Error(val message: String) : ImportResult()
+ }
+
+ sealed class QRCodeParseResult {
+ data class RemoteProfile(val name: String, val host: String, val url: String) :
+ QRCodeParseResult()
+
+ data class LocalProfile(val name: String) : QRCodeParseResult()
+
+ data class Error(val message: String) : QRCodeParseResult()
+ }
+
+ suspend fun importFromUri(uri: Uri): ImportResult =
+ withContext(Dispatchers.IO) {
+ try {
+ val data =
+ context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ ?: return@withContext ImportResult.Error(context.getString(R.string.error_empty_file))
+
+ // Get the filename from the URI
+ val filename = getFileNameFromUri(uri)
+
+ // Try to detect if it's a JSON configuration file
+ val dataString = String(data)
+ if (isJsonConfiguration(dataString)) {
+ // It's a JSON configuration, import it directly as a local profile
+ return@withContext importJsonConfiguration(dataString, filename)
+ }
+
+ // Try to decode as ProfileContent (the old way)
+ val content =
+ try {
+ Libbox.decodeProfileContent(data)
+ } catch (e: Exception) {
+ // If it fails, try one more time as JSON
+ if (dataString.trimStart().startsWith("{") || dataString.trimStart().startsWith("[")) {
+ return@withContext importJsonConfiguration(dataString, filename)
+ }
+ return@withContext ImportResult.Error(
+ context.getString(R.string.error_decode_profile, e.message),
+ )
+ }
+
+ importProfile(content)
+ } catch (e: Exception) {
+ ImportResult.Error(e.message ?: "Unknown error")
+ }
+ }
+
+ suspend fun parseQRCode(data: String): QRCodeParseResult =
+ withContext(Dispatchers.IO) {
+ try {
+ // Check if it's a sing-box remote profile import link
+ if (data.startsWith("sing-box://import-remote-profile")) {
+ try {
+ val profileInfo = Libbox.parseRemoteProfileImportLink(data)
+ return@withContext QRCodeParseResult.RemoteProfile(
+ name = profileInfo.name,
+ host = profileInfo.host,
+ url = profileInfo.url,
+ )
+ } catch (e: Exception) {
+ return@withContext QRCodeParseResult.Error(
+ context.getString(R.string.error_decode_profile, e.message),
+ )
+ }
+ }
+
+ // Check if it's a direct URL
+ if (data.startsWith("http://") || data.startsWith("https://")) {
+ val profileName = extractProfileNameFromUrl(data)
+ return@withContext QRCodeParseResult.RemoteProfile(
+ name = profileName,
+ host = extractHostFromUrl(data),
+ url = data,
+ )
+ }
+
+ // Try to decode as profile content
+ val content =
+ try {
+ Libbox.decodeProfileContent(data.toByteArray())
+ } catch (e: Exception) {
+ return@withContext QRCodeParseResult.Error(
+ context.getString(R.string.error_decode_profile, e.message),
+ )
+ }
+
+ return@withContext QRCodeParseResult.LocalProfile(name = content.name)
+ } catch (e: Exception) {
+ QRCodeParseResult.Error(e.message ?: "Unknown error")
+ }
+ }
+
+ suspend fun importFromQRCode(data: String): ImportResult =
+ withContext(Dispatchers.IO) {
+ try {
+ // Check if it's a sing-box remote profile import link
+ if (data.startsWith("sing-box://import-remote-profile")) {
+ try {
+ val profileInfo = Libbox.parseRemoteProfileImportLink(data)
+ return@withContext importRemoteProfile(profileInfo.name, profileInfo.url)
+ } catch (e: Exception) {
+ return@withContext ImportResult.Error(
+ context.getString(R.string.error_decode_profile, e.message),
+ )
+ }
+ }
+
+ // Check if it's a URL or direct profile content
+ if (data.startsWith("http://") || data.startsWith("https://")) {
+ // Handle remote profile URL
+ val profileName = extractProfileNameFromUrl(data)
+ importRemoteProfile(profileName, data)
+ } else {
+ // Try to decode as profile content
+ val content =
+ try {
+ Libbox.decodeProfileContent(data.toByteArray())
+ } catch (e: Exception) {
+ return@withContext ImportResult.Error(
+ context.getString(R.string.error_decode_profile, e.message),
+ )
+ }
+ importProfile(content)
+ }
+ } catch (e: Exception) {
+ ImportResult.Error(e.message ?: "Unknown error")
+ }
+ }
+
+ private suspend fun importProfile(content: ProfileContent): ImportResult {
+ val typedProfile = TypedProfile()
+ val profile = Profile(name = content.name, typed = typedProfile)
+ profile.userOrder = ProfileManager.nextOrder()
+
+ when (content.type) {
+ Libbox.ProfileTypeLocal -> {
+ typedProfile.type = TypedProfile.Type.Local
+ }
+ Libbox.ProfileTypeiCloud -> {
+ return ImportResult.Error(context.getString(R.string.icloud_profile_unsupported))
+ }
+ Libbox.ProfileTypeRemote -> {
+ typedProfile.type = TypedProfile.Type.Remote
+ typedProfile.remoteURL = content.remotePath
+ typedProfile.autoUpdate = content.autoUpdate
+ typedProfile.autoUpdateInterval = content.autoUpdateInterval
+ typedProfile.lastUpdated = Date(content.lastUpdated)
+ }
+ }
+
+ // Save config file
+ val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() }
+ val configFile = File(configDirectory, "${profile.userOrder}.json")
+ configFile.writeText(content.config)
+ typedProfile.path = configFile.path
+
+ // Create profile in database
+ ProfileManager.create(profile)
+
+ // If no profile is currently selected, select this one
+ if (Settings.selectedProfile == -1L) {
+ Settings.selectedProfile = profile.id
+ }
+
+ return ImportResult.Success(profile)
+ }
+
+ private suspend fun importRemoteProfile(
+ name: String,
+ url: String,
+ ): ImportResult {
+ val typedProfile =
+ TypedProfile().apply {
+ type = TypedProfile.Type.Remote
+ remoteURL = url
+ autoUpdate = true
+ autoUpdateInterval = 60
+ lastUpdated = Date()
+ }
+
+ val profile =
+ Profile(name = name, typed = typedProfile).apply {
+ userOrder = ProfileManager.nextOrder()
+ }
+
+ // Create empty config file for remote profile
+ val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() }
+ val configFile = File(configDirectory, "${profile.userOrder}.json")
+ configFile.writeText("{}")
+ typedProfile.path = configFile.path
+
+ ProfileManager.create(profile)
+
+ // If no profile is currently selected, select this one
+ if (Settings.selectedProfile == -1L) {
+ Settings.selectedProfile = profile.id
+ }
+
+ return ImportResult.Success(profile)
+ }
+
+ private fun extractProfileNameFromUrl(url: String): String {
+ // Extract name from URL or use default
+ return url.substringAfterLast("/")
+ .substringBeforeLast(".")
+ .takeIf { it.isNotEmpty() }
+ ?: "Remote Profile"
+ }
+
+ private fun extractHostFromUrl(url: String): String {
+ return try {
+ val uri = Uri.parse(url)
+ uri.host ?: url
+ } catch (e: Exception) {
+ url
+ }
+ }
+
+ private fun getFileNameFromUri(uri: Uri): String {
+ var filename = "Imported Profile"
+
+ // Try to get filename from content resolver
+ context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
+ val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ if (nameIndex >= 0 && cursor.moveToFirst()) {
+ filename = cursor.getString(nameIndex)
+ ?.substringBeforeLast(".") // Remove extension
+ ?.takeIf { it.isNotEmpty() }
+ ?: filename
+ }
+ }
+
+ // Fallback to getting from URI path
+ if (filename == "Imported Profile") {
+ uri.lastPathSegment?.let { segment ->
+ filename = segment
+ .substringBeforeLast(".")
+ .takeIf { it.isNotEmpty() }
+ ?: filename
+ }
+ }
+
+ return filename
+ }
+
+ private fun isJsonConfiguration(content: String): Boolean {
+ val trimmed = content.trim()
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
+ return false
+ }
+
+ return try {
+ // Try to parse as JSON and check for sing-box configuration fields
+ val json = JSONObject(content)
+ // Check for common sing-box configuration fields
+ json.has("inbounds") ||
+ json.has("outbounds") ||
+ json.has("route") ||
+ json.has("dns") ||
+ json.has("experimental")
+ } catch (e: Exception) {
+ // If it's an array, it might still be valid
+ trimmed.startsWith("[") && trimmed.endsWith("]")
+ }
+ }
+
+ private suspend fun importJsonConfiguration(
+ jsonContent: String,
+ profileName: String,
+ ): ImportResult {
+ return try {
+ // Validate the JSON configuration using sing-box
+ try {
+ // Try to check the configuration
+ Libbox.checkConfig(jsonContent)
+ } catch (e: Exception) {
+ // Configuration validation failed
+ return ImportResult.Error(
+ context.getString(R.string.error_invalid_configuration, e.message),
+ )
+ }
+
+ // Create a local profile with the JSON configuration
+ val typedProfile =
+ TypedProfile().apply {
+ type = TypedProfile.Type.Local
+ }
+
+ val profile =
+ Profile(
+ name = profileName.ifEmpty { "Imported Profile" },
+ typed = typedProfile,
+ ).apply {
+ userOrder = ProfileManager.nextOrder()
+ }
+
+ // Save the configuration file
+ val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() }
+ val configFile = File(configDirectory, "${profile.userOrder}.json")
+ configFile.writeText(jsonContent)
+ typedProfile.path = configFile.path
+
+ // Create profile in database
+ ProfileManager.create(profile)
+
+ ImportResult.Success(profile)
+ } catch (e: Exception) {
+ ImportResult.Error(e.message ?: "Unknown error importing JSON configuration")
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/QRCodeDialog.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/QRCodeDialog.kt
new file mode 100644
index 0000000..df19ad4
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/QRCodeDialog.kt
@@ -0,0 +1,134 @@
+package io.nekohasekai.sfa.compose.screen.configuration
+
+import android.graphics.Bitmap
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+
+@Composable
+fun QRCodeDialog(
+ bitmap: Bitmap,
+ onDismiss: () -> Unit,
+ onShare: () -> Unit,
+ onSave: () -> Unit,
+) {
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ ) {
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth(0.9f)
+ .wrapContentHeight(),
+ shape = RoundedCornerShape(16.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = stringResource(io.nekohasekai.sfa.R.string.share_profile),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // QR Code Image
+ Surface(
+ modifier = Modifier.size(256.dp),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(8.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ bitmap = bitmap.asImageBitmap(),
+ contentDescription = stringResource(io.nekohasekai.sfa.R.string.content_description_qr_code),
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Action buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ OutlinedButton(
+ onClick = onSave,
+ modifier = Modifier.weight(1f),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Save,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(stringResource(io.nekohasekai.sfa.R.string.save))
+ }
+
+ Button(
+ onClick = onShare,
+ modifier = Modifier.weight(1f),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Share,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(stringResource(io.nekohasekai.sfa.R.string.profile_share))
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ClashModeCard.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ClashModeCard.kt
new file mode 100644
index 0000000..3fbbe59
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ClashModeCard.kt
@@ -0,0 +1,84 @@
+package io.nekohasekai.sfa.compose.screen.dashboard
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Tune
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import io.nekohasekai.sfa.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ClashModeCard(
+ modes: List,
+ selectedMode: String,
+ onModeSelected: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Tune,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(R.string.mode),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ SingleChoiceSegmentedButtonRow(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ modes.forEachIndexed { index, mode ->
+ SegmentedButton(
+ shape =
+ SegmentedButtonDefaults.itemShape(
+ index = index,
+ count = modes.size,
+ ),
+ onClick = { onModeSelected(mode) },
+ selected = mode == selectedMode,
+ ) {
+ Text(mode)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ConnectionsCard.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ConnectionsCard.kt
new file mode 100644
index 0000000..fab887d
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ConnectionsCard.kt
@@ -0,0 +1,98 @@
+package io.nekohasekai.sfa.compose.screen.dashboard
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Cable
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import io.nekohasekai.sfa.R
+
+@Composable
+fun ConnectionsCard(
+ connectionsIn: String,
+ connectionsOut: String,
+ modifier: Modifier = Modifier,
+) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Cable,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(R.string.title_connections),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Inbound connections
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.connections_in),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = connectionsIn,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Outbound connections
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.connections_out),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = connectionsOut,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt
new file mode 100644
index 0000000..5ab332b
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt
@@ -0,0 +1,140 @@
+package io.nekohasekai.sfa.compose.screen.dashboard
+
+import android.graphics.Bitmap
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import io.nekohasekai.sfa.constant.Status
+import io.nekohasekai.sfa.database.Profile
+import io.nekohasekai.sfa.utils.CommandClient
+
+@Composable
+fun DashboardCardRenderer(
+ cardGroup: CardGroup,
+ cardWidth: CardWidth,
+ uiState: DashboardUiState,
+ serviceStatus: Status = Status.Stopped,
+ onClashModeSelected: (String) -> Unit,
+ onSystemProxyToggle: (Boolean) -> Unit,
+ // Profile card specific props
+ profiles: List = emptyList(),
+ selectedProfileId: Long = -1L,
+ isLoading: Boolean = false,
+ showAddProfileSheet: Boolean = false,
+ updatingProfileId: Long? = null,
+ updatedProfileId: Long? = null,
+ onProfileSelected: (Long) -> Unit = {},
+ onProfileEdit: (Profile) -> Unit = {},
+ onProfileDelete: (Profile) -> Unit = {},
+ onProfileShare: (Profile) -> Unit = {},
+ onProfileShareURL: (Profile) -> Unit = {},
+ onProfileUpdate: (Profile) -> Unit = {},
+ onProfileMove: (Int, Int) -> Unit = { _, _ -> },
+ onShowAddProfileSheet: () -> Unit = {},
+ onHideAddProfileSheet: () -> Unit = {},
+ shareQRCodeImage: (Bitmap, String) -> Unit = { _, _ -> },
+ saveQRCodeToGallery: (Bitmap, String) -> Unit = { _, _ -> },
+ commandClient: CommandClient? = null,
+ modifier: Modifier = Modifier,
+) {
+ when (cardGroup) {
+ CardGroup.ClashMode -> {
+ if (uiState.clashModeVisible) {
+ ClashModeCard(
+ modes = uiState.clashModes,
+ selectedMode = uiState.selectedClashMode,
+ onModeSelected = onClashModeSelected,
+ modifier = modifier,
+ )
+ }
+ }
+
+ CardGroup.UploadTraffic -> {
+ if (uiState.trafficVisible) {
+ UploadTrafficCard(
+ uplink = uiState.uplink,
+ uplinkTotal = uiState.uplinkTotal,
+ uplinkHistory = uiState.uplinkHistory,
+ modifier = modifier,
+ )
+ }
+ }
+
+ CardGroup.DownloadTraffic -> {
+ if (uiState.trafficVisible) {
+ DownloadTrafficCard(
+ downlink = uiState.downlink,
+ downlinkTotal = uiState.downlinkTotal,
+ downlinkHistory = uiState.downlinkHistory,
+ modifier = modifier,
+ )
+ }
+ }
+
+ CardGroup.Debug -> {
+ if (uiState.isStatusVisible) {
+ DebugCard(
+ memory = uiState.memory,
+ goroutines = uiState.goroutines,
+ modifier = modifier,
+ )
+ }
+ }
+
+ CardGroup.Connections -> {
+ if (uiState.trafficVisible) {
+ ConnectionsCard(
+ connectionsIn = uiState.connectionsIn,
+ connectionsOut = uiState.connectionsOut,
+ modifier = modifier,
+ )
+ }
+ }
+
+ CardGroup.SystemProxy -> {
+ if (uiState.systemProxyVisible) {
+ SystemProxyCard(
+ enabled = uiState.systemProxyEnabled,
+ isSwitching = uiState.systemProxySwitching,
+ onToggle = onSystemProxyToggle,
+ modifier = modifier,
+ )
+ }
+ }
+
+ CardGroup.Profiles -> {
+ ProfilesCard(
+ profiles = profiles,
+ selectedProfileId = selectedProfileId,
+ isLoading = isLoading,
+ showAddProfileSheet = showAddProfileSheet,
+ updatingProfileId = updatingProfileId,
+ updatedProfileId = updatedProfileId,
+ onProfileSelected = onProfileSelected,
+ onProfileEdit = onProfileEdit,
+ onProfileDelete = onProfileDelete,
+ onProfileShare = onProfileShare,
+ onProfileShareURL = onProfileShareURL,
+ onProfileUpdate = onProfileUpdate,
+ onProfileMove = onProfileMove,
+ onShowAddProfileSheet = onShowAddProfileSheet,
+ onHideAddProfileSheet = onHideAddProfileSheet,
+ onImportFromFile = { /* Handled in ProfilesCard */ },
+ onScanQrCode = { /* Handled in ProfilesCard */ },
+ onCreateManually = { /* Handled in ProfilesCard */ },
+ shareQRCodeImage = shareQRCodeImage,
+ saveQRCodeToGallery = saveQRCodeToGallery,
+ )
+ }
+
+ CardGroup.Groups -> {
+ if (uiState.hasGroups) {
+ GroupsCard(
+ serviceStatus = serviceStatus,
+ isCardMode = true,
+ commandClient = commandClient,
+ modifier = modifier,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt
new file mode 100644
index 0000000..e1dda41
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt
@@ -0,0 +1,361 @@
+package io.nekohasekai.sfa.compose.screen.dashboard
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Stop
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.compose.base.UiEvent
+import io.nekohasekai.sfa.compose.util.saveQRCodeToGallery
+import io.nekohasekai.sfa.compose.util.shareQRCodeImage
+import io.nekohasekai.sfa.constant.Status
+import kotlinx.coroutines.launch
+
+data class CardRenderItem(
+ val cards: List,
+ val isRow: Boolean,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DashboardScreen(
+ serviceStatus: Status = Status.Stopped,
+ viewModel: DashboardViewModel = viewModel(),
+) {
+ val uiState by viewModel.uiState.collectAsState()
+
+ // Update service status in ViewModel
+ LaunchedEffect(serviceStatus) {
+ viewModel.updateServiceStatus(serviceStatus)
+ }
+
+ // Events are now handled globally in ComposeActivity via GlobalEventBus
+
+ // Show deprecated notes dialog
+ if (uiState.showDeprecatedDialog && uiState.deprecatedNotes.isNotEmpty()) {
+ val note = uiState.deprecatedNotes.first()
+ AlertDialog(
+ onDismissRequest = { },
+ title = { Text(stringResource(R.string.service_error_title_deprecated_warning)) },
+ text = { Text(note.message) },
+ confirmButton = {
+ TextButton(onClick = { viewModel.dismissDeprecatedNote() }) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ dismissButton =
+ if (!note.migrationLink.isNullOrBlank()) {
+ {
+ TextButton(onClick = {
+ viewModel.sendGlobalEvent(UiEvent.OpenUrl(note.migrationLink))
+ viewModel.dismissDeprecatedNote()
+ }) {
+ Text(stringResource(R.string.service_error_deprecated_warning_documentation))
+ }
+ }
+ } else {
+ null
+ },
+ )
+ }
+
+ val sheetState = rememberModalBottomSheetState()
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+
+ // Show dashboard settings bottom sheet
+ if (uiState.showCardSettingsDialog) {
+ DashboardSettingsBottomSheet(
+ sheetState = sheetState,
+ visibleCards = uiState.visibleCards,
+ cardOrder = uiState.cardOrder,
+ onToggleCard = viewModel::toggleCardVisibility,
+ onReorderCards = viewModel::reorderCards,
+ onResetOrder = viewModel::resetCardOrder,
+ onDismiss = {
+ scope.launch {
+ sheetState.hide()
+ viewModel.closeCardSettingsDialog()
+ }
+ },
+ )
+ }
+
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ LazyColumn(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ contentPadding =
+ PaddingValues(
+ bottom = 88.dp, // Increased to accommodate FAB (56dp height + 32dp padding)
+ ),
+ ) {
+ // Dynamic dashboard cards
+ // Show cards when service is running OR if it's the Profiles card (always available)
+ val serviceRunning = uiState.isStatusVisible
+
+ // Filter cards based on availability
+ val actuallyVisibleCards =
+ uiState.visibleCards.filter { cardGroup ->
+ when (cardGroup) {
+ CardGroup.Profiles -> true // Profiles card is always available
+ else -> serviceRunning && isCardAvailableWhenServiceRunning(cardGroup, uiState)
+ }
+ }.toSet()
+
+ // Process cards to group half-width cards together
+ val cardRenderItems =
+ processCardsForRendering(
+ cardOrder = uiState.cardOrder,
+ visibleCards = actuallyVisibleCards,
+ cardWidths = uiState.cardWidths,
+ )
+
+ items(cardRenderItems) { renderItem ->
+ if (renderItem.isRow && renderItem.cards.size >= 2) {
+ // Render two half-width cards in a row
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ renderItem.cards.forEach { cardGroup ->
+ DashboardCardRenderer(
+ cardGroup = cardGroup,
+ cardWidth =
+ uiState.cardWidths[cardGroup]
+ ?: CardWidth.Full,
+ uiState = uiState,
+ onClashModeSelected = viewModel::selectClashMode,
+ onSystemProxyToggle = viewModel::toggleSystemProxy,
+ // Profile card specific props
+ profiles = uiState.profiles,
+ selectedProfileId = uiState.selectedProfileId,
+ isLoading = uiState.isLoading,
+ showAddProfileSheet = uiState.showAddProfileSheet,
+ updatingProfileId = uiState.updatingProfileId,
+ updatedProfileId = uiState.updatedProfileId,
+ onProfileSelected = viewModel::selectProfile,
+ onProfileEdit = viewModel::editProfile,
+ onProfileDelete = viewModel::deleteProfile,
+ onProfileShare = viewModel::shareProfile,
+ onProfileShareURL = viewModel::shareProfileURL,
+ onProfileUpdate = viewModel::updateProfile,
+ onProfileMove = viewModel::moveProfile,
+ onShowAddProfileSheet = viewModel::showAddProfileSheet,
+ onHideAddProfileSheet = viewModel::hideAddProfileSheet,
+ shareQRCodeImage = { bitmap, name ->
+ scope.launch {
+ shareQRCodeImage(context, bitmap, name)
+ }
+ },
+ saveQRCodeToGallery = { bitmap, name ->
+ scope.launch {
+ saveQRCodeToGallery(context, bitmap, name)
+ }
+ },
+ commandClient = viewModel.commandClient,
+ modifier =
+ Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ )
+ }
+ }
+ } else {
+ // Render single card (full-width or single half-width)
+ renderItem.cards.forEach { cardGroup ->
+ DashboardCardRenderer(
+ cardGroup = cardGroup,
+ cardWidth =
+ uiState.cardWidths[cardGroup]
+ ?: CardWidth.Full,
+ uiState = uiState,
+ serviceStatus = serviceStatus,
+ onClashModeSelected = viewModel::selectClashMode,
+ onSystemProxyToggle = viewModel::toggleSystemProxy,
+ // Profile card specific props
+ profiles = uiState.profiles,
+ selectedProfileId = uiState.selectedProfileId,
+ isLoading = uiState.isLoading,
+ showAddProfileSheet = uiState.showAddProfileSheet,
+ updatingProfileId = uiState.updatingProfileId,
+ updatedProfileId = uiState.updatedProfileId,
+ onProfileSelected = viewModel::selectProfile,
+ onProfileEdit = viewModel::editProfile,
+ onProfileDelete = viewModel::deleteProfile,
+ onProfileShare = viewModel::shareProfile,
+ onProfileShareURL = viewModel::shareProfileURL,
+ onProfileUpdate = viewModel::updateProfile,
+ onProfileMove = viewModel::moveProfile,
+ onShowAddProfileSheet = viewModel::showAddProfileSheet,
+ onHideAddProfileSheet = viewModel::hideAddProfileSheet,
+ shareQRCodeImage = { bitmap, name ->
+ scope.launch {
+ shareQRCodeImage(context, bitmap, name)
+ }
+ },
+ saveQRCodeToGallery = { bitmap, name ->
+ scope.launch {
+ saveQRCodeToGallery(context, bitmap, name)
+ }
+ },
+ commandClient = viewModel.commandClient,
+ )
+ }
+ }
+ }
+ }
+
+ // FAB
+ AnimatedVisibility(
+ visible = uiState.serviceStatus != Status.Stopping,
+ enter = androidx.compose.animation.scaleIn(),
+ exit = androidx.compose.animation.scaleOut(),
+ modifier =
+ Modifier
+ .align(Alignment.BottomEnd)
+ .padding(16.dp),
+ ) {
+ ServiceControlFAB(
+ status = uiState.serviceStatus,
+ onToggle = { viewModel.toggleService() },
+ )
+ }
+ }
+}
+
+@Composable
+fun ServiceControlFAB(
+ status: Status,
+ onToggle: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ FloatingActionButton(
+ onClick = onToggle,
+ modifier = modifier,
+ containerColor = MaterialTheme.colorScheme.primary,
+ ) {
+ Icon(
+ imageVector =
+ when (status) {
+ Status.Started, Status.Starting -> Icons.Default.Stop
+ else -> Icons.Default.PlayArrow
+ },
+ contentDescription =
+ when (status) {
+ Status.Started, Status.Starting -> stringResource(R.string.stop)
+ else -> stringResource(R.string.action_start)
+ },
+ )
+ }
+}
+
+/**
+ * Process cards for rendering, grouping consecutive half-width cards into rows
+ */
+fun processCardsForRendering(
+ cardOrder: List,
+ visibleCards: Set,
+ cardWidths: Map,
+): List {
+ val renderItems = mutableListOf()
+ val visibleOrderedCards = cardOrder.filter { visibleCards.contains(it) }
+
+ var i = 0
+ while (i < visibleOrderedCards.size) {
+ val currentCard = visibleOrderedCards[i]
+ val currentWidth = cardWidths[currentCard] ?: CardWidth.Full
+
+ if (currentWidth == CardWidth.Half) {
+ // Check if next card is also half-width
+ if (i + 1 < visibleOrderedCards.size) {
+ val nextCard = visibleOrderedCards[i + 1]
+ val nextWidth = cardWidths[nextCard] ?: CardWidth.Full
+
+ if (nextWidth == CardWidth.Half) {
+ // Group two half-width cards together
+ renderItems.add(
+ CardRenderItem(
+ cards = listOf(currentCard, nextCard),
+ isRow = true,
+ ),
+ )
+ i += 2
+ continue
+ }
+ }
+ // Single half-width card
+ renderItems.add(
+ CardRenderItem(
+ cards = listOf(currentCard),
+ isRow = false,
+ ),
+ )
+ } else {
+ // Full-width card
+ renderItems.add(
+ CardRenderItem(
+ cards = listOf(currentCard),
+ isRow = false,
+ ),
+ )
+ }
+ i++
+ }
+
+ return renderItems
+}
+
+/**
+ * Determine if a service-dependent card has data available to display.
+ * This function is only relevant when the service is running.
+ * Note: Profiles card is always available and should not use this function.
+ */
+fun isCardAvailableWhenServiceRunning(
+ cardGroup: CardGroup,
+ uiState: DashboardUiState,
+): Boolean {
+ return when (cardGroup) {
+ CardGroup.ClashMode -> uiState.clashModeVisible
+ CardGroup.UploadTraffic -> uiState.trafficVisible
+ CardGroup.DownloadTraffic -> uiState.trafficVisible
+ CardGroup.Debug -> true // Debug info is always available when service is running
+ CardGroup.Connections -> uiState.trafficVisible
+ CardGroup.SystemProxy -> uiState.systemProxyVisible
+ CardGroup.Profiles -> true // This shouldn't be called for Profiles, but return true for safety
+ CardGroup.Groups -> uiState.hasGroups // Groups card available when groups exist
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt
new file mode 100644
index 0000000..deb1a49
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt
@@ -0,0 +1,442 @@
+package io.nekohasekai.sfa.compose.screen.dashboard
+
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.draggable
+import androidx.compose.foundation.gestures.rememberDraggableState
+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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.offset
+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.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.DragHandle
+import androidx.compose.material.icons.filled.RestartAlt
+import androidx.compose.material.icons.outlined.BugReport
+import androidx.compose.material.icons.outlined.Cable
+import androidx.compose.material.icons.outlined.Download
+import androidx.compose.material.icons.outlined.Folder
+import androidx.compose.material.icons.outlined.Person
+import androidx.compose.material.icons.outlined.Route
+import androidx.compose.material.icons.outlined.SettingsEthernet
+import androidx.compose.material.icons.outlined.Upload
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+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.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import io.nekohasekai.sfa.R
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+@Composable
+fun DashboardSettingsBottomSheet(
+ sheetState: SheetState,
+ visibleCards: Set,
+ cardOrder: List,
+ onToggleCard: (CardGroup) -> Unit,
+ onReorderCards: (List) -> Unit,
+ onResetOrder: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ var reorderedList by remember(cardOrder) { mutableStateOf(cardOrder) }
+ var currentVisibleCards by remember(visibleCards) { mutableStateOf(visibleCards) }
+
+ // Update local state when props change (e.g., after reset)
+ LaunchedEffect(cardOrder, visibleCards) {
+ reorderedList = cardOrder
+ currentVisibleCards = visibleCards
+ }
+
+ val hapticFeedback = LocalHapticFeedback.current
+ val scope = rememberCoroutineScope()
+ val listState = rememberLazyListState()
+
+ // Dragging state
+ var draggedItem by remember { mutableStateOf(null) }
+ var draggedIndex by remember { mutableStateOf(-1) }
+ var dragOffset by remember { mutableStateOf(0f) }
+ val density = LocalDensity.current
+
+ fun onMove(
+ fromIndex: Int,
+ toIndex: Int,
+ ) {
+ if (fromIndex != toIndex && fromIndex >= 0 && toIndex >= 0 &&
+ fromIndex < reorderedList.size && toIndex < reorderedList.size
+ ) {
+ val newList = reorderedList.toMutableList()
+ val item = newList.removeAt(fromIndex)
+ newList.add(toIndex, item)
+ reorderedList = newList
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
+ }
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = {
+ if (reorderedList != cardOrder) {
+ onReorderCards(reorderedList)
+ }
+ onDismiss()
+ },
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surface,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ dragHandle = {
+ Surface(
+ modifier = Modifier.padding(vertical = 12.dp),
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
+ shape = RoundedCornerShape(16.dp),
+ ) {
+ Box(
+ modifier = Modifier.size(width = 48.dp, height = 4.dp),
+ )
+ }
+ },
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(0.8f),
+ ) {
+ // Header with reset button
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.dashboard_items),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ TextButton(
+ onClick = {
+ val defaultOrder =
+ listOf(
+ CardGroup.ClashMode,
+ CardGroup.UploadTraffic,
+ CardGroup.DownloadTraffic,
+ CardGroup.Debug,
+ CardGroup.Connections,
+ CardGroup.SystemProxy,
+ CardGroup.Profiles,
+ CardGroup.Groups,
+ )
+ val allCardsEnabled =
+ setOf(
+ CardGroup.ClashMode,
+ CardGroup.UploadTraffic,
+ CardGroup.DownloadTraffic,
+ CardGroup.Debug,
+ CardGroup.Connections,
+ CardGroup.SystemProxy,
+ CardGroup.Profiles,
+ CardGroup.Groups,
+ )
+ reorderedList = defaultOrder
+ currentVisibleCards = allCardsEnabled
+ onResetOrder()
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ },
+ ) {
+ Icon(
+ imageVector = Icons.Default.RestartAlt,
+ contentDescription = stringResource(R.string.reset_order),
+ modifier = Modifier.size(20.dp),
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(stringResource(R.string.reset))
+ }
+ }
+
+ // Instruction text
+ Text(
+ text = stringResource(R.string.drag_handle_to_reorder),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier =
+ Modifier
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 12.dp),
+ )
+
+ // Reorderable list
+ LazyColumn(
+ state = listState,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ itemsIndexed(
+ items = reorderedList,
+ key = { _, item -> item },
+ ) { index, cardGroup ->
+ val isVisible = currentVisibleCards.contains(cardGroup)
+ val isDragging = draggedIndex == index
+
+ DashboardItemCard(
+ cardGroup = cardGroup,
+ isVisible = isVisible,
+ isDragging = isDragging,
+ dragOffset = if (isDragging) dragOffset else 0f,
+ onToggleVisibility = {
+ currentVisibleCards =
+ if (currentVisibleCards.contains(cardGroup)) {
+ currentVisibleCards - cardGroup
+ } else {
+ currentVisibleCards + cardGroup
+ }
+ onToggleCard(cardGroup)
+ },
+ onDragStart = {
+ draggedItem = cardGroup
+ draggedIndex = index
+ dragOffset = 0f
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ },
+ onDrag = { delta ->
+ if (draggedIndex == index) {
+ dragOffset += delta
+
+ // Calculate target index based on drag offset
+ val itemHeight = with(density) { 80.dp.toPx() }
+ val threshold = itemHeight * 0.5f
+
+ when {
+ dragOffset < -threshold && draggedIndex > 0 -> {
+ // Moving up
+ onMove(draggedIndex, draggedIndex - 1)
+ draggedIndex -= 1
+ dragOffset += itemHeight
+ }
+
+ dragOffset > threshold && draggedIndex < reorderedList.size - 1 -> {
+ // Moving down
+ onMove(draggedIndex, draggedIndex + 1)
+ draggedIndex += 1
+ dragOffset -= itemHeight
+ }
+ }
+ }
+ },
+ onDragEnd = {
+ if (reorderedList != cardOrder) {
+ onReorderCards(reorderedList)
+ }
+ draggedItem = null
+ draggedIndex = -1
+ dragOffset = 0f
+ },
+ modifier =
+ Modifier.animateItemPlacement(
+ animationSpec =
+ spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessLow,
+ ),
+ ),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun DashboardItemCard(
+ cardGroup: CardGroup,
+ isVisible: Boolean,
+ isDragging: Boolean,
+ dragOffset: Float,
+ onToggleVisibility: () -> Unit,
+ onDragStart: () -> Unit,
+ onDrag: (Float) -> Unit,
+ onDragEnd: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val offsetY = remember { mutableStateOf(0f) }
+
+ LaunchedEffect(dragOffset) {
+ offsetY.value = dragOffset
+ }
+
+ val cardElevation by animateDpAsState(
+ targetValue = if (isDragging) 6.dp else 1.dp,
+ animationSpec = tween(durationMillis = 300),
+ label = "elevation",
+ )
+
+ Card(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .offset(y = with(LocalDensity.current) { offsetY.value.toDp() })
+ .zIndex(if (isDragging) 1f else 0f)
+ .clip(RoundedCornerShape(12.dp)),
+ elevation =
+ CardDefaults.cardElevation(
+ defaultElevation = cardElevation,
+ ),
+ colors =
+ CardDefaults.cardColors(
+ containerColor =
+ if (isDragging) {
+ MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)
+ } else {
+ MaterialTheme.colorScheme.surface
+ },
+ ),
+ border =
+ BorderStroke(
+ width = 1.dp,
+ color =
+ if (isVisible) {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
+ } else {
+ MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)
+ },
+ ),
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Drag handle
+ val draggableState =
+ rememberDraggableState { delta ->
+ onDrag(delta)
+ }
+
+ Icon(
+ imageVector = Icons.Default.DragHandle,
+ contentDescription = stringResource(R.string.drag_to_reorder),
+ modifier =
+ Modifier
+ .size(24.dp)
+ .draggable(
+ state = draggableState,
+ orientation = Orientation.Vertical,
+ onDragStarted = { onDragStart() },
+ onDragStopped = { onDragEnd() },
+ )
+ .padding(4.dp),
+ tint =
+ if (isDragging) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+
+ // Card icon
+ Icon(
+ imageVector =
+ when (cardGroup) {
+ CardGroup.Debug -> Icons.Outlined.BugReport
+ CardGroup.Connections -> Icons.Outlined.Cable
+ CardGroup.UploadTraffic -> Icons.Outlined.Upload
+ CardGroup.DownloadTraffic -> Icons.Outlined.Download
+ CardGroup.ClashMode -> Icons.Outlined.Route
+ CardGroup.SystemProxy -> Icons.Outlined.SettingsEthernet
+ CardGroup.Profiles -> Icons.Outlined.Person
+ CardGroup.Groups -> Icons.Outlined.Folder
+ },
+ contentDescription = null,
+ modifier =
+ Modifier
+ .size(24.dp)
+ .padding(horizontal = 4.dp),
+ tint =
+ if (isVisible) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+
+ // Card info
+ Column(
+ modifier =
+ Modifier
+ .weight(1f)
+ .padding(horizontal = 8.dp),
+ ) {
+ Text(
+ text =
+ when (cardGroup) {
+ CardGroup.Debug -> stringResource(R.string.title_debug)
+ CardGroup.Connections -> stringResource(R.string.title_connections)
+ CardGroup.UploadTraffic -> stringResource(R.string.upload)
+ CardGroup.DownloadTraffic -> stringResource(R.string.download)
+ CardGroup.ClashMode -> stringResource(R.string.clash_mode)
+ CardGroup.SystemProxy -> stringResource(R.string.system_proxy)
+ CardGroup.Profiles -> stringResource(R.string.title_configuration)
+ CardGroup.Groups -> stringResource(R.string.title_groups)
+ },
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+
+ // Visibility toggle - Profiles card cannot be disabled
+ Switch(
+ checked = isVisible,
+ onCheckedChange = { onToggleVisibility() },
+ enabled = cardGroup != CardGroup.Profiles, // Disable switch for Profiles card
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt
new file mode 100644
index 0000000..9ef36ff
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt
@@ -0,0 +1,745 @@
+package io.nekohasekai.sfa.compose.screen.dashboard
+
+import androidx.lifecycle.viewModelScope
+import io.nekohasekai.libbox.Libbox
+import io.nekohasekai.libbox.OutboundGroup
+import io.nekohasekai.libbox.StatusMessage
+import io.nekohasekai.sfa.bg.BoxService
+import io.nekohasekai.sfa.compose.base.BaseViewModel
+import io.nekohasekai.sfa.compose.base.UiEvent
+import io.nekohasekai.sfa.constant.Status
+import io.nekohasekai.sfa.database.Profile
+import io.nekohasekai.sfa.database.ProfileManager
+import io.nekohasekai.sfa.database.Settings
+import io.nekohasekai.sfa.database.TypedProfile
+import io.nekohasekai.sfa.utils.CommandClient
+import io.nekohasekai.sfa.utils.HTTPClient
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.json.JSONArray
+import org.json.JSONException
+import java.io.File
+import java.util.Collections
+import java.util.Date
+
+enum class CardGroup {
+ ClashMode,
+ UploadTraffic,
+ DownloadTraffic,
+ Debug,
+ Connections,
+ SystemProxy,
+ Profiles,
+ Groups,
+}
+
+enum class CardWidth {
+ Half,
+ Full,
+}
+
+data class DashboardUiState(
+ val serviceStatus: Status = Status.Stopped,
+ val profiles: List = emptyList(),
+ val selectedProfileId: Long = -1L,
+ val selectedProfileName: String? = null,
+ val isLoading: Boolean = false,
+ val hasGroups: Boolean = false,
+ val deprecatedNotes: List = emptyList(),
+ val showDeprecatedDialog: Boolean = false,
+ val showAddProfileSheet: Boolean = false,
+ val updatingProfileId: Long? = null,
+ val updatedProfileId: Long? = null,
+ // Status
+ val memory: String = "",
+ val goroutines: String = "",
+ val isStatusVisible: Boolean = false,
+ // Traffic
+ val trafficVisible: Boolean = false,
+ val connectionsIn: String = "0",
+ val connectionsOut: String = "0",
+ val uplink: String = "0 B/s",
+ val downlink: String = "0 B/s",
+ val uplinkTotal: String = "0 B",
+ val downlinkTotal: String = "0 B",
+ val uplinkHistory: List = List(30) { 0f },
+ val downlinkHistory: List = List(30) { 0f },
+ // Clash Mode
+ val clashModeVisible: Boolean = false,
+ val clashModes: List = emptyList(),
+ val selectedClashMode: String = "",
+ // System Proxy
+ val systemProxyVisible: Boolean = false,
+ val systemProxyEnabled: Boolean = false,
+ val systemProxySwitching: Boolean = false,
+ // Card visibility settings
+ val visibleCards: Set =
+ setOf(
+ CardGroup.ClashMode,
+ CardGroup.UploadTraffic,
+ CardGroup.DownloadTraffic,
+ CardGroup.Debug,
+ CardGroup.Connections,
+ CardGroup.SystemProxy,
+ CardGroup.Profiles,
+ ),
+ val cardOrder: List =
+ listOf(
+ CardGroup.UploadTraffic,
+ CardGroup.DownloadTraffic,
+ CardGroup.Debug,
+ CardGroup.Connections,
+ CardGroup.SystemProxy,
+ CardGroup.ClashMode,
+ CardGroup.Profiles,
+ CardGroup.Groups,
+ ),
+ val cardWidths: Map =
+ mapOf(
+ CardGroup.ClashMode to CardWidth.Full,
+ CardGroup.UploadTraffic to CardWidth.Half,
+ CardGroup.DownloadTraffic to CardWidth.Half,
+ CardGroup.Debug to CardWidth.Half,
+ CardGroup.Connections to CardWidth.Half,
+ CardGroup.SystemProxy to CardWidth.Full,
+ CardGroup.Profiles to CardWidth.Full,
+ CardGroup.Groups to CardWidth.Full,
+ ),
+ val showCardSettingsDialog: Boolean = false,
+) {
+ data class DeprecatedNote(
+ val message: String,
+ val migrationLink: String?,
+ )
+}
+
+// DashboardViewModel now only uses UiEvent for all events
+// No need for DashboardEvent anymore as all events are handled globally
+
+class DashboardViewModel : BaseViewModel(), CommandClient.Handler {
+ private val _serviceStatus = MutableStateFlow(Status.Stopped)
+ val serviceStatus: StateFlow = _serviceStatus.asStateFlow()
+
+ internal val commandClient =
+ CommandClient(
+ viewModelScope,
+ listOf(
+ CommandClient.ConnectionType.Status,
+ CommandClient.ConnectionType.ClashMode,
+ CommandClient.ConnectionType.Groups,
+ ),
+ this,
+ )
+
+ override fun createInitialState(): DashboardUiState {
+ val savedOrder = loadItemOrder()
+ val disabledItems = loadDisabledItems()
+
+ // Calculate visible items (all items minus disabled)
+ val allItems = CardGroup.values().toSet()
+ // Check if this is a first-time user (no saved order means never configured)
+ val isFirstTimeUser = Settings.dashboardItemOrder.isBlank()
+ val actualDisabledItems =
+ if (isFirstTimeUser && Settings.dashboardDisabledItems.isEmpty()) {
+ // First time user - Groups disabled by default
+ setOf(CardGroup.Groups)
+ } else {
+ // User has configured settings, respect their choices
+ disabledItems
+ }
+ val visibleCards = allItems - actualDisabledItems
+
+ return DashboardUiState(
+ cardOrder = savedOrder,
+ visibleCards = visibleCards,
+ )
+ }
+
+ init {
+ loadProfiles()
+ ProfileManager.registerCallback(::onProfilesChanged)
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ ProfileManager.unregisterCallback(::onProfilesChanged)
+ commandClient.disconnect()
+ }
+
+ private fun onProfilesChanged() {
+ loadProfiles()
+ }
+
+ private fun loadProfiles() {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val profiles = ProfileManager.list()
+ val selectedId = Settings.selectedProfile
+
+ withContext(Dispatchers.Main) {
+ updateState {
+ copy(
+ profiles = profiles,
+ selectedProfileId = selectedId,
+ selectedProfileName = profiles.find { it.id == selectedId }?.name,
+ )
+ }
+ }
+ } catch (e: Exception) {
+ sendError(e)
+ }
+ }
+ }
+
+ private fun checkDeprecatedNotes() {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ // Check if deprecated warnings are disabled
+ if (Settings.disableDeprecatedWarnings) {
+ return@launch
+ }
+
+ val notes = Libbox.newStandaloneCommandClient().deprecatedNotes
+ if (notes.hasNext()) {
+ val notesList = mutableListOf()
+ while (notes.hasNext()) {
+ val note = notes.next()
+ notesList.add(
+ DashboardUiState.DeprecatedNote(
+ message = note.message(),
+ migrationLink = note.migrationLink,
+ ),
+ )
+ }
+ withContext(Dispatchers.Main) {
+ updateState {
+ copy(
+ deprecatedNotes = notesList,
+ showDeprecatedDialog = notesList.isNotEmpty(),
+ )
+ }
+ }
+ }
+ } catch (e: Exception) {
+ sendError(e)
+ }
+ }
+ }
+
+ fun toggleService() {
+ when (currentState.serviceStatus) {
+ Status.Starting, Status.Started -> stopService()
+ Status.Stopped -> sendGlobalEvent(UiEvent.RequestStartService)
+ else -> { /* Ignore while transitioning */ }
+ }
+ }
+
+ private fun stopService() {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ BoxService.stop()
+ // Status will be updated via updateServiceStatus callback
+ } catch (e: Exception) {
+ sendError(e)
+ }
+ }
+ }
+
+ fun dismissDeprecatedNote() {
+ val notes = currentState.deprecatedNotes
+ if (notes.isNotEmpty()) {
+ updateState {
+ copy(
+ deprecatedNotes = notes.drop(1),
+ showDeprecatedDialog = notes.size > 1,
+ )
+ }
+ }
+ }
+
+ fun selectProfile(profileId: Long) {
+ if (currentState.isLoading) return
+
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ updateState { copy(isLoading = true) }
+ val profile = ProfileManager.get(profileId) ?: return@launch
+
+ Settings.selectedProfile = profileId
+
+ // Check if service is running
+ if (_serviceStatus.value == Status.Started) {
+ val restart = Settings.rebuildServiceMode()
+ if (restart) {
+ // Need full restart
+ BoxService.stop()
+ sendGlobalEvent(UiEvent.RequestReconnectService)
+ for (i in 0 until 30) {
+ if (_serviceStatus.value == Status.Stopped) {
+ break
+ }
+ delay(100L)
+ }
+ sendGlobalEvent(UiEvent.RequestStartService)
+ } else {
+ // Just reload
+ Libbox.newStandaloneCommandClient().serviceReload()
+ }
+ }
+
+ withContext(Dispatchers.Main) {
+ loadProfiles()
+ }
+ } catch (e: Exception) {
+ sendError(e)
+ } finally {
+ updateState { copy(isLoading = false) }
+ }
+ }
+ }
+
+ fun editProfile(profile: Profile) {
+ sendGlobalEvent(UiEvent.EditProfile(profile.id))
+ }
+
+ fun deleteProfile(profile: Profile) {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ // Update UI immediately for responsiveness
+ withContext(Dispatchers.Main) {
+ updateState {
+ copy(
+ profiles = profiles.filter { p -> p.id != profile.id },
+ )
+ }
+ }
+ // Then delete from database
+ ProfileManager.delete(profile)
+ } catch (e: Exception) {
+ // Reload profiles if deletion fails
+ loadProfiles()
+ sendError(e)
+ }
+ }
+ }
+
+ fun shareProfile(profile: Profile) {
+ // Handled directly in ProfilesCard
+ }
+
+ fun shareProfileURL(profile: Profile) {
+ // Handled directly in ProfilesCard
+ }
+
+ fun updateProfile(profile: Profile) {
+ if (profile.typed.type != TypedProfile.Type.Remote) return
+
+ viewModelScope.launch(Dispatchers.IO) {
+ // Set updating state
+ withContext(Dispatchers.Main) {
+ updateState { copy(updatingProfileId = profile.id) }
+ }
+
+ try {
+ // Fetch remote config
+ val content = HTTPClient().use { it.getString(profile.typed.remoteURL) }
+ Libbox.checkConfig(content)
+
+ // Check if content changed
+ val file = File(profile.typed.path)
+ var contentChanged = false
+ if (!file.exists() || file.readText() != content) {
+ file.writeText(content)
+ contentChanged = true
+ }
+
+ // Update last updated time
+ profile.typed.lastUpdated = Date()
+ ProfileManager.update(profile)
+
+ // Reload profiles
+ loadProfiles()
+
+ // Show success state
+ withContext(Dispatchers.Main) {
+ updateState { copy(updatingProfileId = null, updatedProfileId = profile.id) }
+ }
+
+ // Clear success state after delay
+ withContext(Dispatchers.Main) {
+ delay(1500)
+ updateState { copy(updatedProfileId = null) }
+ }
+
+ // Restart service if this is the selected profile and content changed
+ if (contentChanged && profile.id == Settings.selectedProfile) {
+ withContext(Dispatchers.Main) {
+ sendGlobalEvent(UiEvent.RequestReconnectService)
+ }
+ }
+ } catch (e: Exception) {
+ sendErrorMessage("Failed to update profile: ${e.message}")
+ // Clear updating state on error
+ withContext(Dispatchers.Main) {
+ updateState { copy(updatingProfileId = null) }
+ }
+ }
+ }
+ }
+
+ fun moveProfile(
+ from: Int,
+ to: Int,
+ ) {
+ val currentProfiles = currentState.profiles.toMutableList()
+
+ if (from < to) {
+ for (i in from until to) {
+ Collections.swap(currentProfiles, i, i + 1)
+ }
+ } else {
+ for (i in from downTo to + 1) {
+ Collections.swap(currentProfiles, i, i - 1)
+ }
+ }
+
+ // Update UI immediately
+ updateState { copy(profiles = currentProfiles) }
+
+ // Update user order in database
+ viewModelScope.launch(Dispatchers.IO) {
+ currentProfiles.forEachIndexed { index, profile ->
+ profile.userOrder = index.toLong()
+ }
+ ProfileManager.update(currentProfiles)
+ }
+ }
+
+ fun showAddProfileSheet() {
+ updateState { copy(showAddProfileSheet = true) }
+ }
+
+ fun hideAddProfileSheet() {
+ updateState { copy(showAddProfileSheet = false) }
+ }
+
+ fun updateServiceStatus(status: Status) {
+ viewModelScope.launch {
+ _serviceStatus.emit(status)
+ updateState {
+ copy(
+ serviceStatus = status,
+ isStatusVisible = status == Status.Starting || status == Status.Started,
+ )
+ }
+ handleServiceStatusChange(status)
+ }
+ }
+
+ private fun handleServiceStatusChange(status: Status) {
+ when (status) {
+ Status.Started -> {
+ checkDeprecatedNotes()
+ commandClient.connect()
+ reloadSystemProxyStatus()
+ }
+
+ Status.Stopped -> {
+ commandClient.disconnect()
+ updateState {
+ copy(
+ hasGroups = false,
+ clashModeVisible = false,
+ systemProxyVisible = false,
+ trafficVisible = false,
+ memory = "",
+ goroutines = "",
+ connectionsIn = "0",
+ connectionsOut = "0",
+ uplink = "0 B/s",
+ downlink = "0 B/s",
+ uplinkTotal = "0 B",
+ downlinkTotal = "0 B",
+ uplinkHistory = List(30) { 0f },
+ downlinkHistory = List(30) { 0f },
+ )
+ }
+ }
+
+ else -> {}
+ }
+ }
+
+ private fun reloadSystemProxyStatus() {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val status = Libbox.newStandaloneCommandClient().systemProxyStatus
+ withContext(Dispatchers.Main) {
+ updateState {
+ copy(
+ systemProxyVisible = status.available,
+ systemProxyEnabled = status.enabled,
+ )
+ }
+ }
+ } catch (e: Exception) {
+ // Ignore errors
+ }
+ }
+ }
+
+ fun toggleSystemProxy(enabled: Boolean) {
+ if (currentState.systemProxySwitching) return
+
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ updateState { copy(systemProxySwitching = true) }
+ Settings.systemProxyEnabled = enabled
+ Libbox.newStandaloneCommandClient().setSystemProxyEnabled(enabled)
+ delay(1000L)
+ withContext(Dispatchers.Main) {
+ updateState {
+ copy(
+ systemProxyEnabled = enabled,
+ systemProxySwitching = false,
+ )
+ }
+ }
+ } catch (e: Exception) {
+ sendError(e)
+ updateState { copy(systemProxySwitching = false) }
+ }
+ }
+ }
+
+ fun selectClashMode(mode: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ Libbox.newStandaloneCommandClient().setClashMode(mode)
+ // Update UI state directly without reconnecting
+ withContext(Dispatchers.Main) {
+ updateState {
+ copy(selectedClashMode = mode)
+ }
+ }
+ } catch (e: Exception) {
+ sendError(e)
+ }
+ }
+ }
+
+ // CommandClient.Handler implementation
+ override fun onConnected() {
+ viewModelScope.launch(Dispatchers.Main) {
+ updateState { copy(isStatusVisible = true) }
+ }
+ }
+
+ override fun onDisconnected() {
+ viewModelScope.launch(Dispatchers.Main) {
+ updateState {
+ copy(
+ memory = "",
+ goroutines = "",
+ isStatusVisible = false,
+ )
+ }
+ }
+ }
+
+ override fun updateStatus(status: StatusMessage) {
+ viewModelScope.launch(Dispatchers.Main) {
+ updateState {
+ // Update history by adding new values and removing old ones
+ val newUplinkHistory = (uplinkHistory.drop(1) + status.uplink.toFloat())
+ val newDownlinkHistory = (downlinkHistory.drop(1) + status.downlink.toFloat())
+
+ // Format the total values
+ val newUplinkTotal = Libbox.formatBytes(status.uplinkTotal)
+ val newDownlinkTotal = Libbox.formatBytes(status.downlinkTotal)
+
+ copy(
+ memory = Libbox.formatBytes(status.memory),
+ goroutines = status.goroutines.toString(),
+ // Only set trafficVisible to true, never back to false from status updates
+ trafficVisible = if (status.trafficAvailable) true else trafficVisible,
+ connectionsIn = status.connectionsIn.toString(),
+ connectionsOut = status.connectionsOut.toString(),
+ uplink = "${Libbox.formatBytes(status.uplink)}/s",
+ downlink = "${Libbox.formatBytes(status.downlink)}/s",
+ // Only update total values if they've actually changed
+ uplinkTotal = if (newUplinkTotal != uplinkTotal) newUplinkTotal else uplinkTotal,
+ downlinkTotal = if (newDownlinkTotal != downlinkTotal) newDownlinkTotal else downlinkTotal,
+ uplinkHistory = newUplinkHistory,
+ downlinkHistory = newDownlinkHistory,
+ )
+ }
+ }
+ }
+
+ override fun initializeClashMode(
+ modeList: List,
+ currentMode: String,
+ ) {
+ viewModelScope.launch(Dispatchers.Main) {
+ updateState {
+ copy(
+ clashModeVisible = modeList.size > 1,
+ clashModes = modeList,
+ selectedClashMode = currentMode,
+ )
+ }
+ }
+ }
+
+ override fun updateClashMode(newMode: String) {
+ viewModelScope.launch(Dispatchers.Main) {
+ updateState {
+ copy(selectedClashMode = newMode)
+ }
+ }
+ }
+
+ override fun updateGroups(newGroups: MutableList) {
+ viewModelScope.launch(Dispatchers.Main) {
+ val hasGroups = newGroups.isNotEmpty()
+ updateState {
+ copy(hasGroups = hasGroups)
+ }
+ }
+ }
+
+ fun toggleCardSettingsDialog() {
+ updateState {
+ copy(showCardSettingsDialog = !showCardSettingsDialog)
+ }
+ }
+
+ fun toggleCardVisibility(cardGroup: CardGroup) {
+ // Profiles card cannot be disabled
+ if (cardGroup == CardGroup.Profiles) {
+ return
+ }
+
+ updateState {
+ val newVisibleCards =
+ if (visibleCards.contains(cardGroup)) {
+ visibleCards - cardGroup
+ } else {
+ visibleCards + cardGroup
+ }
+ // Save disabled items to settings
+ saveDisabledItems(newVisibleCards)
+ // Also save the current order if not already saved (indicates user has configured dashboard)
+ if (Settings.dashboardItemOrder.isBlank()) {
+ saveItemOrder(cardOrder)
+ }
+ copy(visibleCards = newVisibleCards)
+ }
+ }
+
+ fun closeCardSettingsDialog() {
+ updateState {
+ copy(showCardSettingsDialog = false)
+ }
+ }
+
+ fun reorderCards(newOrder: List) {
+ updateState {
+ saveItemOrder(newOrder)
+ copy(cardOrder = newOrder)
+ }
+ }
+
+ fun resetCardOrder() {
+ // Clear saved settings to restore defaults
+ Settings.dashboardItemOrder = ""
+ Settings.dashboardDisabledItems = emptySet()
+
+ updateState {
+ copy(
+ cardOrder = getDefaultItemOrder(),
+ visibleCards = CardGroup.values().toSet(),
+ )
+ }
+ }
+
+ // Helper functions for serialization
+ private fun getDefaultItemOrder() =
+ listOf(
+ CardGroup.UploadTraffic,
+ CardGroup.DownloadTraffic,
+ CardGroup.Debug,
+ CardGroup.Connections,
+ CardGroup.SystemProxy,
+ CardGroup.ClashMode,
+ CardGroup.Profiles,
+ CardGroup.Groups,
+ )
+
+ private fun loadItemOrder(): List {
+ val savedOrder = Settings.dashboardItemOrder
+ if (savedOrder.isBlank()) {
+ return getDefaultItemOrder()
+ }
+
+ return try {
+ val jsonArray = JSONArray(savedOrder)
+ val order = mutableListOf()
+
+ for (i in 0 until jsonArray.length()) {
+ val itemName = jsonArray.getString(i)
+ stringToCardGroup(itemName)?.let { order.add(it) }
+ }
+
+ // Add any new items that aren't in the saved order
+ val allItems = CardGroup.values().toSet()
+ val savedItems = order.toSet()
+ val newItems = allItems - savedItems
+
+ order.addAll(newItems)
+ order
+ } catch (e: JSONException) {
+ getDefaultItemOrder()
+ }
+ }
+
+ private fun saveItemOrder(order: List) {
+ val jsonArray = JSONArray()
+ order.forEach { item ->
+ jsonArray.put(cardGroupToString(item))
+ }
+ Settings.dashboardItemOrder = jsonArray.toString()
+ }
+
+ private fun loadDisabledItems(): Set {
+ val savedDisabled = Settings.dashboardDisabledItems
+ // Filter out Profiles from disabled items (it cannot be disabled)
+ return savedDisabled.mapNotNull { stringToCardGroup(it) }
+ .filter { it != CardGroup.Profiles }
+ .toSet()
+ }
+
+ private fun saveDisabledItems(visibleCards: Set) {
+ val allItems = CardGroup.values().toSet()
+ // Always ensure Profiles is in visibleCards (cannot be disabled)
+ val actualVisibleCards = visibleCards + CardGroup.Profiles
+ val disabledItems = allItems - actualVisibleCards
+ Settings.dashboardDisabledItems = disabledItems.map { cardGroupToString(it) }.toSet()
+ }
+
+ private fun cardGroupToString(card: CardGroup): String = card.name
+
+ private fun stringToCardGroup(name: String): CardGroup? {
+ return try {
+ CardGroup.valueOf(name)
+ } catch (e: IllegalArgumentException) {
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DebugCard.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DebugCard.kt
new file mode 100644
index 0000000..3361370
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DebugCard.kt
@@ -0,0 +1,98 @@
+package io.nekohasekai.sfa.compose.screen.dashboard
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.BugReport
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import io.nekohasekai.sfa.R
+
+@Composable
+fun DebugCard(
+ memory: String,
+ goroutines: String,
+ modifier: Modifier = Modifier,
+) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.BugReport,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(R.string.title_debug),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Memory item
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.memory),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = memory.ifEmpty { stringResource(R.string.loading) },
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Goroutines item
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.goroutines),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = goroutines.ifEmpty { stringResource(R.string.loading) },
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DownloadTrafficCard.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DownloadTrafficCard.kt
new file mode 100644
index 0000000..dca3f21
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DownloadTrafficCard.kt
@@ -0,0 +1,84 @@
+package io.nekohasekai.sfa.compose.screen.dashboard
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Download
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.compose.LineChart
+
+@Composable
+fun DownloadTrafficCard(
+ downlink: String,
+ downlinkTotal: String,
+ downlinkHistory: List,
+ modifier: Modifier = Modifier,
+) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Download,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.secondary,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(R.string.download),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = downlink,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Text(
+ text = downlinkTotal,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ LineChart(
+ data = downlinkHistory,
+ lineColor = MaterialTheme.colorScheme.secondary,
+ animate = false,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt
new file mode 100644
index 0000000..e915449
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt
@@ -0,0 +1,695 @@
+package io.nekohasekai.sfa.compose.screen.dashboard
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.FolderOpen
+import androidx.compose.material.icons.filled.Speed
+import androidx.compose.material.icons.filled.UnfoldLess
+import androidx.compose.material.icons.filled.UnfoldMore
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewmodel.compose.viewModel
+import io.nekohasekai.libbox.Libbox
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
+import io.nekohasekai.sfa.constant.Status
+import io.nekohasekai.sfa.ui.dashboard.Group
+import io.nekohasekai.sfa.ui.dashboard.GroupItem
+import io.nekohasekai.sfa.utils.CommandClient
+
+@Composable
+fun GroupsCard(
+ serviceStatus: Status,
+ isCardMode: Boolean = true,
+ commandClient: CommandClient? = null,
+ modifier: Modifier = Modifier,
+) {
+ val viewModel: GroupsViewModel =
+ viewModel(
+ factory =
+ object : ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ @Suppress("UNCHECKED_CAST")
+ return GroupsViewModel(commandClient) as T
+ }
+ },
+ )
+ val snackbarHostState = remember { SnackbarHostState() }
+ val uiState by viewModel.uiState.collectAsState()
+
+ // Stable callbacks to prevent recomposition - use remember with viewModel as key
+ val onToggleExpanded =
+ remember(viewModel) {
+ { groupTag: String -> viewModel.toggleGroupExpand(groupTag) }
+ }
+ val onItemSelected =
+ remember(viewModel) {
+ { groupTag: String, itemTag: String -> viewModel.selectGroupItem(groupTag, itemTag) }
+ }
+ val onUrlTest =
+ remember(viewModel) {
+ { groupTag: String -> viewModel.urlTest(groupTag) }
+ }
+
+ // Only update service status when it actually changes
+ LaunchedEffect(serviceStatus) {
+ viewModel.updateServiceStatus(serviceStatus)
+ }
+
+ // Show snackbar when needed
+ LaunchedEffect(uiState.showCloseConnectionsSnackbar) {
+ if (uiState.showCloseConnectionsSnackbar) {
+ val result =
+ snackbarHostState.showSnackbar(
+ message = "Close all connections?",
+ actionLabel = "Close",
+ duration = androidx.compose.material3.SnackbarDuration.Indefinite,
+ withDismissAction = true,
+ )
+ when (result) {
+ androidx.compose.material3.SnackbarResult.ActionPerformed -> {
+ viewModel.closeConnections()
+ }
+
+ androidx.compose.material3.SnackbarResult.Dismissed -> {
+ viewModel.dismissCloseConnectionsSnackbar()
+ }
+ }
+ }
+ }
+
+ if (isCardMode) {
+ // Card mode - wrapped in a card with header
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ GroupsCardContent(
+ uiState = uiState,
+ isCardMode = true,
+ onToggleAllGroups = { viewModel.toggleAllGroups() },
+ onToggleExpanded = onToggleExpanded,
+ onItemSelected = onItemSelected,
+ onUrlTest = onUrlTest,
+ )
+ }
+ } else {
+ // Standalone mode - direct content without card wrapper
+ GroupsCardContent(
+ uiState = uiState,
+ isCardMode = false,
+ onToggleAllGroups = { viewModel.toggleAllGroups() },
+ onToggleExpanded = onToggleExpanded,
+ onItemSelected = onItemSelected,
+ onUrlTest = onUrlTest,
+ modifier = modifier,
+ )
+ }
+}
+
+@Composable
+private fun GroupsCardContent(
+ uiState: io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsUiState,
+ isCardMode: Boolean,
+ onToggleAllGroups: () -> Unit,
+ onToggleExpanded: (String) -> Unit,
+ onItemSelected: (String, String) -> Unit,
+ onUrlTest: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier.fillMaxWidth()) {
+ if (isCardMode) {
+ // Card header with title and collapse/expand all button
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.FolderOpen,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ text = stringResource(R.string.title_groups),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+
+ // Collapse/Expand all button in the top right
+ if (uiState.groups.isNotEmpty()) {
+ val allCollapsed = uiState.expandedGroups.isEmpty()
+ IconButton(
+ onClick = onToggleAllGroups,
+ modifier = Modifier.size(40.dp),
+ ) {
+ Icon(
+ imageVector =
+ if (allCollapsed) {
+ Icons.Default.UnfoldMore
+ } else {
+ Icons.Default.UnfoldLess
+ },
+ contentDescription = if (allCollapsed) "Expand All" else "Collapse All",
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f),
+ thickness = 1.dp,
+ )
+ }
+
+ // Groups content
+ if (uiState.isLoading) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(200.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+ } else if (uiState.groups.isEmpty()) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(100.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "No groups available",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ } else {
+ if (isCardMode) {
+ // In card mode, show groups directly without LazyColumn
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth(),
+ ) {
+ uiState.groups.forEachIndexed { index, group ->
+ // Add divider above each group (not for the first one in card mode)
+ if (index > 0) {
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f),
+ thickness = 1.dp,
+ )
+ }
+ ProxyGroupItem(
+ group = group,
+ isExpanded = uiState.expandedGroups.contains(group.tag),
+ onToggleExpanded = { onToggleExpanded(group.tag) },
+ onItemSelected = { itemTag -> onItemSelected(group.tag, itemTag) },
+ onUrlTest = { onUrlTest(group.tag) },
+ showCard = false,
+ )
+ }
+ }
+ } else {
+ // In standalone mode, use LazyColumn for scrolling
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding =
+ PaddingValues(
+ start = 16.dp,
+ end = 16.dp,
+ top = 8.dp,
+ bottom = 16.dp,
+ ),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(
+ items = uiState.groups,
+ key = { it.tag },
+ contentType = { "GroupCard" },
+ ) { group ->
+ ProxyGroupItem(
+ group = group,
+ isExpanded = uiState.expandedGroups.contains(group.tag),
+ onToggleExpanded = { onToggleExpanded(group.tag) },
+ onItemSelected = { itemTag -> onItemSelected(group.tag, itemTag) },
+ onUrlTest = { onUrlTest(group.tag) },
+ showCard = true,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ProxyGroupItem(
+ group: Group,
+ isExpanded: Boolean,
+ onToggleExpanded: () -> Unit,
+ onItemSelected: (String) -> Unit,
+ onUrlTest: () -> Unit,
+ showCard: Boolean,
+) {
+ val content = @Composable {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ // Header (clickable to expand/collapse)
+ Surface(
+ onClick = onToggleExpanded,
+ color = Color.Transparent,
+ ) {
+ ListItem(
+ headlineContent = {
+ Column {
+ Text(
+ text = group.tag,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = Libbox.proxyDisplayType(group.type),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ // Show selected item when collapsed
+ AnimatedVisibility(
+ visible = !isExpanded && group.selected.isNotEmpty(),
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = "•",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = group.selected,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+ },
+ trailingContent = {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // URL Test button
+ AnimatedVisibility(
+ visible = group.selectable,
+ enter = slideInVertically() + fadeIn(),
+ exit = slideOutVertically() + fadeOut(),
+ ) {
+ IconButton(
+ onClick = {
+ onUrlTest()
+ },
+ modifier = Modifier.size(40.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Speed,
+ contentDescription = stringResource(R.string.url_test),
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ // Expand/Collapse indicator
+ val rotationAngle by animateFloatAsState(
+ targetValue = if (isExpanded) 180f else 0f,
+ animationSpec = tween(300),
+ label = "ExpandIcon",
+ )
+
+ Icon(
+ imageVector = Icons.Default.ExpandMore,
+ contentDescription = if (isExpanded) "Collapse" else "Expand",
+ modifier =
+ Modifier
+ .size(24.dp)
+ .graphicsLayer { rotationZ = rotationAngle },
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ },
+ colors =
+ ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+ }
+
+ // Expandable content
+ AnimatedVisibility(
+ visible = isExpanded && group.items.isNotEmpty(),
+ enter =
+ expandVertically(animationSpec = tween(300)) +
+ fadeIn(
+ animationSpec =
+ tween(
+ 300,
+ ),
+ ),
+ exit =
+ shrinkVertically(animationSpec = tween(300)) +
+ fadeOut(
+ animationSpec =
+ tween(
+ 300,
+ ),
+ ),
+ ) {
+ Column {
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f),
+ thickness = 1.dp,
+ )
+
+ // Proxy Items
+ ProxyItemsList(
+ items = group.items,
+ selectedTag = group.selected,
+ isSelectable = group.selectable,
+ onItemSelected = onItemSelected,
+ )
+ }
+ }
+ }
+ }
+
+ if (showCard) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ content()
+ }
+ } else {
+ content()
+ }
+}
+
+@Composable
+private fun ProxyItemsList(
+ items: List,
+ selectedTag: String,
+ isSelectable: Boolean,
+ onItemSelected: (String) -> Unit,
+) {
+ val itemsPerRow = 2
+ val chunkedItems =
+ remember(items) {
+ items.chunked(itemsPerRow)
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ chunkedItems.forEach { rowItems ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ rowItems.forEach { item ->
+ key(item.tag) {
+ Box(
+ modifier = Modifier.weight(1f),
+ ) {
+ ProxyChip(
+ item = item,
+ isSelected = item.tag == selectedTag,
+ isSelectable = isSelectable,
+ onClick = { onItemSelected(item.tag) },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ }
+ repeat(itemsPerRow - rowItems.size) {
+ Box(modifier = Modifier.weight(1f))
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ProxyChip(
+ item: GroupItem,
+ isSelected: Boolean,
+ isSelectable: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ // Use simpler, faster animations
+ val animatedElevation by animateFloatAsState(
+ targetValue = if (isSelected) 6.dp.value else 1.dp.value,
+ animationSpec = tween(150),
+ label = "Elevation",
+ )
+
+ val surfaceModifier = modifier
+ val surfaceShape = RoundedCornerShape(8.dp)
+ val surfaceColor =
+ when {
+ isSelected -> MaterialTheme.colorScheme.primaryContainer
+ else -> MaterialTheme.colorScheme.surface
+ }
+ val surfaceBorder =
+ androidx.compose.foundation.BorderStroke(
+ width = if (isSelected) 2.dp else 1.dp,
+ color =
+ when {
+ isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
+ else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
+ },
+ )
+
+ val content: @Composable () -> Unit = {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ // First line: Name
+ Text(
+ text = item.tag,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
+ color =
+ if (isSelected) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ // Second line: Type on left, Latency on right
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Type
+ Text(
+ text = Libbox.proxyDisplayType(item.type),
+ style = MaterialTheme.typography.labelSmall,
+ color =
+ if (isSelected) {
+ MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ },
+ )
+
+ // Latency
+ AnimatedVisibility(
+ visible = item.urlTestTime > 0,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ ProxyLatencyBadge(
+ delay = item.urlTestDelay,
+ isSelected = isSelected,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (isSelectable) {
+ Surface(
+ onClick = onClick,
+ modifier = surfaceModifier,
+ shape = surfaceShape,
+ color = surfaceColor,
+ tonalElevation = animatedElevation.dp,
+ border = surfaceBorder,
+ content = content,
+ )
+ } else {
+ Surface(
+ modifier = surfaceModifier,
+ shape = surfaceShape,
+ color = surfaceColor,
+ tonalElevation = animatedElevation.dp,
+ border = surfaceBorder,
+ content = content,
+ )
+ }
+}
+
+@Composable
+private fun ProxyLatencyBadge(
+ delay: Int,
+ isSelected: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ // Direct color calculation without animation for better performance
+ val colorScheme = MaterialTheme.colorScheme
+ val latencyColor =
+ remember(delay, isSelected) {
+ when {
+ delay < 100 -> {
+ // Excellent - green/tertiary
+ if (isSelected) {
+ colorScheme.tertiary
+ } else {
+ colorScheme.tertiary.copy(alpha = 0.9f)
+ }
+ }
+
+ delay < 300 -> {
+ // Good - primary
+ if (isSelected) {
+ colorScheme.primary
+ } else {
+ colorScheme.primary.copy(alpha = 0.9f)
+ }
+ }
+
+ delay < 500 -> {
+ // Fair - secondary/warning
+ if (isSelected) {
+ colorScheme.secondary
+ } else {
+ colorScheme.secondary.copy(alpha = 0.9f)
+ }
+ }
+
+ else -> {
+ // Poor - error
+ if (isSelected) {
+ colorScheme.error
+ } else {
+ colorScheme.error.copy(alpha = 0.9f)
+ }
+ }
+ }
+ }
+
+ Text(
+ text = "${delay}ms",
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = latencyColor,
+ modifier = modifier,
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt
new file mode 100644
index 0000000..884b85b
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt
@@ -0,0 +1,877 @@
+package io.nekohasekai.sfa.compose.screen.dashboard
+
+import android.content.Intent
+import android.graphics.Bitmap
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ExperimentalFoundationApi
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.ExpandLess
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.IosShare
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.QrCode2
+import androidx.compose.material.icons.filled.QrCodeScanner
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material.icons.filled.Update
+import androidx.compose.material.icons.outlined.CreateNewFolder
+import androidx.compose.material.icons.outlined.Description
+import androidx.compose.material.icons.outlined.FileUpload
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.nekohasekai.libbox.Libbox
+import io.nekohasekai.libbox.ProfileContent
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.compose.NewProfileComposeActivity
+import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler
+import io.nekohasekai.sfa.compose.screen.configuration.QRCodeDialog
+import io.nekohasekai.sfa.compose.util.ProfileIcons
+import io.nekohasekai.sfa.compose.util.QRCodeGenerator
+import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
+import io.nekohasekai.sfa.database.Profile
+import io.nekohasekai.sfa.database.ProfileManager
+import io.nekohasekai.sfa.database.TypedProfile
+import io.nekohasekai.sfa.ktx.errorDialogBuilder
+import io.nekohasekai.sfa.ktx.shareProfile
+import io.nekohasekai.sfa.ui.profile.QRScanActivity
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import sh.calvin.reorderable.ReorderableItem
+import sh.calvin.reorderable.rememberReorderableLazyListState
+
+@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
+@Composable
+fun ProfilesCard(
+ profiles: List,
+ selectedProfileId: Long,
+ isLoading: Boolean,
+ showAddProfileSheet: Boolean,
+ updatingProfileId: Long? = null,
+ updatedProfileId: Long? = null,
+ onProfileSelected: (Long) -> Unit,
+ onProfileEdit: (Profile) -> Unit,
+ onProfileDelete: (Profile) -> Unit,
+ onProfileShare: (Profile) -> Unit,
+ onProfileShareURL: (Profile) -> Unit,
+ onProfileUpdate: (Profile) -> Unit,
+ onProfileMove: (Int, Int) -> Unit,
+ onShowAddProfileSheet: () -> Unit,
+ onHideAddProfileSheet: () -> Unit,
+ onImportFromFile: () -> Unit,
+ onScanQrCode: () -> Unit,
+ onCreateManually: () -> Unit,
+ shareQRCodeImage: suspend (Bitmap, String) -> Unit,
+ saveQRCodeToGallery: suspend (Bitmap, String) -> Unit,
+) {
+ val context = LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+
+ // Import handler
+ val importHandler = remember { ProfileImportHandler(context) }
+
+ // QR code dialog state
+ var showQRCodeDialog by remember { mutableStateOf(false) }
+ var qrCodeProfile by remember { mutableStateOf(null) }
+
+ // Activity result launchers
+ val newProfileLauncher =
+ rememberLauncherForActivityResult(
+ ActivityResultContracts.StartActivityForResult(),
+ ) { result ->
+ if (result.resultCode == android.app.Activity.RESULT_OK) {
+ val profileId = result.data?.getLongExtra(NewProfileComposeActivity.EXTRA_PROFILE_ID, -1L)
+ if (profileId != null && profileId != -1L) {
+ // Find the profile and open edit screen
+ coroutineScope.launch {
+ val profile =
+ withContext(Dispatchers.IO) {
+ ProfileManager.get(profileId)
+ }
+ profile?.let {
+ withContext(Dispatchers.Main) {
+ onProfileEdit(it)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ val importFromFileLauncher =
+ rememberLauncherForActivityResult(
+ ActivityResultContracts.GetContent(),
+ ) { uri ->
+ uri?.let {
+ coroutineScope.launch {
+ when (val result = importHandler.importFromUri(uri)) {
+ is ProfileImportHandler.ImportResult.Success -> {
+ // Profile imported successfully, open edit screen
+ withContext(Dispatchers.Main) {
+ onProfileEdit(result.profile)
+ }
+ }
+ is ProfileImportHandler.ImportResult.Error -> {
+ withContext(Dispatchers.Main) {
+ context.errorDialogBuilder(Exception(result.message)).show()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ val scanQrCodeLauncher =
+ rememberLauncherForActivityResult(
+ QRScanActivity.Contract(),
+ ) { result ->
+ result?.let { intent ->
+ val data = intent.dataString
+ if (data != null) {
+ coroutineScope.launch {
+ when (val parseResult = importHandler.parseQRCode(data)) {
+ is ProfileImportHandler.QRCodeParseResult.RemoteProfile -> {
+ withContext(Dispatchers.Main) {
+ val newProfileIntent =
+ Intent(context, NewProfileComposeActivity::class.java).apply {
+ putExtra(NewProfileComposeActivity.EXTRA_IMPORT_NAME, parseResult.name)
+ putExtra(NewProfileComposeActivity.EXTRA_IMPORT_URL, parseResult.url)
+ }
+ newProfileLauncher.launch(newProfileIntent)
+ }
+ }
+
+ is ProfileImportHandler.QRCodeParseResult.LocalProfile -> {
+ when (val importResult = importHandler.importFromQRCode(data)) {
+ is ProfileImportHandler.ImportResult.Success -> {
+ withContext(Dispatchers.Main) {
+ onProfileEdit(importResult.profile)
+ }
+ }
+
+ is ProfileImportHandler.ImportResult.Error -> {
+ withContext(Dispatchers.Main) {
+ context.errorDialogBuilder(Exception(importResult.message)).show()
+ }
+ }
+ }
+ }
+
+ is ProfileImportHandler.QRCodeParseResult.Error -> {
+ withContext(Dispatchers.Main) {
+ context.errorDialogBuilder(Exception(parseResult.message)).show()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Handle import events
+ LaunchedEffect(onImportFromFile, onScanQrCode) {
+ // These are just to trigger the launchers
+ }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ ) {
+ // Header with title and add button
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Description,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(R.string.title_configuration),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+
+ IconButton(
+ onClick = onShowAddProfileSheet,
+ modifier = Modifier.size(32.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = stringResource(R.string.add_profile),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ if (profiles.isEmpty()) {
+ Text(
+ text = stringResource(R.string.no_profiles),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(vertical = 16.dp),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ ProfileList(
+ profiles = profiles,
+ selectedProfileId = selectedProfileId,
+ isLoading = isLoading,
+ updatingProfileId = updatingProfileId,
+ updatedProfileId = updatedProfileId,
+ onProfileClick = { profile ->
+ if (profile.id != selectedProfileId) {
+ onProfileSelected(profile.id)
+ }
+ },
+ onEditProfile = onProfileEdit,
+ onDeleteProfile = onProfileDelete,
+ onShareProfile = { profile ->
+ coroutineScope.launch(Dispatchers.IO) {
+ try {
+ context.shareProfile(profile)
+ } catch (e: Exception) {
+ withContext(Dispatchers.Main) {
+ context.errorDialogBuilder(e).show()
+ }
+ }
+ }
+ },
+ onShareProfileURL = { profile ->
+ qrCodeProfile = profile
+ showQRCodeDialog = true
+ },
+ onUpdateProfile = onProfileUpdate,
+ onMove = onProfileMove,
+ )
+ }
+ }
+ }
+
+ // Add profile bottom sheet
+ if (showAddProfileSheet) {
+ ModalBottomSheet(
+ onDismissRequest = onHideAddProfileSheet,
+ containerColor = MaterialTheme.colorScheme.surface,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 32.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.add_profile),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
+ )
+
+ ListItem(
+ modifier =
+ Modifier.clickable {
+ onHideAddProfileSheet()
+ // Accept any file type to support both JSON and encoded profile files
+ importFromFileLauncher.launch("*/*")
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.FileUpload,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ headlineContent = {
+ Text(stringResource(R.string.profile_add_import_file))
+ },
+ supportingContent = {
+ Text(stringResource(R.string.import_from_file_description))
+ },
+ )
+
+ ListItem(
+ modifier =
+ Modifier.clickable {
+ onHideAddProfileSheet()
+ scanQrCodeLauncher.launch(null)
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Default.QrCodeScanner,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ headlineContent = {
+ Text(stringResource(R.string.profile_add_scan_qr_code))
+ },
+ supportingContent = {
+ Text(stringResource(R.string.scan_qr_code_description))
+ },
+ )
+
+ ListItem(
+ modifier =
+ Modifier.clickable {
+ onHideAddProfileSheet()
+ val intent = Intent(context, NewProfileComposeActivity::class.java)
+ newProfileLauncher.launch(intent)
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.CreateNewFolder,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ headlineContent = {
+ Text(stringResource(R.string.profile_add_create_manually))
+ },
+ supportingContent = {
+ Text(stringResource(R.string.create_new_profile_description))
+ },
+ )
+ }
+ }
+ }
+
+ // QR Code dialog
+ if (showQRCodeDialog && qrCodeProfile != null) {
+ val profile = qrCodeProfile!!
+ val link =
+ remember(profile) {
+ Libbox.generateRemoteProfileImportLink(
+ profile.name,
+ profile.typed.remoteURL,
+ )
+ }
+ val qrBitmap =
+ remember(link) {
+ QRCodeGenerator.generate(link)
+ }
+
+ QRCodeDialog(
+ bitmap = qrBitmap,
+ onDismiss = {
+ showQRCodeDialog = false
+ qrCodeProfile = null
+ },
+ onShare = {
+ coroutineScope.launch {
+ shareQRCodeImage(qrBitmap, profile.name)
+ }
+ showQRCodeDialog = false
+ qrCodeProfile = null
+ },
+ onSave = {
+ coroutineScope.launch {
+ saveQRCodeToGallery(qrBitmap, profile.name)
+ showQRCodeDialog = false
+ qrCodeProfile = null
+ }
+ },
+ )
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun ProfileList(
+ profiles: List,
+ selectedProfileId: Long,
+ isLoading: Boolean,
+ updatingProfileId: Long? = null,
+ updatedProfileId: Long? = null,
+ onProfileClick: (Profile) -> Unit,
+ onEditProfile: (Profile) -> Unit,
+ onDeleteProfile: (Profile) -> Unit,
+ onShareProfile: (Profile) -> Unit,
+ onShareProfileURL: (Profile) -> Unit,
+ onUpdateProfile: (Profile) -> Unit,
+ onMove: (Int, Int) -> Unit,
+) {
+ val lazyListState = rememberLazyListState()
+ val reorderableLazyListState =
+ rememberReorderableLazyListState(lazyListState) { from, to ->
+ onMove(from.index, to.index)
+ }
+
+ LazyColumn(
+ state = lazyListState,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .heightIn(min = 60.dp, max = 400.dp),
+ // Flexible height with min/max constraints
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ userScrollEnabled = profiles.size > 6, // Only enable scroll if more than 6 profiles
+ ) {
+ itemsIndexed(profiles, key = { _, profile -> profile.id }) { index, profile ->
+ ReorderableItem(
+ reorderableLazyListState,
+ key = profile.id,
+ ) { isDragging ->
+ ProfileItem(
+ profile = profile,
+ isSelected = profile.id == selectedProfileId,
+ isDragging = isDragging,
+ isLoading = isLoading,
+ isUpdating = profile.id == updatingProfileId,
+ showUpdateSuccess = profile.id == updatedProfileId,
+ onProfileClick = onProfileClick,
+ onEditProfile = onEditProfile,
+ onDeleteProfile = onDeleteProfile,
+ onShareProfile = onShareProfile,
+ onShareProfileURL = onShareProfileURL,
+ onUpdateProfile = onUpdateProfile,
+ modifier = Modifier.longPressDraggableHandle(),
+ )
+ }
+ }
+ }
+}
+
+private suspend fun createProfileContent(profile: Profile): ByteArray {
+ val content = ProfileContent()
+ content.name = profile.name
+ when (profile.typed.type) {
+ TypedProfile.Type.Local -> {
+ content.type = Libbox.ProfileTypeLocal
+ }
+ TypedProfile.Type.Remote -> {
+ content.type = Libbox.ProfileTypeRemote
+ }
+ }
+ content.config = java.io.File(profile.typed.path).readText()
+ content.remotePath = profile.typed.remoteURL
+ content.autoUpdate = profile.typed.autoUpdate
+ content.autoUpdateInterval = profile.typed.autoUpdateInterval
+ content.lastUpdated = profile.typed.lastUpdated.time
+ return content.encode()
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ProfileItem(
+ profile: Profile,
+ isSelected: Boolean,
+ isDragging: Boolean,
+ isLoading: Boolean,
+ isUpdating: Boolean = false,
+ showUpdateSuccess: Boolean = false,
+ onProfileClick: (Profile) -> Unit,
+ onEditProfile: (Profile) -> Unit,
+ onDeleteProfile: (Profile) -> Unit,
+ onShareProfile: (Profile) -> Unit,
+ onShareProfileURL: (Profile) -> Unit,
+ onUpdateProfile: (Profile) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var showMenu by remember { mutableStateOf(false) }
+ var expandedShareSubmenu by remember { mutableStateOf(false) }
+ val context = LocalContext.current
+ val coroutineScope = rememberCoroutineScope()
+
+ // Animated values for visual feedback
+ val animatedElevation by animateFloatAsState(
+ targetValue =
+ when {
+ isDragging -> 8.dp.value
+ isSelected -> 3.dp.value
+ else -> 1.dp.value
+ },
+ animationSpec = tween(300),
+ label = "Elevation",
+ )
+
+ val animatedBorderAlpha by animateFloatAsState(
+ targetValue = if (isSelected) 0.8f else 0.3f,
+ animationSpec = tween(300),
+ label = "BorderAlpha",
+ )
+
+ // File save launcher
+ val saveFileLauncher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument("application/octet-stream"),
+ ) { uri ->
+ if (uri != null) {
+ coroutineScope.launch(Dispatchers.IO) {
+ try {
+ val profileData = createProfileContent(profile)
+ context.contentResolver.openOutputStream(uri)?.use { outputStream ->
+ outputStream.write(profileData)
+ }
+ withContext(Dispatchers.Main) {
+ val successMessage = context.getString(R.string.profile_saved_successfully)
+ Toast.makeText(
+ context,
+ successMessage,
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ } catch (e: Exception) {
+ withContext(Dispatchers.Main) {
+ val failedMessage = context.getString(R.string.profile_save_failed)
+ Toast.makeText(
+ context,
+ "$failedMessage: ${e.message}",
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ }
+ }
+ }
+
+ Surface(
+ onClick = { if (!isLoading) onProfileClick(profile) },
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ color =
+ when {
+ isDragging -> MaterialTheme.colorScheme.tertiaryContainer
+ isSelected -> MaterialTheme.colorScheme.primaryContainer
+ else -> MaterialTheme.colorScheme.surface
+ },
+ tonalElevation = animatedElevation.dp,
+ border =
+ androidx.compose.foundation.BorderStroke(
+ width = if (isSelected) 2.dp else 1.dp,
+ color =
+ when {
+ isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = animatedBorderAlpha)
+ else -> MaterialTheme.colorScheme.outline.copy(alpha = animatedBorderAlpha)
+ },
+ ),
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Profile icon - use custom icon if set, otherwise default
+ val profileIcon =
+ ProfileIcons.getIconById(profile.icon)
+ ?: Icons.AutoMirrored.Default.InsertDriveFile
+
+ Icon(
+ imageVector = profileIcon,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint =
+ if (isSelected) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ // Profile info
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ // Profile name
+ Text(
+ text = profile.name,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
+ color =
+ if (isSelected) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ // Second line: Type and last updated
+ val context = LocalContext.current
+ Text(
+ text =
+ when (profile.typed.type) {
+ TypedProfile.Type.Local -> stringResource(R.string.profile_type_local)
+ TypedProfile.Type.Remote ->
+ stringResource(
+ R.string.profile_type_remote_updated,
+ RelativeTimeFormatter.format(context, profile.typed.lastUpdated),
+ )
+ },
+ style = MaterialTheme.typography.labelSmall,
+ color =
+ if (isSelected) {
+ MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ },
+ )
+ }
+
+ // Update button for remote profiles
+ if (profile.typed.type == TypedProfile.Type.Remote) {
+ IconButton(
+ onClick = {
+ if (!isUpdating && !showUpdateSuccess) {
+ onUpdateProfile(profile)
+ }
+ },
+ modifier = Modifier.size(32.dp),
+ enabled = !isUpdating && !showUpdateSuccess,
+ ) {
+ when {
+ isUpdating -> {
+ CircularProgressIndicator(
+ modifier = Modifier.size(18.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+
+ showUpdateSuccess -> {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = stringResource(R.string.update_successful),
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+
+ else -> {
+ Icon(
+ imageVector = Icons.Default.Update,
+ contentDescription = stringResource(R.string.update_profile),
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+
+ // More options button
+ Spacer(modifier = Modifier.width(4.dp))
+
+ Box {
+ IconButton(
+ onClick = {
+ showMenu = true
+ expandedShareSubmenu = false // Always start with submenu collapsed
+ },
+ modifier = Modifier.size(32.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = stringResource(R.string.more_options),
+ modifier = Modifier.size(20.dp),
+ tint =
+ if (isSelected) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ }
+
+ DropdownMenu(
+ expanded = showMenu,
+ onDismissRequest = {
+ showMenu = false
+ expandedShareSubmenu = false // Reset submenu state when closing
+ },
+ modifier = Modifier.widthIn(min = 200.dp),
+ ) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.edit)) },
+ onClick = {
+ showMenu = false
+ onEditProfile(profile)
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Edit,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ )
+
+ // Share submenu header
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_share)) },
+ onClick = {
+ expandedShareSubmenu = !expandedShareSubmenu
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.IosShare,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ trailingIcon = {
+ Icon(
+ imageVector =
+ if (expandedShareSubmenu) {
+ Icons.Default.ExpandLess
+ } else {
+ Icons.Default.ExpandMore
+ },
+ contentDescription = null,
+ )
+ },
+ )
+
+ // Share submenu items (shown inline when expanded)
+ if (expandedShareSubmenu) {
+ // Save As File
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.save_as_file)) },
+ onClick = {
+ showMenu = false
+ saveFileLauncher.launch("${profile.name}.bpf")
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Save,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 24.dp),
+ )
+ },
+ )
+
+ // Share As File
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.share_as_file)) },
+ onClick = {
+ showMenu = false
+ onShareProfile(profile)
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.IosShare,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 24.dp),
+ )
+ },
+ )
+
+ // Share URL as QR Code (only for remote profiles)
+ if (profile.typed.type == TypedProfile.Type.Remote) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.profile_share_url)) },
+ onClick = {
+ showMenu = false
+ onShareProfileURL(profile)
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.QrCode2,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 24.dp),
+ )
+ },
+ )
+ }
+ }
+
+ HorizontalDivider()
+
+ DropdownMenuItem(
+ text = {
+ Text(
+ stringResource(R.string.menu_delete),
+ color = MaterialTheme.colorScheme.error,
+ )
+ },
+ onClick = {
+ showMenu = false
+ onDeleteProfile(profile)
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ )
+ },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/SystemProxyCard.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/SystemProxyCard.kt
new file mode 100644
index 0000000..d6b1df3
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/SystemProxyCard.kt
@@ -0,0 +1,67 @@
+package io.nekohasekai.sfa.compose.screen.dashboard
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.SettingsEthernet
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import io.nekohasekai.sfa.R
+
+@Composable
+fun SystemProxyCard(
+ enabled: Boolean,
+ isSwitching: Boolean,
+ onToggle: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(
+ modifier = Modifier.weight(1f),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.SettingsEthernet,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(R.string.system_http_proxy),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ Switch(
+ checked = enabled,
+ onCheckedChange = onToggle,
+ enabled = !isSwitching,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/UploadTrafficCard.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/UploadTrafficCard.kt
new file mode 100644
index 0000000..75f07e2
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/UploadTrafficCard.kt
@@ -0,0 +1,84 @@
+package io.nekohasekai.sfa.compose.screen.dashboard
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Upload
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.compose.LineChart
+
+@Composable
+fun UploadTrafficCard(
+ uplink: String,
+ uplinkTotal: String,
+ uplinkHistory: List,
+ modifier: Modifier = Modifier,
+) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Upload,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(R.string.upload),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = uplink,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Text(
+ text = uplinkTotal,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ LineChart(
+ data = uplinkHistory,
+ lineColor = MaterialTheme.colorScheme.primary,
+ animate = false,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsScreen.kt
new file mode 100644
index 0000000..7e824f0
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsScreen.kt
@@ -0,0 +1,518 @@
+package io.nekohasekai.sfa.compose.screen.dashboard.groups
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.Speed
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import io.nekohasekai.libbox.Libbox
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.constant.Status
+import io.nekohasekai.sfa.ui.dashboard.Group
+import io.nekohasekai.sfa.ui.dashboard.GroupItem
+
+@Composable
+fun GroupsScreen(
+ serviceStatus: Status,
+ viewModel: GroupsViewModel = viewModel(),
+ snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
+ onToggleAllGroups: () -> Unit = { viewModel.toggleAllGroups() },
+ modifier: Modifier = Modifier,
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val context = LocalContext.current
+
+ // Stable callbacks to prevent recomposition
+ val onToggleExpanded =
+ remember<(String) -> Unit> {
+ { groupTag -> viewModel.toggleGroupExpand(groupTag) }
+ }
+ val onItemSelected =
+ remember<(String, String) -> Unit> {
+ { groupTag, itemTag -> viewModel.selectGroupItem(groupTag, itemTag) }
+ }
+ val onUrlTest =
+ remember<(String) -> Unit> {
+ { groupTag -> viewModel.urlTest(groupTag) }
+ }
+
+ LaunchedEffect(serviceStatus, viewModel) {
+ viewModel.updateServiceStatus(serviceStatus)
+ }
+
+ // Show snackbar when needed
+ LaunchedEffect(uiState.showCloseConnectionsSnackbar) {
+ if (uiState.showCloseConnectionsSnackbar) {
+ val message = context.getString(R.string.close_connections_confirm)
+ val actionLabel = context.getString(R.string.close)
+ val result =
+ snackbarHostState.showSnackbar(
+ message = message,
+ actionLabel = actionLabel,
+ duration = androidx.compose.material3.SnackbarDuration.Indefinite,
+ withDismissAction = true,
+ )
+ when (result) {
+ androidx.compose.material3.SnackbarResult.ActionPerformed -> {
+ viewModel.closeConnections()
+ }
+ androidx.compose.material3.SnackbarResult.Dismissed -> {
+ viewModel.dismissCloseConnectionsSnackbar()
+ }
+ }
+ }
+ }
+
+ if (uiState.isLoading) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+ } else {
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding =
+ PaddingValues(
+ start = 16.dp,
+ end = 16.dp,
+ top = 8.dp,
+ bottom = 16.dp,
+ ),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(
+ items = uiState.groups,
+ key = { it.tag },
+ contentType = { "GroupCard" },
+ ) { group ->
+ ProxyGroupCard(
+ group = group,
+ isExpanded = uiState.expandedGroups.contains(group.tag),
+ onToggleExpanded = remember { { onToggleExpanded(group.tag) } },
+ onItemSelected = remember { { itemTag -> onItemSelected(group.tag, itemTag) } },
+ onUrlTest = remember { { onUrlTest(group.tag) } },
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ProxyGroupCard(
+ group: Group,
+ isExpanded: Boolean,
+ onToggleExpanded: () -> Unit,
+ onItemSelected: (String) -> Unit,
+ onUrlTest: () -> Unit,
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ // Header (clickable to expand/collapse)
+ Surface(
+ onClick = onToggleExpanded,
+ color = Color.Transparent,
+ ) {
+ ListItem(
+ headlineContent = {
+ Column {
+ Text(
+ text = group.tag,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = Libbox.proxyDisplayType(group.type),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ // Show selected item when collapsed
+ AnimatedVisibility(
+ visible = !isExpanded && group.selected.isNotEmpty(),
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = "•",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = group.selected,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+ },
+ trailingContent = {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // URL Test button
+ AnimatedVisibility(
+ visible = group.selectable,
+ enter = slideInVertically() + fadeIn(),
+ exit = slideOutVertically() + fadeOut(),
+ ) {
+ IconButton(
+ onClick = {
+ onUrlTest()
+ // Don't toggle expansion when clicking URL test
+ },
+ modifier = Modifier.size(40.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Speed,
+ contentDescription = stringResource(R.string.url_test),
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ // Expand/Collapse indicator
+ val rotationAngle by animateFloatAsState(
+ targetValue = if (isExpanded) 180f else 0f,
+ animationSpec = tween(300),
+ label = "ExpandIcon",
+ )
+
+ val expandContentDescription = stringResource(R.string.expand)
+ val collapseContentDescription = stringResource(R.string.collapse)
+ Icon(
+ imageVector = Icons.Default.ExpandMore,
+ contentDescription = if (isExpanded) collapseContentDescription else expandContentDescription,
+ modifier =
+ Modifier
+ .size(24.dp)
+ .graphicsLayer { rotationZ = rotationAngle },
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ },
+ colors =
+ ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+ }
+
+ // Expandable content
+ AnimatedVisibility(
+ visible = isExpanded && group.items.isNotEmpty(),
+ enter = expandVertically(animationSpec = tween(300)) + fadeIn(animationSpec = tween(300)),
+ exit = shrinkVertically(animationSpec = tween(300)) + fadeOut(animationSpec = tween(300)),
+ ) {
+ Column {
+ androidx.compose.material3.HorizontalDivider(
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f),
+ thickness = 1.dp,
+ )
+
+ // Proxy Items
+ ProxyItemsList(
+ items = group.items,
+ selectedTag = group.selected,
+ isSelectable = group.selectable,
+ onItemSelected = onItemSelected,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ProxyItemsList(
+ items: List,
+ selectedTag: String,
+ isSelectable: Boolean,
+ onItemSelected: (String) -> Unit,
+) {
+ // Cache the chunked items to avoid re-chunking on every recomposition
+ val itemsPerRow = 2
+ val chunkedItems =
+ remember(items) {
+ items.chunked(itemsPerRow)
+ }
+
+ // Use Column with Rows for better control over item sizing
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ chunkedItems.forEach { rowItems ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ rowItems.forEach { item ->
+ Box(
+ modifier = Modifier.weight(1f),
+ ) {
+ ProxyChip(
+ item = item,
+ isSelected = item.tag == selectedTag,
+ isSelectable = isSelectable,
+ onClick = remember { { onItemSelected(item.tag) } },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ // Add empty boxes for incomplete rows to maintain equal sizing
+ repeat(itemsPerRow - rowItems.size) {
+ Box(modifier = Modifier.weight(1f))
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ProxyChip(
+ item: GroupItem,
+ isSelected: Boolean,
+ isSelectable: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ // Use simpler, faster animations
+ val animatedElevation by animateFloatAsState(
+ targetValue = if (isSelected) 6.dp.value else 1.dp.value,
+ animationSpec = tween(150),
+ label = "Elevation",
+ )
+
+ val surfaceModifier = modifier
+ val surfaceShape = RoundedCornerShape(8.dp)
+ val surfaceColor =
+ when {
+ isSelected -> MaterialTheme.colorScheme.primaryContainer
+ else -> MaterialTheme.colorScheme.surface
+ }
+ val surfaceBorder =
+ androidx.compose.foundation.BorderStroke(
+ width = if (isSelected) 2.dp else 1.dp,
+ color =
+ when {
+ isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
+ else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
+ },
+ )
+
+ val content: @Composable () -> Unit = {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ // First line: Name
+ Text(
+ text = item.tag,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
+ color =
+ if (isSelected) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ // Second line: Type on left, Latency on right
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Type
+ Text(
+ text = Libbox.proxyDisplayType(item.type),
+ style = MaterialTheme.typography.labelSmall,
+ color =
+ if (isSelected) {
+ MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ },
+ )
+
+ // Latency
+ AnimatedVisibility(
+ visible = item.urlTestTime > 0,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ ProxyLatencyBadge(
+ delay = item.urlTestDelay,
+ isSelected = isSelected,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (isSelectable) {
+ Surface(
+ onClick = onClick,
+ modifier = surfaceModifier,
+ shape = surfaceShape,
+ color = surfaceColor,
+ tonalElevation = animatedElevation.dp,
+ border = surfaceBorder,
+ content = content,
+ )
+ } else {
+ Surface(
+ modifier = surfaceModifier,
+ shape = surfaceShape,
+ color = surfaceColor,
+ tonalElevation = animatedElevation.dp,
+ border = surfaceBorder,
+ content = content,
+ )
+ }
+}
+
+@Composable
+private fun ProxyLatencyBadge(
+ delay: Int,
+ isSelected: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ // Direct color calculation without animation for better performance
+ val colorScheme = MaterialTheme.colorScheme
+ val latencyColor =
+ remember(delay, isSelected, colorScheme) {
+ when {
+ delay < 100 -> {
+ // Excellent - green/tertiary
+ if (isSelected) {
+ colorScheme.tertiary
+ } else {
+ colorScheme.tertiary.copy(alpha = 0.9f)
+ }
+ }
+
+ delay < 300 -> {
+ // Good - primary
+ if (isSelected) {
+ colorScheme.primary
+ } else {
+ colorScheme.primary.copy(alpha = 0.9f)
+ }
+ }
+
+ delay < 500 -> {
+ // Fair - secondary/warning
+ if (isSelected) {
+ colorScheme.secondary
+ } else {
+ colorScheme.secondary.copy(alpha = 0.9f)
+ }
+ }
+
+ else -> {
+ // Poor - error
+ if (isSelected) {
+ colorScheme.error
+ } else {
+ colorScheme.error.copy(alpha = 0.9f)
+ }
+ }
+ }
+ }
+
+ Text(
+ text = "${delay}ms",
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = latencyColor,
+ modifier = modifier,
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt
new file mode 100644
index 0000000..9b33f0e
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt
@@ -0,0 +1,304 @@
+package io.nekohasekai.sfa.compose.screen.dashboard.groups
+
+import androidx.lifecycle.viewModelScope
+import io.nekohasekai.libbox.Libbox
+import io.nekohasekai.libbox.OutboundGroup
+import io.nekohasekai.sfa.compose.base.BaseViewModel
+import io.nekohasekai.sfa.compose.base.ScreenEvent
+import io.nekohasekai.sfa.constant.Status
+import io.nekohasekai.sfa.ui.dashboard.Group
+import io.nekohasekai.sfa.ui.dashboard.GroupItem
+import io.nekohasekai.sfa.ui.dashboard.toList
+import io.nekohasekai.sfa.utils.CommandClient
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+data class GroupsUiState(
+ val groups: List = emptyList(),
+ val isLoading: Boolean = false,
+ val expandedGroups: Set = emptySet(),
+ val showCloseConnectionsSnackbar: Boolean = false,
+)
+
+sealed class GroupsEvent : ScreenEvent {
+ data class GroupSelected(val groupTag: String, val itemTag: String) : GroupsEvent()
+}
+
+class GroupsViewModel(
+ private val sharedCommandClient: CommandClient? = null,
+) : BaseViewModel(), CommandClient.Handler {
+ private val commandClient: CommandClient
+ private val isUsingSharedClient: Boolean
+
+ private val _serviceStatus = MutableStateFlow(Status.Stopped)
+ val serviceStatus = _serviceStatus.asStateFlow()
+ private var lastServiceStatus: Status = Status.Stopped
+ private var connectionJob: Job? = null
+
+ init {
+ if (sharedCommandClient != null) {
+ commandClient = sharedCommandClient
+ isUsingSharedClient = true
+ commandClient.addHandler(this)
+ } else {
+ commandClient =
+ CommandClient(
+ viewModelScope,
+ CommandClient.ConnectionType.Groups,
+ this,
+ )
+ isUsingSharedClient = false
+ }
+ }
+
+ override fun createInitialState() = GroupsUiState()
+
+ override fun onCleared() {
+ super.onCleared()
+ connectionJob?.cancel()
+ connectionJob = null
+ if (isUsingSharedClient) {
+ commandClient.removeHandler(this)
+ } else {
+ commandClient.disconnect()
+ }
+ }
+
+ private fun handleServiceStatusChange(status: Status) {
+ if (status == Status.Started) {
+ updateState {
+ copy(isLoading = true)
+ }
+ if (!isUsingSharedClient) {
+ connectionJob?.cancel()
+ connectionJob = viewModelScope.launch(Dispatchers.IO) {
+ while (isActive) {
+ try {
+ commandClient.connect()
+ break
+ } catch (e: Exception) {
+ delay(100)
+ }
+ }
+ }
+ }
+ } else {
+ connectionJob?.cancel()
+ connectionJob = null
+ if (!isUsingSharedClient) {
+ commandClient.disconnect()
+ }
+ updateState {
+ copy(
+ groups = emptyList(),
+ isLoading = false,
+ )
+ }
+ }
+ }
+
+ fun updateServiceStatus(status: Status) {
+ if (status == lastServiceStatus) {
+ return
+ }
+ lastServiceStatus = status
+ viewModelScope.launch {
+ _serviceStatus.emit(status)
+ handleServiceStatusChange(status)
+ }
+ }
+
+ fun toggleGroupExpand(groupTag: String) {
+ updateState {
+ val newExpandedGroups =
+ if (expandedGroups.contains(groupTag)) {
+ expandedGroups - groupTag
+ } else {
+ expandedGroups + groupTag
+ }
+ copy(expandedGroups = newExpandedGroups)
+ }
+ }
+
+ fun toggleAllGroups() {
+ updateState {
+ if (expandedGroups.isEmpty()) {
+ // All are collapsed, expand all
+ copy(expandedGroups = groups.map { it.tag }.toSet())
+ } else {
+ // Some or all are expanded, collapse all
+ copy(expandedGroups = emptySet())
+ }
+ }
+ }
+
+ fun selectGroupItem(
+ groupTag: String,
+ itemTag: String,
+ ) {
+ // Check if this is actually a different selection
+ val currentGroup = uiState.value.groups.find { it.tag == groupTag }
+ if (currentGroup?.selected == itemTag) {
+ // Same item selected, no need to do anything
+ return
+ }
+
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ // Select the new outbound immediately
+ Libbox.newStandaloneCommandClient().selectOutbound(groupTag, itemTag)
+
+ // Update local state and show snackbar
+ withContext(Dispatchers.Main) {
+ updateState {
+ copy(
+ groups =
+ groups.map { group ->
+ if (group.tag == groupTag) {
+ group.copy(selected = itemTag)
+ } else {
+ group
+ }
+ },
+ showCloseConnectionsSnackbar = true,
+ )
+ }
+ sendEvent(GroupsEvent.GroupSelected(groupTag, itemTag))
+ }
+ } catch (e: Exception) {
+ sendError(e)
+ }
+ }
+ }
+
+ fun closeConnections() {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ Libbox.newStandaloneCommandClient().closeConnections()
+ withContext(Dispatchers.Main) {
+ dismissCloseConnectionsSnackbar()
+ }
+ } catch (e: Exception) {
+ withContext(Dispatchers.Main) {
+ dismissCloseConnectionsSnackbar()
+ }
+ sendError(e)
+ }
+ }
+ }
+
+ fun dismissCloseConnectionsSnackbar() {
+ updateState {
+ copy(showCloseConnectionsSnackbar = false)
+ }
+ }
+
+ fun urlTest(groupTag: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ Libbox.newStandaloneCommandClient().urlTest(groupTag)
+ } catch (e: Exception) {
+ sendError(e)
+ }
+ }
+ }
+
+ // CommandClient.Handler implementation
+ override fun onConnected() {
+ viewModelScope.launch(Dispatchers.Main) {
+ // Connection established, waiting for groups
+ }
+ }
+
+ override fun onDisconnected() {
+ viewModelScope.launch(Dispatchers.Main) {
+ updateState {
+ copy(
+ groups = emptyList(),
+ isLoading = false,
+ )
+ }
+ }
+ }
+
+ override fun updateGroups(newGroups: MutableList) {
+ connectionJob?.cancel()
+ connectionJob = null
+ viewModelScope.launch(Dispatchers.Default) {
+ val currentGroups = uiState.value.groups
+ val newGroupsMap = newGroups.associateBy { it.tag }
+
+ // Smart merge: preserve existing Group objects when only delays change
+ val mergedGroups =
+ if (currentGroups.isEmpty()) {
+ // Initial load
+ newGroups.map(::Group)
+ } else {
+ currentGroups.map { existingGroup ->
+ val newGroupData = newGroupsMap[existingGroup.tag]
+ if (newGroupData != null) {
+ // Check if only delays have changed
+ val newItems = newGroupData.items.toList()
+ val hasStructuralChange =
+ existingGroup.items.size != newItems.size ||
+ existingGroup.selected != newGroupData.selected ||
+ existingGroup.type != newGroupData.type ||
+ existingGroup.selectable != newGroupData.selectable
+
+ if (hasStructuralChange) {
+ // Structural change, create new Group
+ Group(newGroupData)
+ } else {
+ // Only delays might have changed, update items efficiently
+ val updatedItems =
+ existingGroup.items.mapIndexed { index, item ->
+ val newItemData = newItems.getOrNull(index)
+ if (newItemData != null &&
+ item.tag == newItemData.tag &&
+ item.type == newItemData.type
+ ) {
+ // Only update if delay actually changed
+ if (item.urlTestDelay != newItemData.urlTestDelay ||
+ item.urlTestTime != newItemData.urlTestTime
+ ) {
+ GroupItem(newItemData)
+ } else {
+ item // Keep existing object
+ }
+ } else {
+ if (newItemData != null) {
+ GroupItem(newItemData)
+ } else {
+ item // Keep existing if index out of bounds
+ }
+ }
+ }
+ existingGroup.copy(items = updatedItems)
+ }
+ } else {
+ existingGroup
+ }
+ } +
+ newGroups.filter { newGroup ->
+ currentGroups.none { it.tag == newGroup.tag }
+ }.map(::Group)
+ }
+
+ withContext(Dispatchers.Main) {
+ updateState {
+ // Keep existing expanded state when groups are updated
+ copy(
+ groups = mergedGroups,
+ isLoading = false,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt
new file mode 100644
index 0000000..702826d
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt
@@ -0,0 +1,851 @@
+package io.nekohasekai.sfa.compose.screen.log
+
+import android.content.ClipData
+import android.content.Intent
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.DragInteraction
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckBox
+import androidx.compose.material.icons.filled.CheckBoxOutlineBlank
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.ExpandLess
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.FilterList
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.RadioButtonChecked
+import androidx.compose.material.icons.filled.RadioButtonUnchecked
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material.icons.filled.Stop
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+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.OutlinedTextField
+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.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.content.FileProvider
+import androidx.lifecycle.viewmodel.compose.viewModel
+import io.nekohasekai.sfa.Application
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.bg.BoxService
+import io.nekohasekai.sfa.compose.ComposeActivity
+import io.nekohasekai.sfa.constant.Status
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LogScreen(
+ serviceStatus: Status = Status.Stopped,
+ viewModel: LogViewModel = viewModel(),
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val context = LocalContext.current
+ val listState = rememberLazyListState()
+ val coroutineScope = rememberCoroutineScope()
+
+ // Handle back press in selection mode
+ androidx.activity.compose.BackHandler(enabled = uiState.isSelectionMode) {
+ viewModel.clearSelection()
+ }
+
+ // Track if user is at the bottom of the list
+ val isAtBottom by remember {
+ derivedStateOf {
+ val layoutInfo = listState.layoutInfo
+ val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()
+ lastVisibleItem != null && lastVisibleItem.index == layoutInfo.totalItemsCount - 1
+ }
+ }
+
+ // Re-enable auto-scroll when user reaches bottom
+ LaunchedEffect(isAtBottom) {
+ if (isAtBottom) {
+ viewModel.setAutoScrollEnabled(true)
+ }
+ }
+
+ // Detect user manual scroll to disable auto-scroll
+ LaunchedEffect(listState) {
+ var dragStartIndex: Int? = null
+ var dragStartOffset: Int? = null
+
+ listState.interactionSource.interactions.collect { interaction ->
+ when (interaction) {
+ is DragInteraction.Start -> {
+ dragStartIndex = listState.firstVisibleItemIndex
+ dragStartOffset = listState.firstVisibleItemScrollOffset
+ }
+ is DragInteraction.Stop, is DragInteraction.Cancel -> {
+ if (dragStartIndex != null && dragStartOffset != null) {
+ val currentIndex = listState.firstVisibleItemIndex
+ val currentOffset = listState.firstVisibleItemScrollOffset
+
+ val scrolledUp =
+ if (dragStartIndex != currentIndex) {
+ dragStartIndex!! > currentIndex
+ } else {
+ dragStartOffset!! > currentOffset
+ }
+
+ if (scrolledUp) {
+ viewModel.setAutoScrollEnabled(false)
+ }
+
+ dragStartIndex = null
+ dragStartOffset = null
+ }
+ }
+ }
+ }
+ }
+
+ // Handle scroll to bottom requests from ViewModel
+ val scrollToBottomTrigger by viewModel.scrollToBottomTrigger.collectAsState()
+ LaunchedEffect(scrollToBottomTrigger) {
+ if (scrollToBottomTrigger > 0 && uiState.logs.isNotEmpty()) {
+ listState.animateScrollToItem(uiState.logs.size - 1)
+ }
+ }
+
+ // Update service status in ViewModel
+ LaunchedEffect(serviceStatus) {
+ viewModel.updateServiceStatus(serviceStatus)
+ }
+
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ // Show selection mode bar
+ if (uiState.isSelectionMode) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ shadowElevation = 2.dp,
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = { viewModel.clearSelection() }) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = stringResource(R.string.content_description_exit_selection_mode),
+ )
+ }
+ Text(
+ text =
+ stringResource(
+ R.string.selected_count,
+ uiState.selectedLogIndices.size,
+ ),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ Row {
+ IconButton(
+ onClick = {
+ val selectedText = viewModel.getSelectedLogsText()
+ if (selectedText.isNotEmpty()) {
+ val clipLabel = context.getString(R.string.title_log)
+ val clip = ClipData.newPlainText(clipLabel, selectedText)
+ Application.clipboard.setPrimaryClip(clip)
+ Toast.makeText(
+ context,
+ context.getString(R.string.copied_to_clipboard),
+ Toast.LENGTH_SHORT,
+ ).show()
+ viewModel.clearSelection()
+ }
+ },
+ enabled = uiState.selectedLogIndices.isNotEmpty(),
+ ) {
+ Icon(
+ imageVector = Icons.Default.ContentCopy,
+ contentDescription = stringResource(R.string.content_description_copy_selected),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Show active filter indicator
+ if (uiState.filterLogLevel != LogLevel.Default && !uiState.isSelectionMode) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text =
+ stringResource(
+ R.string.filter_label,
+ uiState.filterLogLevel.label,
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ TextButton(
+ onClick = { viewModel.setLogLevel(LogLevel.Default) },
+ contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
+ modifier = Modifier.height(24.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.clear_filter),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ }
+ }
+
+ // Show search bar with animation
+ AnimatedVisibility(
+ visible = uiState.isSearchActive,
+ enter =
+ expandVertically(
+ animationSpec = tween(300),
+ ) +
+ fadeIn(
+ animationSpec = tween(300),
+ ),
+ exit =
+ shrinkVertically(
+ animationSpec = tween(300),
+ ) +
+ fadeOut(
+ animationSpec = tween(300),
+ ),
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surface,
+ shadowElevation = 4.dp,
+ ) {
+ val focusRequester = remember { FocusRequester() }
+ val focusManager = LocalFocusManager.current
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ OutlinedTextField(
+ value = uiState.searchQuery,
+ onValueChange = { viewModel.updateSearchQuery(it) },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, bottom = 12.dp)
+ .focusRequester(focusRequester),
+ placeholder = { Text(stringResource(R.string.search_logs_placeholder)) },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = stringResource(R.string.search),
+ )
+ },
+ trailingIcon = {
+ if (uiState.searchQuery.isNotEmpty()) {
+ IconButton(onClick = { viewModel.updateSearchQuery("") }) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = stringResource(R.string.content_description_clear_search),
+ )
+ }
+ }
+ },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions =
+ KeyboardActions(
+ onSearch = {
+ focusManager.clearFocus()
+ },
+ ),
+ )
+ }
+ }
+
+ if (uiState.logs.isEmpty()) {
+ // Empty state
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text =
+ when (serviceStatus) {
+ Status.Started -> stringResource(R.string.status_started)
+ Status.Starting -> stringResource(R.string.status_starting)
+ Status.Stopping -> stringResource(R.string.status_stopping)
+ else -> stringResource(R.string.status_default)
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ } else {
+ // Log list
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxSize(),
+ contentPadding =
+ PaddingValues(
+ start = 8.dp,
+ end = 8.dp,
+ top = 8.dp,
+ bottom = 88.dp, // Space for FAB
+ ),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ itemsIndexed(
+ items = uiState.logs,
+ key = { _, log -> log.id },
+ ) { index, log ->
+ LogItem(
+ annotatedString = log.annotatedString,
+ index = index,
+ isSelected = uiState.selectedLogIndices.contains(index),
+ isSelectionMode = uiState.isSelectionMode,
+ onLongClick = {
+ if (!uiState.isSelectionMode) {
+ viewModel.toggleSelectionMode()
+ viewModel.toggleLogSelection(index)
+ }
+ },
+ onClick = {
+ if (uiState.isSelectionMode) {
+ viewModel.toggleLogSelection(index)
+ }
+ },
+ )
+ }
+ }
+ }
+ } // Close Column
+
+ // Options Menu - Material 3 style
+ Box(
+ modifier =
+ Modifier
+ .align(Alignment.TopEnd)
+ .padding(end = 8.dp),
+ ) {
+ var expandedLogLevel by remember { mutableStateOf(false) }
+ var expandedSave by remember { mutableStateOf(false) }
+
+ // File save launcher (must be outside DropdownMenu)
+ val saveFileLauncher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.CreateDocument("text/plain"),
+ onResult = { uri ->
+ uri?.let {
+ try {
+ context.contentResolver.openOutputStream(it)?.use { outputStream ->
+ val logsText = viewModel.getAllLogsText()
+ outputStream.write(logsText.toByteArray())
+ outputStream.flush()
+ Toast.makeText(
+ context,
+ context.getString(R.string.logs_saved_successfully),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ } catch (e: Exception) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.failed_to_save_logs, e.message),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ },
+ )
+
+ DropdownMenu(
+ expanded = uiState.isOptionsMenuOpen,
+ onDismissRequest = {
+ viewModel.toggleOptionsMenu()
+ expandedLogLevel = false
+ expandedSave = false
+ },
+ modifier = Modifier.widthIn(min = 200.dp),
+ ) {
+ // Log Level section with nested items
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = stringResource(R.string.log_level),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ onClick = { expandedLogLevel = !expandedLogLevel },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.FilterList,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ trailingIcon = {
+ Icon(
+ imageVector =
+ if (expandedLogLevel) {
+ Icons.Default.ExpandLess
+ } else {
+ Icons.Default.ExpandMore
+ },
+ contentDescription = null,
+ )
+ },
+ )
+
+ // Show log levels inline when expanded
+ if (expandedLogLevel) {
+ LogLevel.entries.filter { it.priority > 1 }.forEach { level ->
+ DropdownMenuItem(
+ text = {
+ Text(text = level.label)
+ },
+ onClick = {
+ viewModel.setLogLevel(level)
+ viewModel.toggleOptionsMenu()
+ expandedLogLevel = false
+ },
+ leadingIcon = {
+ Icon(
+ imageVector =
+ if (uiState.filterLogLevel == level) {
+ Icons.Default.RadioButtonChecked
+ } else {
+ Icons.Default.RadioButtonUnchecked
+ },
+ contentDescription =
+ if (uiState.filterLogLevel == level) {
+ stringResource(R.string.group_selected_title)
+ } else {
+ null
+ },
+ tint =
+ if (uiState.filterLogLevel == level) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ modifier = Modifier.padding(start = 24.dp),
+ )
+ },
+ )
+ }
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+ }
+
+ // Save section with nested items
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = stringResource(R.string.save),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ onClick = { expandedSave = !expandedSave },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Save,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ trailingIcon = {
+ Icon(
+ imageVector =
+ if (expandedSave) {
+ Icons.Default.ExpandLess
+ } else {
+ Icons.Default.ExpandMore
+ },
+ contentDescription = null,
+ )
+ },
+ )
+
+ // Show save options inline when expanded
+ if (expandedSave) {
+ // Copy to Clipboard
+ DropdownMenuItem(
+ text = {
+ Text(text = stringResource(R.string.save_to_clipboard))
+ },
+ onClick = {
+ val logsText = viewModel.getAllLogsText()
+ if (logsText.isNotEmpty()) {
+ val clip =
+ ClipData.newPlainText(
+ context.getString(R.string.title_log),
+ logsText,
+ )
+ Application.clipboard.setPrimaryClip(clip)
+ Toast.makeText(
+ context,
+ context.getString(R.string.logs_copied_to_clipboard),
+ Toast.LENGTH_SHORT,
+ ).show()
+ } else {
+ Toast.makeText(
+ context,
+ context.getString(R.string.no_logs_to_copy),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ viewModel.toggleOptionsMenu()
+ expandedSave = false
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.ContentCopy,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 24.dp),
+ )
+ },
+ )
+
+ // Save to File
+ DropdownMenuItem(
+ text = {
+ Text(text = stringResource(R.string.save_to_file))
+ },
+ onClick = {
+ val timestamp =
+ SimpleDateFormat(
+ "yyyyMMdd_HHmmss",
+ Locale.getDefault(),
+ ).format(Date())
+ saveFileLauncher.launch("logs_$timestamp.txt")
+ viewModel.toggleOptionsMenu()
+ expandedSave = false
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Save,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 24.dp),
+ )
+ },
+ )
+
+ // Share as File
+ DropdownMenuItem(
+ text = {
+ Text(text = stringResource(R.string.menu_share))
+ },
+ onClick = {
+ val logsText = viewModel.getAllLogsText()
+ if (logsText.isNotEmpty()) {
+ try {
+ val logsDir =
+ File(context.cacheDir, "logs").also { it.mkdirs() }
+ val timestamp =
+ SimpleDateFormat(
+ "yyyyMMdd_HHmmss",
+ Locale.getDefault(),
+ ).format(Date())
+ val logFile = File(logsDir, "logs_$timestamp.txt")
+ logFile.writeText(logsText)
+
+ val uri =
+ FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.cache",
+ logFile,
+ )
+ val shareIntent =
+ Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(
+ Intent.createChooser(
+ shareIntent,
+ context.getString(R.string.intent_share_logs),
+ ),
+ )
+ } catch (e: Exception) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.failed_to_share_logs, e.message),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ } else {
+ Toast.makeText(
+ context,
+ context.getString(R.string.no_logs_to_share),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ viewModel.toggleOptionsMenu()
+ expandedSave = false
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Share,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 24.dp),
+ )
+ },
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+ }
+
+ // Clear logs option
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = stringResource(R.string.clear_logs),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error,
+ )
+ },
+ onClick = {
+ viewModel.requestClearLogs()
+ viewModel.toggleOptionsMenu()
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ )
+ },
+ )
+ }
+ }
+
+ // FABs - Hide during selection mode
+ Column(
+ modifier =
+ Modifier
+ .align(Alignment.BottomEnd)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ // Scroll to bottom FAB
+ AnimatedVisibility(
+ visible = !isAtBottom && !uiState.isSelectionMode && uiState.logs.isNotEmpty(),
+ enter = androidx.compose.animation.scaleIn(),
+ exit = androidx.compose.animation.scaleOut(),
+ ) {
+ FloatingActionButton(
+ onClick = { viewModel.scrollToBottom() },
+ containerColor = MaterialTheme.colorScheme.secondary,
+ ) {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = stringResource(R.string.content_description_scroll_to_bottom),
+ )
+ }
+ }
+
+ // Start/Stop Service FAB
+ AnimatedVisibility(
+ visible = serviceStatus != Status.Stopping && !uiState.isSelectionMode,
+ enter = androidx.compose.animation.scaleIn(),
+ exit = androidx.compose.animation.scaleOut(),
+ ) {
+ FloatingActionButton(
+ onClick = {
+ when (serviceStatus) {
+ Status.Started, Status.Starting -> BoxService.stop()
+ Status.Stopped -> (context as ComposeActivity).startService()
+ else -> {}
+ }
+ },
+ containerColor = MaterialTheme.colorScheme.primary,
+ ) {
+ Icon(
+ imageVector =
+ when (serviceStatus) {
+ Status.Started, Status.Starting -> Icons.Default.Stop
+ else -> Icons.Default.PlayArrow
+ },
+ contentDescription =
+ when (serviceStatus) {
+ Status.Started, Status.Starting -> stringResource(R.string.stop)
+ else -> stringResource(R.string.action_start)
+ },
+ )
+ }
+ }
+ }
+ } // Close Box that contains Column, Options Menu and FAB
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun LogItem(
+ annotatedString: androidx.compose.ui.text.AnnotatedString,
+ index: Int,
+ isSelected: Boolean,
+ isSelectionMode: Boolean,
+ onLongClick: () -> Unit,
+ onClick: () -> Unit,
+) {
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = onLongClick,
+ ),
+ shape = RoundedCornerShape(4.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor =
+ if (isSelected) {
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
+ },
+ ),
+ border =
+ if (isSelected) {
+ CardDefaults.outlinedCardBorder().copy(
+ width = 2.dp,
+ brush =
+ androidx.compose.ui.graphics.SolidColor(
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
+ ),
+ )
+ } else {
+ null
+ },
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (isSelectionMode) {
+ Icon(
+ imageVector = if (isSelected) Icons.Default.CheckBox else Icons.Default.CheckBoxOutlineBlank,
+ contentDescription =
+ if (isSelected) {
+ stringResource(R.string.group_selected_title)
+ } else {
+ stringResource(
+ R.string.not_selected,
+ )
+ },
+ modifier = Modifier.padding(start = 12.dp, end = 4.dp),
+ tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Text(
+ text = annotatedString,
+ modifier =
+ Modifier
+ .weight(1f)
+ .padding(
+ start = if (isSelectionMode) 4.dp else 12.dp,
+ end = 12.dp,
+ top = 8.dp,
+ bottom = 8.dp,
+ ),
+ fontSize = 13.sp,
+ fontFamily = FontFamily.Monospace,
+ lineHeight = 18.sp,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt
new file mode 100644
index 0000000..17e6d72
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt
@@ -0,0 +1,311 @@
+package io.nekohasekai.sfa.compose.screen.log
+
+import androidx.compose.ui.text.AnnotatedString
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.nekohasekai.libbox.Libbox
+import io.nekohasekai.libbox.LogEntry
+import io.nekohasekai.sfa.compose.util.AnsiColorUtils
+import io.nekohasekai.sfa.constant.Status
+import io.nekohasekai.sfa.utils.CommandClient
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.LinkedList
+import java.util.concurrent.atomic.AtomicLong
+
+data class ProcessedLogEntry(
+ val id: Long,
+ val originalEntry: LogEntry,
+ val annotatedString: AnnotatedString,
+)
+
+enum class LogLevel(val label: String, val priority: Int) {
+ Default("Default", 7),
+
+ PANIC("Panic", 0),
+ FATAL("Fatal", 1),
+ ERROR("Error", 2),
+ WARNING("Warn", 3),
+ INFO("Info", 4),
+ DEBUG("Debug", 5),
+ TRACE("Trace", 6),
+}
+
+data class LogUiState(
+ val logs: List = emptyList(),
+ val isConnected: Boolean = false,
+ val serviceStatus: Status = Status.Stopped,
+ val isPaused: Boolean = false,
+ val searchQuery: String = "",
+ val isSearchActive: Boolean = false,
+ val defaultLogLevel: LogLevel = LogLevel.Default,
+ val filterLogLevel: LogLevel = LogLevel.Default,
+ val isOptionsMenuOpen: Boolean = false,
+ val isSelectionMode: Boolean = false,
+ val selectedLogIndices: Set = emptySet(),
+)
+
+class LogViewModel : ViewModel(), CommandClient.Handler {
+ companion object {
+ private val maxLines = 3000
+ }
+
+ private val _uiState = MutableStateFlow(LogUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _autoScrollEnabled = MutableStateFlow(true)
+ val isAtBottom: StateFlow = _autoScrollEnabled.asStateFlow()
+
+ private val _scrollToBottomTrigger = MutableStateFlow(0)
+ val scrollToBottomTrigger: StateFlow = _scrollToBottomTrigger.asStateFlow()
+
+ private val _searchQueryInternal = MutableStateFlow("")
+ private val logIdGenerator = AtomicLong(0)
+
+ private val allLogs = LinkedList()
+ private val bufferedLogs = LinkedList()
+ private val commandClient =
+ CommandClient(
+ scope = viewModelScope,
+ connectionType = CommandClient.ConnectionType.Log,
+ handler = this,
+ )
+
+ init {
+ viewModelScope.launch {
+ _searchQueryInternal
+ .debounce(300)
+ .distinctUntilChanged()
+ .collect { query ->
+ _uiState.update { it.copy(searchQuery = query) }
+ updateDisplayedLogs()
+ }
+ }
+ }
+
+ private fun processLogEntry(entry: LogEntry): ProcessedLogEntry {
+ return ProcessedLogEntry(
+ id = logIdGenerator.incrementAndGet(),
+ originalEntry = entry,
+ annotatedString = AnsiColorUtils.ansiToAnnotatedString(entry.message),
+ )
+ }
+
+ fun updateServiceStatus(status: Status) {
+ _uiState.update { it.copy(serviceStatus = status) }
+
+ when (status) {
+ Status.Started -> {
+ commandClient.connect()
+ }
+
+ Status.Stopped, Status.Stopping -> {
+ commandClient.disconnect()
+ _uiState.update { it.copy(isConnected = false) }
+ }
+
+ else -> {}
+ }
+ }
+
+ override fun onConnected() {
+ _uiState.update { it.copy(isConnected = true) }
+ }
+
+ override fun onDisconnected() {
+ _uiState.update { it.copy(isConnected = false) }
+ }
+
+ override fun setDefaultLogLevel(level: Int) {
+ val logLevel = LogLevel.entries.find { it.priority == level } ?: error("Unknown log level: $level")
+ _uiState.update { it.copy(defaultLogLevel = logLevel) }
+ updateDisplayedLogs()
+ }
+
+ override fun clearLogs() {
+ allLogs.clear()
+ bufferedLogs.clear()
+ _uiState.update { it.copy(isPaused = false) }
+ updateDisplayedLogs()
+ }
+
+ fun requestClearLogs() {
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ runCatching {
+ Libbox.newStandaloneCommandClient().clearLogs()
+ }
+ }
+ }
+ }
+
+ override fun appendLogs(message: List) {
+ val processedLogs = message.map { processLogEntry(it) }
+ if (_uiState.value.isPaused) {
+ bufferedLogs.addAll(processedLogs)
+ } else {
+ val totalSize = allLogs.size + processedLogs.size
+ val removeCount = (totalSize - maxLines).coerceAtLeast(0)
+
+ if (removeCount > 0) {
+ repeat(removeCount) {
+ allLogs.removeFirst()
+ }
+ }
+
+ allLogs.addAll(processedLogs)
+ updateDisplayedLogs()
+
+ if (_autoScrollEnabled.value && !_uiState.value.isPaused && !_uiState.value.isSearchActive) {
+ scrollToBottom()
+ }
+ }
+ }
+
+ fun togglePause() {
+ val currentState = _uiState.value
+ if (currentState.isPaused && bufferedLogs.isNotEmpty()) {
+ // When resuming, add buffered logs
+ val totalSize = allLogs.size + bufferedLogs.size
+ val removeCount = (totalSize - maxLines).coerceAtLeast(0)
+
+ if (removeCount > 0) {
+ repeat(removeCount) {
+ allLogs.removeFirst()
+ }
+ }
+
+ allLogs.addAll(bufferedLogs)
+ bufferedLogs.clear()
+ }
+
+ _uiState.update { it.copy(isPaused = !it.isPaused) }
+ updateDisplayedLogs()
+ }
+
+ fun toggleSearch() {
+ _uiState.update {
+ it.copy(
+ isSearchActive = !it.isSearchActive,
+ searchQuery = if (!it.isSearchActive) it.searchQuery else "",
+ )
+ }
+ updateDisplayedLogs()
+ }
+
+ fun updateSearchQuery(query: String) {
+ _searchQueryInternal.value = query
+ }
+
+ fun setLogLevel(level: LogLevel) {
+ _uiState.update { it.copy(filterLogLevel = level) }
+ updateDisplayedLogs()
+ }
+
+ fun toggleOptionsMenu() {
+ _uiState.update { it.copy(isOptionsMenuOpen = !it.isOptionsMenuOpen) }
+ }
+
+ fun setAutoScrollEnabled(enabled: Boolean) {
+ _autoScrollEnabled.value = enabled
+ }
+
+ fun scrollToBottom() {
+ _autoScrollEnabled.value = true
+ _scrollToBottomTrigger.value++
+ }
+
+ fun toggleSelectionMode() {
+ _uiState.update {
+ if (it.isSelectionMode) {
+ // Exit selection mode, clear selections, and resume if it was paused by selection mode
+ it.copy(isSelectionMode = false, selectedLogIndices = emptySet(), isPaused = false)
+ } else {
+ // Enter selection mode and pause log updates
+ it.copy(isSelectionMode = true, isPaused = true)
+ }
+ }
+ }
+
+ fun toggleLogSelection(index: Int) {
+ _uiState.update { state ->
+ val newSelection =
+ if (state.selectedLogIndices.contains(index)) {
+ state.selectedLogIndices - index
+ } else {
+ state.selectedLogIndices + index
+ }
+
+ // Exit selection mode and unpause if no items are selected
+ if (newSelection.isEmpty()) {
+ state.copy(
+ isSelectionMode = false,
+ selectedLogIndices = emptySet(),
+ isPaused = false,
+ )
+ } else {
+ state.copy(selectedLogIndices = newSelection)
+ }
+ }
+ }
+
+ fun clearSelection() {
+ _uiState.update {
+ it.copy(isSelectionMode = false, selectedLogIndices = emptySet(), isPaused = false)
+ }
+ }
+
+ fun getSelectedLogsText(): String {
+ val state = _uiState.value
+ return state.selectedLogIndices
+ .sorted()
+ .mapNotNull { index ->
+ state.logs.getOrNull(index)?.originalEntry?.message
+ }
+ .joinToString("\n")
+ }
+
+ fun getAllLogsText(): String {
+ return _uiState.value.logs.joinToString("\n") { it.originalEntry.message }
+ }
+
+ private fun updateDisplayedLogs() {
+ val currentState = _uiState.value
+ val levelPriority =
+ if (currentState.filterLogLevel != LogLevel.Default) {
+ currentState.filterLogLevel.priority
+ } else {
+ currentState.defaultLogLevel.priority
+ }
+ val searchQuery = currentState.searchQuery
+
+ val logsToDisplay =
+ allLogs.asSequence()
+ .filter { log -> log.originalEntry.level <= levelPriority }
+ .filter { log ->
+ searchQuery.isEmpty() || log.originalEntry.message.contains(searchQuery, ignoreCase = true)
+ }
+ .toList()
+
+ val selectionCleared =
+ if (_uiState.value.isSelectionMode && _uiState.value.logs != logsToDisplay) {
+ emptySet()
+ } else {
+ _uiState.value.selectedLogIndices
+ }
+
+ _uiState.update { it.copy(logs = logsToDisplay, selectedLogIndices = selectionCleared) }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ commandClient.disconnect()
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt
new file mode 100644
index 0000000..75e6004
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt
@@ -0,0 +1,862 @@
+package io.nekohasekai.sfa.compose.screen.profile
+
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.horizontalScroll
+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.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.Redo
+import androidx.compose.material.icons.automirrored.filled.Undo
+import androidx.compose.material.icons.filled.ArrowDownward
+import androidx.compose.material.icons.filled.ArrowUpward
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Code
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material.icons.filled.ExpandLess
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.VerticalDivider
+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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.isCtrlPressed
+import androidx.compose.ui.input.key.isMetaPressed
+import androidx.compose.ui.input.key.isShiftPressed
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.blacksquircle.ui.language.json.JsonLanguage
+import io.nekohasekai.sfa.R
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
+@Composable
+fun EditProfileContentScreen(
+ profileId: Long,
+ onNavigateBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ profileName: String = "",
+ isReadOnly: Boolean = false,
+) {
+ val viewModel: EditProfileContentViewModel =
+ viewModel(
+ factory = EditProfileContentViewModel.Factory(profileId, profileName, isReadOnly),
+ )
+ val uiState by viewModel.uiState.collectAsState()
+ val context = LocalContext.current
+ var showUnsavedChangesDialog by remember { mutableStateOf(false) }
+ val searchFocusRequester = remember { FocusRequester() }
+ val focusManager = LocalFocusManager.current
+ val coroutineScope = rememberCoroutineScope()
+
+ // Handle error messages
+ LaunchedEffect(uiState.errorMessage) {
+ uiState.errorMessage?.let { message ->
+ Toast.makeText(context, message, Toast.LENGTH_LONG).show()
+ viewModel.clearError()
+ }
+ }
+
+ // Focus search field when search bar is shown
+ LaunchedEffect(uiState.showSearchBar) {
+ if (uiState.showSearchBar) {
+ searchFocusRequester.requestFocus()
+ }
+ }
+
+ // Handle save success message
+ LaunchedEffect(uiState.showSaveSuccessMessage) {
+ if (uiState.showSaveSuccessMessage) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.configuration_saved),
+ Toast.LENGTH_SHORT,
+ ).show()
+ viewModel.clearSaveSuccessMessage()
+ }
+ }
+
+ // Handle back press when there are unsaved changes (not in read-only mode)
+ BackHandler(enabled = uiState.hasUnsavedChanges && !uiState.isReadOnly) {
+ showUnsavedChangesDialog = true
+ }
+
+ Scaffold(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .onPreviewKeyEvent { event ->
+ if (event.type == KeyEventType.KeyDown) {
+ // Support both Ctrl (Windows/Linux) and Cmd (macOS)
+ val modifierPressed = event.isCtrlPressed || event.isMetaPressed
+
+ when {
+ // Ctrl/Cmd+Z - Undo
+ modifierPressed && event.key == Key.Z && !event.isShiftPressed && !uiState.isReadOnly -> {
+ viewModel.undo()
+ true
+ }
+ // Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y - Redo
+ (
+ modifierPressed && event.isShiftPressed && event.key == Key.Z ||
+ modifierPressed && event.key == Key.Y
+ ) && !uiState.isReadOnly -> {
+ viewModel.redo()
+ true
+ }
+ // Ctrl/Cmd+S - Save
+ modifierPressed && event.key == Key.S && !uiState.isReadOnly -> {
+ if (uiState.hasUnsavedChanges && !uiState.isLoading) {
+ viewModel.saveConfiguration()
+ }
+ true
+ }
+ // Ctrl/Cmd+F - Search
+ modifierPressed && event.key == Key.F -> {
+ viewModel.toggleSearchBar()
+ true
+ }
+ // Ctrl/Cmd+A - Select All
+ modifierPressed && event.key == Key.A -> {
+ viewModel.selectAll()
+ true
+ }
+ // Ctrl/Cmd+X - Cut (only in edit mode)
+ modifierPressed && event.key == Key.X && !uiState.isReadOnly -> {
+ viewModel.cut()
+ true
+ }
+ // Ctrl/Cmd+C - Copy
+ modifierPressed && event.key == Key.C -> {
+ viewModel.copy()
+ true
+ }
+ // Ctrl/Cmd+V - Paste (only in edit mode)
+ modifierPressed && event.key == Key.V && !uiState.isReadOnly -> {
+ viewModel.paste()
+ true
+ }
+ // Escape - Close search bar if open
+ event.key == Key.Escape && uiState.showSearchBar -> {
+ viewModel.toggleSearchBar()
+ true
+ }
+ // F3 or Ctrl/Cmd+G - Find next (when search is active)
+ (event.key == Key.F3 || (modifierPressed && event.key == Key.G && !event.isShiftPressed)) &&
+ uiState.searchQuery.isNotEmpty() -> {
+ viewModel.findNext()
+ viewModel.focusEditor()
+ true
+ }
+ // Shift+F3 or Ctrl/Cmd+Shift+G - Find previous (when search is active)
+ (
+ (event.isShiftPressed && event.key == Key.F3) ||
+ (modifierPressed && event.isShiftPressed && event.key == Key.G)
+ ) &&
+ uiState.searchQuery.isNotEmpty() -> {
+ viewModel.findPrevious()
+ viewModel.focusEditor()
+ true
+ }
+
+ else -> false
+ }
+ } else {
+ false
+ }
+ },
+ topBar = {
+ TopAppBar(
+ title = {
+ Column {
+ Text(
+ if (uiState.isReadOnly) {
+ stringResource(R.string.view_configuration)
+ } else {
+ stringResource(R.string.title_edit_configuration)
+ },
+ )
+ if (uiState.profileName.isNotEmpty()) {
+ Text(
+ text = uiState.profileName,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ },
+ navigationIcon = {
+ IconButton(
+ onClick = {
+ if (uiState.hasUnsavedChanges && !uiState.isReadOnly) {
+ showUnsavedChangesDialog = true
+ } else {
+ onNavigateBack()
+ }
+ },
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.content_description_back),
+ )
+ }
+ },
+ actions = {
+ // Search/Collapse button (Ctrl/Cmd+F)
+ IconButton(
+ onClick = { viewModel.toggleSearchBar() },
+ ) {
+ Icon(
+ imageVector = if (uiState.showSearchBar) Icons.Default.ExpandLess else Icons.Default.Search,
+ contentDescription =
+ if (uiState.showSearchBar) {
+ stringResource(R.string.content_description_collapse_search)
+ } else {
+ stringResource(R.string.search)
+ },
+ tint =
+ if (uiState.showSearchBar) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ )
+ }
+
+ // Save button (only show if not read-only) (Ctrl/Cmd+S)
+ if (!uiState.isReadOnly) {
+ IconButton(
+ onClick = { viewModel.saveConfiguration() },
+ enabled = uiState.hasUnsavedChanges && !uiState.isLoading,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Save,
+ contentDescription = stringResource(R.string.save),
+ tint =
+ if (uiState.hasUnsavedChanges) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
+ },
+ )
+ }
+ }
+ },
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ )
+ },
+ ) { paddingValues ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ ) {
+ // Search bar (appears at top when activated)
+ AnimatedVisibility(
+ visible = uiState.showSearchBar,
+ enter = slideInVertically(initialOffsetY = { -it }) + fadeIn() + expandVertically(),
+ exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() + shrinkVertically(),
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ shadowElevation = 4.dp,
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 12.dp, end = 12.dp, bottom = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedTextField(
+ value = uiState.searchQuery,
+ onValueChange = { viewModel.updateSearchQuery(it) },
+ modifier =
+ Modifier
+ .weight(1f)
+ .focusRequester(searchFocusRequester)
+ .onPreviewKeyEvent { event ->
+ if (event.key == Key.Enter && event.type == KeyEventType.KeyDown) {
+ coroutineScope.launch {
+ // Clear focus from search field first
+ focusManager.clearFocus()
+ // Small delay to let UI update
+ delay(100)
+ // Then focus editor with current search result selection
+ viewModel.focusEditorWithCurrentSearchResult()
+ }
+ true
+ } else {
+ false
+ }
+ },
+ label = { Text(stringResource(R.string.search)) },
+ placeholder = { Text(stringResource(R.string.search_placeholder)) },
+ singleLine = true,
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ trailingIcon = {
+ if (uiState.searchQuery.isNotEmpty()) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text =
+ if (uiState.searchResultCount > 0) {
+ "${uiState.currentSearchIndex}/${uiState.searchResultCount}"
+ } else {
+ "0/0"
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(end = 4.dp),
+ )
+ IconButton(
+ onClick = {
+ // Focus editor with current selection before clearing search
+ viewModel.focusEditorWithCurrentSearchResult()
+ viewModel.updateSearchQuery("")
+ focusManager.clearFocus()
+ },
+ modifier = Modifier.size(24.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = stringResource(R.string.clear),
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+ }
+ },
+ )
+
+ // Only show navigation buttons when there are search results
+ if (uiState.searchQuery.isNotEmpty() && uiState.searchResultCount > 0) {
+ Spacer(modifier = Modifier.width(8.dp))
+
+ IconButton(
+ onClick = {
+ viewModel.findPrevious()
+ viewModel.focusEditor()
+ },
+ ) {
+ Icon(
+ imageVector = Icons.Default.ArrowUpward,
+ contentDescription = stringResource(R.string.previous),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+
+ IconButton(
+ onClick = {
+ viewModel.findNext()
+ viewModel.focusEditor()
+ },
+ ) {
+ Icon(
+ imageVector = Icons.Default.ArrowDownward,
+ contentDescription = stringResource(R.string.next),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Editor in a Box with floating elements
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .weight(1f),
+ ) {
+ // Editor
+ AndroidView(
+ factory = { context ->
+ ManualScrollTextProcessor(context).apply {
+ language = JsonLanguage()
+ setTextSize(14f)
+ setPadding(16, 16, 16, if (uiState.isReadOnly) 16 else 120) // Less padding for read-only
+ typeface = android.graphics.Typeface.MONOSPACE
+ setBackgroundColor(
+ androidx.core.content.ContextCompat.getColor(context, android.R.color.transparent),
+ )
+ // Set up the editor with read-only state - this handles all configuration
+ viewModel.setEditor(this, uiState.isReadOnly)
+ }
+ },
+ update = { textProcessor ->
+ // Re-apply configuration when read-only state changes
+ viewModel.setEditor(textProcessor, uiState.isReadOnly)
+ },
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ )
+
+ // Simple loading indicator at the top
+ if (uiState.isLoading) {
+ LinearProgressIndicator(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .align(Alignment.TopCenter),
+ )
+ }
+
+ // Floating bottom editor bar with error banner (only show if not read-only)
+ if (!uiState.isReadOnly) {
+ Column(
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth(),
+ ) {
+ // Configuration error banner (appears above the symbol bar)
+ AnimatedVisibility(
+ visible = uiState.configurationError != null,
+ enter = slideInVertically { it } + fadeIn(),
+ exit = slideOutVertically { it } + fadeOut(),
+ ) {
+ Surface(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp)
+ .padding(bottom = 2.dp),
+ shape = RoundedCornerShape(12.dp),
+ tonalElevation = 6.dp,
+ shadowElevation = 4.dp,
+ color = MaterialTheme.colorScheme.errorContainer,
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ // Match symbol bar padding
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Error,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.size(20.dp),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = uiState.configurationError ?: "",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.weight(1f),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ IconButton(
+ onClick = { viewModel.dismissConfigurationError() },
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = stringResource(R.string.dismiss),
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+ }
+ }
+
+ // Symbol input bar
+ Surface(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 8.dp),
+ shape = RoundedCornerShape(12.dp),
+ tonalElevation = 6.dp,
+ shadowElevation = 4.dp,
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState())
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Undo button with text
+ TextButton(
+ onClick = { viewModel.undo() },
+ enabled = uiState.canUndo,
+ modifier = Modifier.padding(end = 4.dp),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Default.Undo,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint =
+ if (uiState.canUndo) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
+ },
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = stringResource(R.string.menu_undo),
+ style = MaterialTheme.typography.labelLarge,
+ color =
+ if (uiState.canUndo) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
+ },
+ )
+ }
+
+ // Redo button with text
+ TextButton(
+ onClick = { viewModel.redo() },
+ enabled = uiState.canRedo,
+ modifier = Modifier.padding(end = 4.dp),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Default.Redo,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint =
+ if (uiState.canRedo) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
+ },
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = stringResource(R.string.menu_redo),
+ style = MaterialTheme.typography.labelLarge,
+ color =
+ if (uiState.canRedo) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
+ },
+ )
+ }
+
+ // Format button with text
+ TextButton(
+ onClick = { viewModel.formatConfiguration() },
+ modifier = Modifier.padding(end = 8.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Code,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = stringResource(R.string.menu_format),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+
+ VerticalDivider(
+ modifier =
+ Modifier
+ .height(24.dp)
+ .padding(horizontal = 8.dp),
+ )
+
+ // Symbols ranked by frequency of use in JSON
+
+ // Most common - quotes and colon (used for every key-value pair)
+ TextButton(
+ onClick = { viewModel.insertSymbol("\"") },
+ modifier =
+ Modifier
+ .padding(0.dp)
+ .height(36.dp)
+ .width(36.dp),
+ shape = RoundedCornerShape(4.dp),
+ contentPadding = PaddingValues(0.dp),
+ ) {
+ Text(
+ text = "\"",
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+
+ TextButton(
+ onClick = { viewModel.insertSymbol(":") },
+ modifier =
+ Modifier
+ .padding(0.dp)
+ .height(36.dp)
+ .width(36.dp),
+ shape = RoundedCornerShape(4.dp),
+ contentPadding = PaddingValues(0.dp),
+ ) {
+ Text(
+ text = ":",
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+
+ TextButton(
+ onClick = { viewModel.insertSymbol(",") },
+ modifier =
+ Modifier
+ .padding(0.dp)
+ .height(36.dp)
+ .width(36.dp),
+ shape = RoundedCornerShape(4.dp),
+ contentPadding = PaddingValues(0.dp),
+ ) {
+ Text(
+ text = ",",
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+
+ Spacer(modifier = Modifier.width(4.dp))
+
+ // Object brackets (very common)
+ TextButton(
+ onClick = { viewModel.insertSymbol("{") },
+ modifier =
+ Modifier
+ .padding(0.dp)
+ .height(36.dp)
+ .width(36.dp),
+ shape = RoundedCornerShape(4.dp),
+ contentPadding = PaddingValues(0.dp),
+ ) {
+ Text(
+ text = "{",
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+
+ TextButton(
+ onClick = { viewModel.insertSymbol("}") },
+ modifier =
+ Modifier
+ .padding(0.dp)
+ .height(36.dp)
+ .width(36.dp),
+ shape = RoundedCornerShape(4.dp),
+ contentPadding = PaddingValues(0.dp),
+ ) {
+ Text(
+ text = "}",
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+
+ Spacer(modifier = Modifier.width(4.dp))
+
+ // Array brackets (common)
+ TextButton(
+ onClick = { viewModel.insertSymbol("[") },
+ modifier =
+ Modifier
+ .padding(0.dp)
+ .height(36.dp)
+ .width(36.dp),
+ shape = RoundedCornerShape(4.dp),
+ contentPadding = PaddingValues(0.dp),
+ ) {
+ Text(
+ text = "[",
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+
+ TextButton(
+ onClick = { viewModel.insertSymbol("]") },
+ modifier =
+ Modifier
+ .padding(0.dp)
+ .height(36.dp)
+ .width(36.dp),
+ shape = RoundedCornerShape(4.dp),
+ contentPadding = PaddingValues(0.dp),
+ ) {
+ Text(
+ text = "]",
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+
+ Spacer(modifier = Modifier.width(4.dp))
+
+ // Common values - using same TextButton style for keywords
+ listOf("true", "false").forEach { text ->
+ TextButton(
+ onClick = { viewModel.insertSymbol(text) },
+ modifier =
+ Modifier
+ .padding(0.dp)
+ .height(36.dp),
+ shape = RoundedCornerShape(4.dp),
+ contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
+ ) {
+ Text(
+ text = text,
+ fontWeight = FontWeight.Medium,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(4.dp))
+
+ // Less common symbols - same TextButton style
+ listOf("-", "_", "/", "\\", "(", ")", "@", "#", "$", "%", "&", "*").forEach { symbol ->
+ TextButton(
+ onClick = { viewModel.insertSymbol(symbol) },
+ modifier =
+ Modifier
+ .padding(0.dp)
+ .height(36.dp)
+ .width(36.dp),
+ shape = RoundedCornerShape(4.dp),
+ contentPadding = PaddingValues(0.dp),
+ ) {
+ Text(
+ text = symbol,
+ fontWeight = FontWeight.Medium,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ // End padding for scroll
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Unsaved changes dialog
+ if (showUnsavedChangesDialog) {
+ AlertDialog(
+ onDismissRequest = { showUnsavedChangesDialog = false },
+ title = { Text(stringResource(R.string.unsaved_changes)) },
+ text = { Text(stringResource(R.string.unsaved_changes_message)) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showUnsavedChangesDialog = false
+ onNavigateBack()
+ },
+ ) {
+ Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error)
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = { showUnsavedChangesDialog = false },
+ ) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ )
+ }
+
+ // Initial loading
+ LaunchedEffect(profileId) {
+ viewModel.loadConfiguration()
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt
new file mode 100644
index 0000000..4a0155c
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt
@@ -0,0 +1,614 @@
+package io.nekohasekai.sfa.compose.screen.profile
+
+import androidx.core.widget.addTextChangedListener
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import io.nekohasekai.libbox.Libbox
+import io.nekohasekai.sfa.database.Profile
+import io.nekohasekai.sfa.database.ProfileManager
+import io.nekohasekai.sfa.ktx.unwrap
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+
+data class EditProfileContentUiState(
+ val isLoading: Boolean = false,
+ val content: String = "",
+ val originalContent: String = "",
+ val hasUnsavedChanges: Boolean = false,
+ val canUndo: Boolean = false,
+ val canRedo: Boolean = false,
+ val showSaveSuccessMessage: Boolean = false,
+ val errorMessage: String? = null,
+ val configurationError: String? = null,
+ val isCheckingConfig: Boolean = false,
+ val showSearchBar: Boolean = false,
+ val searchQuery: String = "",
+ val searchResultCount: Int = 0,
+ val currentSearchIndex: Int = 0,
+ val isReadOnly: Boolean = false, // Add read-only flag
+ val profileName: String = "", // Add profile name
+)
+
+class EditProfileContentViewModel(
+ private val profileId: Long,
+ initialProfileName: String = "",
+ initialIsReadOnly: Boolean = false,
+) : ViewModel() {
+ private val _uiState =
+ MutableStateFlow(
+ EditProfileContentUiState(
+ profileName = initialProfileName,
+ isReadOnly = initialIsReadOnly,
+ ),
+ )
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var profile: Profile? = null
+ private var editor: ManualScrollTextProcessor? = null
+ private var configCheckJob: Job? = null
+
+ fun setEditor(
+ textProcessor: ManualScrollTextProcessor,
+ isReadOnly: Boolean = false,
+ ) {
+ val isNewEditor = editor != textProcessor
+ editor = textProcessor
+ textProcessor.resumeAutoScroll()
+
+ // Always keep these for scrolling, focus, and selection
+ textProcessor.isEnabled = true
+ textProcessor.isFocusable = true
+ textProcessor.isFocusableInTouchMode = true
+
+ // Allow text selection for copying
+ textProcessor.setTextIsSelectable(true)
+
+ // Multi-line configuration
+ textProcessor.setSingleLine(false)
+ textProcessor.maxLines = Integer.MAX_VALUE
+ textProcessor.inputType = android.text.InputType.TYPE_CLASS_TEXT or
+ android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE or
+ android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
+ textProcessor.isCursorVisible = true
+
+ if (isReadOnly) {
+ // Use a custom OnKeyListener that blocks all key input
+ textProcessor.setOnKeyListener { _, _, _ -> true } // Return true to consume all key events
+ // Enable long click for selection
+ textProcessor.isLongClickable = true
+
+ // Customize text selection to remove Cut and Paste options
+ textProcessor.customSelectionActionModeCallback =
+ object : android.view.ActionMode.Callback {
+ override fun onCreateActionMode(
+ mode: android.view.ActionMode?,
+ menu: android.view.Menu?,
+ ): Boolean {
+ // Allow the action mode to be created
+ return true
+ }
+
+ override fun onPrepareActionMode(
+ mode: android.view.ActionMode?,
+ menu: android.view.Menu?,
+ ): Boolean {
+ // Remove editing-related menu items, keep only Copy and Select All
+ menu?.let { m ->
+ // Remove all editing-related items
+ m.removeItem(android.R.id.cut)
+ m.removeItem(android.R.id.paste)
+ m.removeItem(android.R.id.pasteAsPlainText)
+ m.removeItem(android.R.id.replaceText)
+ m.removeItem(android.R.id.undo)
+ m.removeItem(android.R.id.redo)
+ m.removeItem(android.R.id.autofill)
+ m.removeItem(android.R.id.textAssist)
+ }
+ return true
+ }
+
+ override fun onActionItemClicked(
+ mode: android.view.ActionMode?,
+ item: android.view.MenuItem?,
+ ): Boolean {
+ // Let the default implementation handle allowed actions (copy, select all)
+ return false
+ }
+
+ override fun onDestroyActionMode(mode: android.view.ActionMode?) {
+ // No special cleanup needed
+ }
+ }
+ } else {
+ // For editable mode, remove the blocking listener
+ textProcessor.setOnKeyListener(null)
+ // Remove the custom selection callback to allow all text operations
+ textProcessor.customSelectionActionModeCallback = null
+
+ // Only add text change listener for new editors in editable mode
+ if (isNewEditor) {
+ textProcessor.addTextChangedListener { editable ->
+ val currentText = editable?.toString() ?: ""
+ _uiState.update { state ->
+ state.copy(
+ content = currentText,
+ canUndo = textProcessor.canUndo(),
+ canRedo = textProcessor.canRedo(),
+ hasUnsavedChanges = currentText != state.originalContent,
+ )
+ }
+
+ // Schedule background configuration check
+ scheduleConfigurationCheck(currentText)
+ }
+ }
+ }
+ }
+
+ private fun scheduleConfigurationCheck(content: String) {
+ // Cancel previous check
+ configCheckJob?.cancel()
+
+ // Clear error immediately when user is typing
+ _uiState.update { it.copy(configurationError = null) }
+
+ // Schedule new check after 2 seconds of inactivity
+ configCheckJob =
+ viewModelScope.launch {
+ delay(2000) // Wait 2 seconds
+
+ // Check configuration in background
+ checkConfigurationInBackground(content)
+ }
+ }
+
+ private suspend fun checkConfigurationInBackground(content: String) {
+ if (content.isBlank()) {
+ // Don't check empty content
+ return
+ }
+
+ withContext(Dispatchers.IO) {
+ try {
+ _uiState.update { it.copy(isCheckingConfig = true) }
+
+ // Check configuration
+ Libbox.checkConfig(content)
+
+ // Configuration is valid, clear any error
+ _uiState.update {
+ it.copy(
+ configurationError = null,
+ isCheckingConfig = false,
+ )
+ }
+ } catch (e: Exception) {
+ // Configuration has errors, show them
+ _uiState.update {
+ it.copy(
+ configurationError = e.message ?: "Invalid configuration",
+ isCheckingConfig = false,
+ )
+ }
+ }
+ }
+ }
+
+ fun loadConfiguration() {
+ viewModelScope.launch(Dispatchers.IO) {
+ _uiState.update { it.copy(isLoading = true) }
+
+ try {
+ val loadedProfile =
+ ProfileManager.get(profileId)
+ ?: throw IllegalArgumentException("Profile not found")
+ profile = loadedProfile
+
+ // Just load the content, we already have profile metadata from Intent
+ val content = File(loadedProfile.typed.path).readText()
+
+ withContext(Dispatchers.Main) {
+ editor?.let {
+ it.resumeAutoScroll()
+ it.setTextContent(content)
+ }
+ _uiState.update {
+ it.copy(
+ content = content,
+ originalContent = content,
+ hasUnsavedChanges = false,
+ isLoading = false,
+ // Keep profileName and isReadOnly from initial state - no need to update
+ )
+ }
+ }
+ } catch (e: Exception) {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorMessage = e.message ?: "Failed to load configuration",
+ )
+ }
+ }
+ }
+ }
+
+ fun saveConfiguration() {
+ viewModelScope.launch(Dispatchers.IO) {
+ _uiState.update { it.copy(isLoading = true) }
+
+ try {
+ val currentContent =
+ withContext(Dispatchers.Main) {
+ editor?.text?.toString() ?: ""
+ }
+
+ // Save to file without validation
+ profile?.let { p ->
+ File(p.typed.path).writeText(currentContent)
+ }
+
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ originalContent = currentContent,
+ hasUnsavedChanges = false,
+ showSaveSuccessMessage = true,
+ )
+ }
+
+ // Hide success message after delay
+ delay(2000)
+ _uiState.update { it.copy(showSaveSuccessMessage = false) }
+ } catch (e: Exception) {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorMessage = e.message ?: "Save failed",
+ )
+ }
+ }
+ }
+ }
+
+ fun formatConfiguration() {
+ viewModelScope.launch(Dispatchers.IO) {
+ _uiState.update { it.copy(isLoading = true) }
+
+ try {
+ val currentContent =
+ withContext(Dispatchers.Main) {
+ editor?.text?.toString() ?: ""
+ }
+ val formatted = Libbox.formatConfig(currentContent).unwrap
+
+ if (formatted != currentContent) {
+ withContext(Dispatchers.Main) {
+ editor?.let {
+ it.resumeAutoScroll()
+ it.setTextContent(formatted)
+ }
+ }
+ // Note: hasUnsavedChanges will be updated by the text change listener
+ }
+
+ _uiState.update { it.copy(isLoading = false) }
+ } catch (e: Exception) {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorMessage = e.message ?: "Format failed",
+ )
+ }
+ }
+ }
+ }
+
+ fun undo() {
+ editor?.let {
+ if (it.canUndo()) {
+ it.resumeAutoScroll()
+ it.undo()
+ _uiState.update { state ->
+ state.copy(
+ canUndo = it.canUndo(),
+ canRedo = it.canRedo(),
+ )
+ }
+ }
+ }
+ }
+
+ fun redo() {
+ editor?.let {
+ if (it.canRedo()) {
+ it.resumeAutoScroll()
+ it.redo()
+ _uiState.update { state ->
+ state.copy(
+ canUndo = it.canUndo(),
+ canRedo = it.canRedo(),
+ )
+ }
+ }
+ }
+ }
+
+ fun clearError() {
+ _uiState.update { it.copy(errorMessage = null) }
+ }
+
+ fun clearSaveSuccessMessage() {
+ _uiState.update { it.copy(showSaveSuccessMessage = false) }
+ }
+
+ fun dismissConfigurationError() {
+ _uiState.update { it.copy(configurationError = null) }
+ }
+
+ fun toggleSearchBar() {
+ _uiState.update {
+ val newShowSearchBar = !it.showSearchBar
+ it.copy(
+ showSearchBar = newShowSearchBar,
+ searchQuery = "",
+ searchResultCount = 0,
+ currentSearchIndex = 0,
+ )
+ }
+ }
+
+ fun updateSearchQuery(query: String) {
+ _uiState.update { it.copy(searchQuery = query) }
+ if (query.isNotEmpty()) {
+ performSearch(query)
+ } else {
+ _uiState.update {
+ it.copy(
+ searchResultCount = 0,
+ currentSearchIndex = 0,
+ )
+ }
+ }
+ }
+
+ private fun performSearch(query: String) {
+ editor?.let { textProcessor ->
+ val text = textProcessor.text?.toString() ?: ""
+ if (text.isEmpty() || query.isEmpty()) {
+ _uiState.update {
+ it.copy(
+ searchResultCount = 0,
+ currentSearchIndex = 0,
+ )
+ }
+ return
+ }
+
+ val matches = mutableListOf()
+ var index = text.indexOf(query, ignoreCase = true)
+ while (index != -1) {
+ matches.add(index)
+ index = text.indexOf(query, index + 1, ignoreCase = true)
+ }
+
+ _uiState.update {
+ it.copy(
+ searchResultCount = matches.size,
+ currentSearchIndex = if (matches.isNotEmpty()) 1 else 0,
+ )
+ }
+
+ // Highlight first match
+ if (matches.isNotEmpty()) {
+ val firstMatch = matches[0]
+ textProcessor.resumeAutoScroll()
+ textProcessor.setSelection(firstMatch, firstMatch + query.length)
+ }
+ }
+ }
+
+ fun findNext() {
+ val state = _uiState.value
+ if (state.searchResultCount == 0 || state.searchQuery.isEmpty()) return
+
+ editor?.let { textProcessor ->
+ val text = textProcessor.text?.toString() ?: ""
+ val currentPosition = textProcessor.selectionEnd
+
+ var nextIndex = text.indexOf(state.searchQuery, currentPosition, ignoreCase = true)
+ if (nextIndex == -1) {
+ // Wrap around to beginning
+ nextIndex = text.indexOf(state.searchQuery, 0, ignoreCase = true)
+ }
+
+ if (nextIndex != -1) {
+ textProcessor.resumeAutoScroll()
+ textProcessor.setSelection(nextIndex, nextIndex + state.searchQuery.length)
+
+ // Update current index
+ val matches = mutableListOf()
+ var index = text.indexOf(state.searchQuery, ignoreCase = true)
+ var currentMatchIndex = 0
+ var counter = 0
+ while (index != -1) {
+ if (index == nextIndex) {
+ currentMatchIndex = counter + 1
+ }
+ matches.add(index)
+ counter++
+ index = text.indexOf(state.searchQuery, index + 1, ignoreCase = true)
+ }
+
+ _uiState.update {
+ it.copy(currentSearchIndex = currentMatchIndex)
+ }
+ }
+ }
+ }
+
+ fun findPrevious() {
+ val state = _uiState.value
+ if (state.searchResultCount == 0 || state.searchQuery.isEmpty()) return
+
+ editor?.let { textProcessor ->
+ val text = textProcessor.text?.toString() ?: ""
+ val currentPosition = textProcessor.selectionStart
+
+ var prevIndex = text.lastIndexOf(state.searchQuery, currentPosition - 1, ignoreCase = true)
+ if (prevIndex == -1) {
+ // Wrap around to end
+ prevIndex = text.lastIndexOf(state.searchQuery, ignoreCase = true)
+ }
+
+ if (prevIndex != -1) {
+ textProcessor.resumeAutoScroll()
+ textProcessor.setSelection(prevIndex, prevIndex + state.searchQuery.length)
+
+ // Update current index
+ val matches = mutableListOf()
+ var index = text.indexOf(state.searchQuery, ignoreCase = true)
+ var currentMatchIndex = 0
+ var counter = 0
+ while (index != -1) {
+ if (index == prevIndex) {
+ currentMatchIndex = counter + 1
+ }
+ matches.add(index)
+ counter++
+ index = text.indexOf(state.searchQuery, index + 1, ignoreCase = true)
+ }
+
+ _uiState.update {
+ it.copy(currentSearchIndex = currentMatchIndex)
+ }
+ }
+ }
+ }
+
+ fun insertSymbol(symbol: String) {
+ editor?.let { textProcessor ->
+ val start = textProcessor.selectionStart
+ val end = textProcessor.selectionEnd
+ val text = textProcessor.text
+
+ if (text != null) {
+ val newText =
+ StringBuilder(text)
+ .replace(start, end, symbol)
+ .toString()
+
+ textProcessor.resumeAutoScroll()
+ textProcessor.setTextContent(newText)
+ // Place cursor after the inserted symbol
+ textProcessor.setSelection(start + symbol.length)
+ }
+ }
+ }
+
+ fun focusEditor() {
+ editor?.let { textProcessor ->
+ // Ensure the editor is focusable
+ textProcessor.isFocusable = true
+ textProcessor.isFocusableInTouchMode = true
+ textProcessor.resumeAutoScroll()
+ textProcessor.requestFocus()
+
+ // Keep the current selection if there's a search active
+ if (_uiState.value.searchQuery.isNotEmpty() && _uiState.value.searchResultCount > 0) {
+ // Selection is already set by search, just request focus
+ textProcessor.requestFocus()
+ } else if (!_uiState.value.isReadOnly) {
+ // No search active and not read-only, place cursor at current position
+ val currentPosition = textProcessor.selectionEnd
+ textProcessor.setSelection(currentPosition)
+ }
+ }
+ }
+
+ fun focusEditorWithCurrentSearchResult() {
+ editor?.let { textProcessor ->
+ // Ensure the editor is focusable
+ textProcessor.isFocusable = true
+ textProcessor.isFocusableInTouchMode = true
+ textProcessor.resumeAutoScroll()
+
+ val state = _uiState.value
+ if (state.searchQuery.isNotEmpty() && state.searchResultCount > 0) {
+ // Make sure current search result is selected
+ val text = textProcessor.text?.toString() ?: ""
+ val currentSelection = textProcessor.selectionStart
+
+ // Find which match is currently selected or find the nearest one
+ var matchIndex = text.indexOf(state.searchQuery, currentSelection, ignoreCase = true)
+ if (matchIndex == -1 && currentSelection > 0) {
+ // Try from the beginning if no match found after cursor
+ matchIndex = text.indexOf(state.searchQuery, 0, ignoreCase = true)
+ }
+
+ if (matchIndex != -1) {
+ textProcessor.setSelection(matchIndex, matchIndex + state.searchQuery.length)
+ }
+ }
+ textProcessor.requestFocus()
+ }
+ }
+
+ fun selectAll() {
+ editor?.let { textProcessor ->
+ val text = textProcessor.text?.toString() ?: ""
+ if (text.isNotEmpty()) {
+ textProcessor.resumeAutoScroll()
+ textProcessor.setSelection(0, text.length)
+ textProcessor.requestFocus()
+ }
+ }
+ }
+
+ fun cut() {
+ editor?.let { textProcessor ->
+ if (textProcessor.hasSelection()) {
+ textProcessor.onTextContextMenuItem(android.R.id.cut)
+ }
+ }
+ }
+
+ fun copy() {
+ editor?.let { textProcessor ->
+ if (textProcessor.hasSelection()) {
+ textProcessor.onTextContextMenuItem(android.R.id.copy)
+ }
+ }
+ }
+
+ fun paste() {
+ editor?.let { textProcessor ->
+ if (!_uiState.value.isReadOnly) {
+ textProcessor.onTextContextMenuItem(android.R.id.paste)
+ }
+ }
+ }
+
+ class Factory(
+ private val profileId: Long,
+ private val initialProfileName: String = "",
+ private val initialIsReadOnly: Boolean = false,
+ ) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ if (modelClass.isAssignableFrom(EditProfileContentViewModel::class.java)) {
+ return EditProfileContentViewModel(profileId, initialProfileName, initialIsReadOnly) as T
+ }
+ throw IllegalArgumentException("Unknown ViewModel class")
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt
new file mode 100644
index 0000000..aa11b10
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt
@@ -0,0 +1,565 @@
+package io.nekohasekai.sfa.compose.screen.profile
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+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.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+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.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.CloudDownload
+import androidx.compose.material.icons.filled.Code
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material.icons.filled.Update
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+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.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.compose.util.ProfileIcons
+import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
+import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary
+import io.nekohasekai.sfa.database.TypedProfile
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EditProfileScreen(
+ profileId: Long,
+ onNavigateBack: () -> Unit,
+ onNavigateToIconSelection: (currentIconId: String?) -> Unit = {},
+ onNavigateToEditContent: (profileName: String, isReadOnly: Boolean) -> Unit = { _, _ -> },
+ viewModel: EditProfileViewModel = viewModel(),
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+
+ // Clear success indicator after delay
+ LaunchedEffect(uiState.showUpdateSuccess) {
+ if (uiState.showUpdateSuccess) {
+ kotlinx.coroutines.delay(1500)
+ viewModel.clearUpdateSuccess()
+ }
+ }
+
+ // Dialog states
+ var showErrorDialog by remember { mutableStateOf(false) }
+ var showUnsavedChangesDialog by remember { mutableStateOf(false) }
+
+ // Launch icon selection screen when needed
+ if (uiState.showIconDialog) {
+ LaunchedEffect(Unit) {
+ viewModel.hideIconDialog()
+ onNavigateToIconSelection(uiState.icon)
+ }
+ }
+
+ // Show error dialog when there's an error message
+ LaunchedEffect(uiState.errorMessage) {
+ if (uiState.errorMessage != null) {
+ showErrorDialog = true
+ }
+ }
+
+ // Error dialog
+ if (showErrorDialog) {
+ AlertDialog(
+ onDismissRequest = {
+ showErrorDialog = false
+ viewModel.clearError()
+ },
+ title = { Text(stringResource(R.string.error_title)) },
+ text = { Text(uiState.errorMessage ?: "") },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showErrorDialog = false
+ viewModel.clearError()
+ },
+ ) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ )
+ }
+
+ // Unsaved changes dialog
+ if (showUnsavedChangesDialog) {
+ AlertDialog(
+ onDismissRequest = { showUnsavedChangesDialog = false },
+ title = { Text(stringResource(R.string.unsaved_changes)) },
+ text = { Text(stringResource(R.string.unsaved_changes_message)) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showUnsavedChangesDialog = false
+ onNavigateBack()
+ },
+ ) {
+ Text(
+ stringResource(R.string.discard),
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = { showUnsavedChangesDialog = false },
+ ) {
+ Text(stringResource(android.R.string.cancel))
+ }
+ },
+ )
+ }
+
+ // Handle back navigation
+ val handleBack = {
+ if (uiState.hasChanges) {
+ showUnsavedChangesDialog = true
+ } else {
+ onNavigateBack()
+ }
+ }
+
+ // Intercept system back button
+ BackHandler(enabled = uiState.hasChanges) {
+ showUnsavedChangesDialog = true
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.title_edit_profile)) },
+ navigationIcon = {
+ IconButton(onClick = handleBack) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.content_description_back),
+ )
+ }
+ },
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ )
+ },
+ bottomBar = {
+ AnimatedVisibility(
+ visible = uiState.hasChanges,
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically(),
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 3.dp,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets.navigationBars)
+ .padding(16.dp),
+ ) {
+ Button(
+ onClick = { viewModel.saveChanges() },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !uiState.isSaving && uiState.autoUpdateIntervalError == null,
+ ) {
+ if (uiState.isSaving) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(18.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary,
+ )
+ } else {
+ Icon(
+ Icons.Default.Save,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(R.string.save))
+ }
+ }
+ }
+ }
+ }
+ },
+ ) { paddingValues ->
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ ) {
+ // Progress indicator at top (only for initial loading)
+ if (uiState.isLoading) {
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+
+ if (!uiState.isLoading) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ // Basic Information Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
+ ),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.basic_information),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+
+ OutlinedTextField(
+ value = uiState.name,
+ onValueChange = viewModel::updateName,
+ label = { Text(stringResource(R.string.profile_name)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ )
+
+ HorizontalDivider(
+ modifier = Modifier.padding(vertical = 4.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+
+ // Icon selection with Material You style
+ Text(
+ text = stringResource(R.string.icon),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 4.dp),
+ )
+
+ Surface(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .clickable { viewModel.showIconDialog() },
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ // Display current icon
+ val currentIcon =
+ ProfileIcons.getIconById(uiState.icon)
+ ?: Icons.AutoMirrored.Filled.InsertDriveFile
+
+ Icon(
+ imageVector = currentIcon,
+ contentDescription = stringResource(R.string.profile_icon),
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+
+ Text(
+ text =
+ uiState.icon?.let { iconId ->
+ MaterialIconsLibrary.getAllIcons()
+ .find { it.id == iconId }?.label
+ } ?: stringResource(R.string.default_text),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f),
+ )
+
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = stringResource(R.string.select_icon),
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+
+ // Remote Profile Options
+ if (uiState.profileType == TypedProfile.Type.Remote) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f),
+ ),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ Icons.Default.CloudDownload,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.size(20.dp),
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.remote_configuration),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.tertiary,
+ )
+ uiState.lastUpdated?.let { lastUpdated ->
+ Text(
+ text =
+ stringResource(
+ R.string.last_updated_format,
+ RelativeTimeFormatter.format(
+ context,
+ lastUpdated,
+ ),
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ // Update button in top-right corner
+ IconButton(
+ onClick = { viewModel.updateRemoteProfile() },
+ enabled = !uiState.isUpdating && !uiState.showUpdateSuccess,
+ ) {
+ when {
+ uiState.isUpdating -> {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.dp,
+ )
+ }
+ uiState.showUpdateSuccess -> {
+ Icon(
+ Icons.Default.Check,
+ contentDescription = stringResource(R.string.success),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ else -> {
+ Icon(
+ Icons.Default.Update,
+ contentDescription = stringResource(R.string.profile_update),
+ tint = MaterialTheme.colorScheme.tertiary,
+ )
+ }
+ }
+ }
+ }
+
+ OutlinedTextField(
+ value = uiState.remoteUrl,
+ onValueChange = viewModel::updateRemoteUrl,
+ label = { Text(stringResource(R.string.profile_url)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ )
+
+ HorizontalDivider()
+
+ // Auto Update Toggle
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = stringResource(R.string.profile_auto_update),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Switch(
+ checked = uiState.autoUpdate,
+ onCheckedChange = viewModel::updateAutoUpdate,
+ )
+ }
+
+ AnimatedVisibility(visible = uiState.autoUpdate) {
+ OutlinedTextField(
+ value = uiState.autoUpdateInterval.toString(),
+ onValueChange = viewModel::updateAutoUpdateInterval,
+ label = { Text(stringResource(R.string.profile_auto_update_interval)) },
+ supportingText = {
+ uiState.autoUpdateIntervalError?.let { error ->
+ Text(
+ text = error,
+ color = MaterialTheme.colorScheme.error,
+ )
+ } ?: Text(stringResource(R.string.profile_auto_update_interval_minimum_hint))
+ },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ isError = uiState.autoUpdateIntervalError != null,
+ )
+ }
+ }
+ }
+ }
+
+ // Content Card (for both Local and Remote profiles) - placed at the end
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f),
+ ),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.InsertDriveFile,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.secondary,
+ modifier = Modifier.size(20.dp),
+ )
+ Text(
+ text = stringResource(R.string.content),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.secondary,
+ )
+ }
+
+ // JSON Editor/Viewer option
+ Surface(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .clickable {
+ onNavigateToEditContent(
+ uiState.name,
+ uiState.profileType == TypedProfile.Type.Remote,
+ )
+ },
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Code,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ text =
+ if (uiState.profileType == TypedProfile.Type.Remote) {
+ stringResource(R.string.json_viewer)
+ } else {
+ stringResource(R.string.json_editor)
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f),
+ )
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileViewModel.kt
new file mode 100644
index 0000000..18a2697
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileViewModel.kt
@@ -0,0 +1,376 @@
+package io.nekohasekai.sfa.compose.screen.profile
+
+import android.app.Application
+import android.content.Context
+import android.net.Uri
+import android.widget.Toast
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import io.nekohasekai.libbox.Libbox
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.bg.UpdateProfileWork
+import io.nekohasekai.sfa.database.Profile
+import io.nekohasekai.sfa.database.ProfileManager
+import io.nekohasekai.sfa.database.Settings
+import io.nekohasekai.sfa.database.TypedProfile
+import io.nekohasekai.sfa.utils.HTTPClient
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+data class EditProfileUiState(
+ val profile: Profile? = null,
+ val name: String = "",
+ val icon: String? = null,
+ val profileType: TypedProfile.Type? = null,
+ val remoteUrl: String = "",
+ val autoUpdate: Boolean = false,
+ val autoUpdateInterval: Int = 60,
+ val lastUpdated: Date? = null,
+ // Original values for change detection
+ val originalName: String = "",
+ val originalIcon: String? = null,
+ val originalRemoteUrl: String = "",
+ val originalAutoUpdate: Boolean = false,
+ val originalAutoUpdateInterval: Int = 60,
+ // State flags
+ val hasChanges: Boolean = false,
+ val isLoading: Boolean = true,
+ val isUpdating: Boolean = false,
+ val showUpdateSuccess: Boolean = false,
+ val isSaving: Boolean = false,
+ val errorMessage: String? = null,
+ val autoUpdateIntervalError: String? = null,
+ val showIconDialog: Boolean = false,
+)
+
+class EditProfileViewModel(application: Application) : AndroidViewModel(application) {
+ private val _uiState = MutableStateFlow(EditProfileUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ // Store the content to export when user selects a file location
+ var pendingExportContent: String? = null
+ var pendingExportFileName: String? = null
+
+ fun loadProfile(profileId: Long) {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val profile = ProfileManager.get(profileId)
+ if (profile == null) {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorMessage = "Profile not found",
+ )
+ }
+ return@launch
+ }
+
+ val typedProfile = profile.typed
+ _uiState.update {
+ it.copy(
+ profile = profile,
+ name = profile.name,
+ originalName = profile.name,
+ icon = profile.icon,
+ originalIcon = profile.icon,
+ profileType = typedProfile.type,
+ remoteUrl = typedProfile.remoteURL,
+ originalRemoteUrl = typedProfile.remoteURL,
+ autoUpdate = typedProfile.autoUpdate,
+ originalAutoUpdate = typedProfile.autoUpdate,
+ autoUpdateInterval = typedProfile.autoUpdateInterval,
+ originalAutoUpdateInterval = typedProfile.autoUpdateInterval,
+ lastUpdated = typedProfile.lastUpdated,
+ isLoading = false,
+ )
+ }
+ } catch (e: Exception) {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorMessage = e.message,
+ )
+ }
+ }
+ }
+ }
+
+ fun updateName(name: String) {
+ _uiState.update { state ->
+ state.copy(
+ name = name,
+ hasChanges =
+ checkHasChanges(
+ state.copy(name = name),
+ ),
+ )
+ }
+ }
+
+ fun updateIcon(icon: String?) {
+ _uiState.update { state ->
+ state.copy(
+ icon = icon,
+ hasChanges =
+ checkHasChanges(
+ state.copy(icon = icon),
+ ),
+ )
+ }
+ }
+
+ fun showIconDialog() {
+ _uiState.update { it.copy(showIconDialog = true) }
+ }
+
+ fun hideIconDialog() {
+ _uiState.update { it.copy(showIconDialog = false) }
+ }
+
+ fun updateRemoteUrl(url: String) {
+ _uiState.update { state ->
+ state.copy(
+ remoteUrl = url,
+ hasChanges =
+ checkHasChanges(
+ state.copy(remoteUrl = url),
+ ),
+ )
+ }
+ }
+
+ fun updateAutoUpdate(enabled: Boolean) {
+ _uiState.update { state ->
+ state.copy(
+ autoUpdate = enabled,
+ hasChanges =
+ checkHasChanges(
+ state.copy(autoUpdate = enabled),
+ ),
+ )
+ }
+ }
+
+ fun updateAutoUpdateInterval(interval: String) {
+ val intValue = interval.toIntOrNull() ?: 60
+ val error =
+ when {
+ interval.isBlank() -> getApplication().getString(R.string.profile_input_required)
+ intValue < 15 -> getApplication().getString(R.string.profile_auto_update_interval_minimum_hint)
+ else -> null
+ }
+
+ _uiState.update { state ->
+ state.copy(
+ autoUpdateInterval = intValue,
+ autoUpdateIntervalError = error,
+ hasChanges =
+ if (error == null) {
+ checkHasChanges(state.copy(autoUpdateInterval = intValue))
+ } else {
+ state.hasChanges
+ },
+ )
+ }
+ }
+
+ private fun checkHasChanges(state: EditProfileUiState): Boolean {
+ return state.name != state.originalName ||
+ state.icon != state.originalIcon ||
+ state.remoteUrl != state.originalRemoteUrl ||
+ state.autoUpdate != state.originalAutoUpdate ||
+ state.autoUpdateInterval != state.originalAutoUpdateInterval
+ }
+
+ fun saveChanges() {
+ val state = _uiState.value
+ val profile = state.profile ?: return
+
+ if (state.autoUpdateIntervalError != null) {
+ return
+ }
+
+ viewModelScope.launch(Dispatchers.IO) {
+ _uiState.update { it.copy(isSaving = true) }
+
+ try {
+ // Update profile object
+ profile.name = state.name
+ profile.icon = state.icon
+ profile.typed.remoteURL = state.remoteUrl
+
+ // Handle auto-update changes
+ val autoUpdateChanged = state.autoUpdate != state.originalAutoUpdate
+ profile.typed.autoUpdate = state.autoUpdate
+ profile.typed.autoUpdateInterval = state.autoUpdateInterval
+
+ // Save to database
+ ProfileManager.update(profile)
+
+ // Reconfigure updater if auto-update was enabled
+ if (autoUpdateChanged && state.autoUpdate) {
+ UpdateProfileWork.reconfigureUpdater()
+ }
+
+ // Update UI state with new original values
+ _uiState.update {
+ it.copy(
+ originalName = state.name,
+ originalIcon = state.icon,
+ originalRemoteUrl = state.remoteUrl,
+ originalAutoUpdate = state.autoUpdate,
+ originalAutoUpdateInterval = state.autoUpdateInterval,
+ hasChanges = false,
+ isSaving = false,
+ )
+ }
+ } catch (e: Exception) {
+ _uiState.update {
+ it.copy(
+ isSaving = false,
+ errorMessage = e.message,
+ )
+ }
+ }
+ }
+ }
+
+ fun updateRemoteProfile() {
+ val state = _uiState.value
+ val profile = state.profile ?: return
+
+ if (profile.typed.type != TypedProfile.Type.Remote) return
+
+ viewModelScope.launch(Dispatchers.IO) {
+ _uiState.update { it.copy(isUpdating = true) }
+
+ try {
+ var selectedProfileUpdated = false
+
+ // Fetch remote config
+ val content = HTTPClient().use { it.getString(profile.typed.remoteURL) }
+ Libbox.checkConfig(content)
+
+ // Check if content changed
+ val file = File(profile.typed.path)
+ if (!file.exists() || file.readText() != content) {
+ file.writeText(content)
+ if (profile.id == Settings.selectedProfile) {
+ selectedProfileUpdated = true
+ }
+ }
+
+ // Update last updated time
+ profile.typed.lastUpdated = Date()
+ ProfileManager.update(profile)
+
+ // Update UI state with success indicator
+ _uiState.update {
+ it.copy(
+ lastUpdated = profile.typed.lastUpdated,
+ isUpdating = false,
+ showUpdateSuccess = true,
+ )
+ }
+
+ // Reload service if needed
+ if (selectedProfileUpdated) {
+ try {
+ Libbox.newStandaloneCommandClient().serviceReload()
+ } catch (e: Exception) {
+ // Service reload errors are not critical
+ }
+ }
+ } catch (e: Exception) {
+ _uiState.update {
+ it.copy(
+ isUpdating = false,
+ errorMessage = e.message,
+ )
+ }
+ }
+ }
+ }
+
+ fun clearError() {
+ _uiState.update { it.copy(errorMessage = null) }
+ }
+
+ fun clearUpdateSuccess() {
+ _uiState.update { it.copy(showUpdateSuccess = false) }
+ }
+
+ fun prepareExport(context: Context): String? {
+ val state = _uiState.value
+ val profile = state.profile ?: return null
+
+ return try {
+ // Read the configuration file
+ val configFile = File(profile.typed.path)
+ if (!configFile.exists()) {
+ Toast.makeText(context, "Configuration file not found", Toast.LENGTH_SHORT).show()
+ return null
+ }
+
+ val content = configFile.readText()
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
+ val fileName = "${profile.name.replace(" ", "_")}_$timestamp.json"
+
+ // Store content for later when user picks location
+ pendingExportContent = content
+ pendingExportFileName = fileName
+
+ fileName
+ } catch (e: Exception) {
+ Toast.makeText(
+ context,
+ context.getString(
+ io.nekohasekai.sfa.R.string.failed_to_read_configuration,
+ e.message,
+ ),
+ Toast.LENGTH_SHORT,
+ ).show()
+ null
+ }
+ }
+
+ fun saveExportToUri(
+ context: Context,
+ uri: Uri,
+ ) {
+ val content = pendingExportContent ?: return
+
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ context.contentResolver.openOutputStream(uri)?.use { outputStream ->
+ outputStream.write(content.toByteArray())
+ }
+
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ context,
+ "Configuration exported successfully",
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ } catch (e: Exception) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, "Export failed: ${e.message}", Toast.LENGTH_LONG).show()
+ }
+ } finally {
+ // Clear pending export data
+ pendingExportContent = null
+ pendingExportFileName = null
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionDialog.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionDialog.kt
new file mode 100644
index 0000000..e96e287
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionDialog.kt
@@ -0,0 +1,193 @@
+package io.nekohasekai.sfa.compose.screen.profile
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+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.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+import io.nekohasekai.sfa.compose.util.ProfileIcons
+
+@Composable
+fun IconSelectionDialog(
+ currentIconId: String?,
+ onIconSelected: (String?) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ Dialog(onDismissRequest = onDismiss) {
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .heightIn(max = 500.dp),
+ shape = RoundedCornerShape(16.dp),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.select_profile_icon),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(4),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ ) {
+ // Add option to remove custom icon (use default)
+ item {
+ IconOption(
+ icon = null,
+ label = stringResource(R.string.default_text),
+ isSelected = currentIconId == null,
+ onClick = {
+ onIconSelected(null)
+ onDismiss()
+ },
+ )
+ }
+
+ items(ProfileIcons.availableIcons) { profileIcon ->
+ IconOption(
+ icon = profileIcon,
+ label = profileIcon.label,
+ isSelected = currentIconId == profileIcon.id,
+ onClick = {
+ onIconSelected(profileIcon.id)
+ onDismiss()
+ },
+ )
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun IconOption(
+ icon: ProfileIcon?,
+ label: String,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+) {
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .aspectRatio(1f)
+ .clip(RoundedCornerShape(8.dp))
+ .clickable { onClick() },
+ colors =
+ CardDefaults.cardColors(
+ containerColor =
+ if (isSelected) {
+ MaterialTheme.colorScheme.primaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+ },
+ ),
+ border =
+ if (isSelected) {
+ CardDefaults.outlinedCardBorder()
+ } else {
+ null
+ },
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(8.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ if (icon != null) {
+ Icon(
+ imageVector = icon.icon,
+ contentDescription = label,
+ modifier = Modifier.size(28.dp),
+ tint =
+ if (isSelected) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ } else {
+ // Default icon indicator
+ Text(
+ text = stringResource(R.string.auto),
+ style = MaterialTheme.typography.bodyMedium,
+ color =
+ if (isSelected) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color =
+ if (isSelected) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionScreen.kt
new file mode 100644
index 0000000..afc1c5c
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionScreen.kt
@@ -0,0 +1,629 @@
+package io.nekohasekai.sfa.compose.screen.profile
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+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.WindowInsets
+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.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+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.items
+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.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.filled.RestartAlt
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.SearchOff
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+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.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+import io.nekohasekai.sfa.compose.util.icons.IconCategory
+import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun IconSelectionScreen(
+ currentIconId: String?,
+ onIconSelected: (String?) -> Unit,
+ onNavigateBack: () -> Unit,
+) {
+ var searchQuery by remember { mutableStateOf("") }
+ var selectedCategory by remember { mutableStateOf(null) }
+ var viewMode by remember { mutableStateOf(IconViewMode.CATEGORIES) }
+ var isSearchActive by remember { mutableStateOf(false) }
+ val focusManager = LocalFocusManager.current
+
+ // Get icons based on current mode
+ val displayedIcons =
+ remember(searchQuery, selectedCategory, viewMode) {
+ when {
+ searchQuery.isNotEmpty() -> MaterialIconsLibrary.searchIcons(searchQuery)
+ selectedCategory != null -> {
+ MaterialIconsLibrary.categories
+ .find { it.name == selectedCategory }
+ ?.icons ?: emptyList()
+ }
+ viewMode == IconViewMode.ALL -> MaterialIconsLibrary.getAllIcons()
+ else -> emptyList()
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.select_icon)) },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.content_description_back),
+ )
+ }
+ },
+ actions = {
+ IconButton(
+ onClick = {
+ isSearchActive = !isSearchActive
+ if (!isSearchActive) {
+ searchQuery = ""
+ viewMode = IconViewMode.CATEGORIES
+ selectedCategory = null
+ focusManager.clearFocus()
+ }
+ },
+ ) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription =
+ if (isSearchActive) {
+ stringResource(R.string.close_search)
+ } else {
+ stringResource(
+ R.string.search_icons,
+ )
+ },
+ tint =
+ if (isSearchActive) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ )
+ }
+ },
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ )
+ },
+ bottomBar = {
+ // Footer with current selection info
+ currentIconId?.let { id ->
+ MaterialIconsLibrary.getIconById(id)?.let { icon ->
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets.navigationBars)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
+ ),
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Column {
+ val iconInfo = MaterialIconsLibrary.getAllIcons().find { it.id == id }
+ Text(
+ text =
+ stringResource(
+ R.string.current_icon_format,
+ iconInfo?.label ?: id,
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ MaterialIconsLibrary.getCategoryForIcon(id)?.let { category ->
+ Text(
+ text = category,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ ) { paddingValues ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ ) {
+ // Show search bar with animation
+ AnimatedVisibility(
+ visible = isSearchActive,
+ enter =
+ expandVertically(
+ animationSpec = tween(300),
+ ) +
+ fadeIn(
+ animationSpec = tween(300),
+ ),
+ exit =
+ shrinkVertically(
+ animationSpec = tween(300),
+ ) +
+ fadeOut(
+ animationSpec = tween(300),
+ ),
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surface,
+ shadowElevation = 4.dp,
+ ) {
+ val focusRequester = remember { FocusRequester() }
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ OutlinedTextField(
+ value = searchQuery,
+ onValueChange = {
+ searchQuery = it
+ if (it.isNotEmpty()) {
+ viewMode = IconViewMode.SEARCH
+ } else {
+ viewMode = IconViewMode.CATEGORIES
+ selectedCategory = null
+ }
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, bottom = 12.dp)
+ .focusRequester(focusRequester),
+ placeholder = { Text(stringResource(R.string.search_icons_placeholder)) },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = stringResource(R.string.search),
+ )
+ },
+ trailingIcon = {
+ if (searchQuery.isNotEmpty()) {
+ IconButton(onClick = {
+ searchQuery = ""
+ viewMode = IconViewMode.CATEGORIES
+ selectedCategory = null
+ }) {
+ Icon(
+ imageVector = Icons.Default.Clear,
+ contentDescription = stringResource(R.string.content_description_clear_search),
+ )
+ }
+ }
+ },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions =
+ KeyboardActions(
+ onSearch = {
+ focusManager.clearFocus()
+ },
+ ),
+ )
+ }
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp),
+ ) {
+ // View mode tabs (only show when not searching)
+ AnimatedVisibility(visible = searchQuery.isEmpty()) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ FilterChip(
+ selected = viewMode == IconViewMode.CATEGORIES && selectedCategory == null,
+ onClick = {
+ viewMode = IconViewMode.CATEGORIES
+ selectedCategory = null
+ },
+ label = { Text(stringResource(R.string.categories)) },
+ leadingIcon =
+ if (viewMode == IconViewMode.CATEGORIES && selectedCategory == null) {
+ { Icon(Icons.Default.Check, contentDescription = null, Modifier.size(16.dp)) }
+ } else {
+ null
+ },
+ )
+
+ FilterChip(
+ selected = viewMode == IconViewMode.ALL,
+ onClick = {
+ viewMode = IconViewMode.ALL
+ selectedCategory = null
+ },
+ label = { Text(stringResource(R.string.all_icons)) },
+ leadingIcon =
+ if (viewMode == IconViewMode.ALL) {
+ { Icon(Icons.Default.Check, contentDescription = null, Modifier.size(16.dp)) }
+ } else {
+ null
+ },
+ )
+
+ FilterChip(
+ selected = currentIconId == null,
+ onClick = {
+ onIconSelected(null)
+ onNavigateBack()
+ },
+ label = { Text(stringResource(R.string.default_text)) },
+ leadingIcon = {
+ Icon(Icons.Default.RestartAlt, contentDescription = null, Modifier.size(16.dp))
+ },
+ )
+ }
+ }
+
+ // Back button when category is selected
+ AnimatedVisibility(visible = selectedCategory != null && searchQuery.isEmpty()) {
+ TextButton(
+ onClick = {
+ selectedCategory = null
+ viewMode = IconViewMode.CATEGORIES
+ },
+ modifier = Modifier.padding(vertical = 4.dp),
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(stringResource(R.string.back_to_categories))
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Main content area
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ ) {
+ when {
+ // Search results
+ searchQuery.isNotEmpty() -> {
+ if (displayedIcons.isEmpty()) {
+ EmptySearchResult(searchQuery)
+ } else {
+ IconGrid(
+ icons = displayedIcons,
+ currentIconId = currentIconId,
+ onIconClick = { icon ->
+ onIconSelected(icon.id)
+ onNavigateBack()
+ },
+ )
+ }
+ }
+ // Category view
+ viewMode == IconViewMode.CATEGORIES && selectedCategory == null -> {
+ CategoryList(
+ categories = MaterialIconsLibrary.categories,
+ currentIconId = currentIconId,
+ onCategoryClick = { category ->
+ selectedCategory = category.name
+ },
+ )
+ }
+ // Icons in selected category or all icons
+ displayedIcons.isNotEmpty() -> {
+ Column {
+ selectedCategory?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(bottom = 8.dp),
+ )
+ }
+ IconGrid(
+ icons = displayedIcons,
+ currentIconId = currentIconId,
+ onIconClick = { icon ->
+ onIconSelected(icon.id)
+ onNavigateBack()
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CategoryList(
+ categories: List,
+ currentIconId: String?,
+ onCategoryClick: (IconCategory) -> Unit,
+) {
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(categories) { category ->
+ CategoryCard(
+ category = category,
+ hasSelectedIcon = category.icons.any { it.id == currentIconId },
+ onClick = { onCategoryClick(category) },
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CategoryCard(
+ category: IconCategory,
+ hasSelectedIcon: Boolean,
+ onClick: () -> Unit,
+) {
+ Card(
+ onClick = onClick,
+ modifier = Modifier.fillMaxWidth(),
+ colors =
+ CardDefaults.cardColors(
+ containerColor =
+ if (hasSelectedIcon) {
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+ },
+ ),
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = category.name,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Text(
+ text = stringResource(R.string.icon_count_format, category.icons.size),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ // Preview first 3 icons
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ category.icons.take(3).forEach { icon ->
+ Icon(
+ imageVector = icon.icon,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Icon(
+ Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun IconGrid(
+ icons: List,
+ currentIconId: String?,
+ onIconClick: (ProfileIcon) -> Unit,
+) {
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = 72.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(icons) { icon ->
+ IconGridItem(
+ icon = icon,
+ isSelected = currentIconId == icon.id,
+ onClick = { onIconClick(icon) },
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun IconGridItem(
+ icon: ProfileIcon,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+) {
+ Card(
+ onClick = onClick,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .aspectRatio(1f),
+ colors =
+ CardDefaults.cardColors(
+ containerColor =
+ if (isSelected) {
+ MaterialTheme.colorScheme.primaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
+ },
+ ),
+ border =
+ if (isSelected) {
+ CardDefaults.outlinedCardBorder()
+ } else {
+ null
+ },
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(8.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ imageVector = icon.icon,
+ contentDescription = icon.label,
+ modifier = Modifier.size(28.dp),
+ tint =
+ if (isSelected) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = icon.label,
+ style = MaterialTheme.typography.labelSmall,
+ color =
+ if (isSelected) {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+}
+
+@Composable
+private fun EmptySearchResult(query: String) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(32.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ Icons.Default.SearchOff,
+ contentDescription = null,
+ modifier = Modifier.size(64.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(R.string.no_icons_found),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Text(
+ text = stringResource(R.string.no_icons_match, query),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+private enum class IconViewMode {
+ CATEGORIES,
+ ALL,
+ SEARCH,
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/ManualScrollTextProcessor.java b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/ManualScrollTextProcessor.java
new file mode 100644
index 0000000..4ed1dc0
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/ManualScrollTextProcessor.java
@@ -0,0 +1,132 @@
+package io.nekohasekai.sfa.compose.screen.profile;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.ViewConfiguration;
+
+import com.blacksquircle.ui.editorkit.widget.TextProcessor;
+
+public class ManualScrollTextProcessor extends TextProcessor {
+
+ private final int touchSlop;
+ private boolean allowCursorAutoScroll = true;
+ private float downX;
+ private float downY;
+ private boolean userDragging;
+ private int downSelectionStart = -1;
+ private int downSelectionEnd = -1;
+ private boolean restoringSelection;
+
+ public ManualScrollTextProcessor(Context context) {
+ this(context, null);
+ }
+
+ public ManualScrollTextProcessor(Context context, AttributeSet attrs) {
+ this(context, attrs, android.R.attr.autoCompleteTextViewStyle);
+ }
+
+ public ManualScrollTextProcessor(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
+ }
+
+ public void resumeAutoScroll() {
+ allowCursorAutoScroll = true;
+ userDragging = false;
+ }
+
+ @Override
+ public boolean bringPointIntoView(int offset) {
+ if (allowCursorAutoScroll) {
+ return super.bringPointIntoView(offset);
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ int action = event.getActionMasked();
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ downX = event.getX();
+ downY = event.getY();
+ userDragging = false;
+ restoringSelection = false;
+ downSelectionStart = getSelectionStart();
+ downSelectionEnd = getSelectionEnd();
+ break;
+ case MotionEvent.ACTION_MOVE:
+ if (!userDragging) {
+ float dx = Math.abs(event.getX() - downX);
+ float dy = Math.abs(event.getY() - downY);
+ if (dx > touchSlop || dy > touchSlop) {
+ userDragging = true;
+ allowCursorAutoScroll = false;
+ }
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ break;
+ default:
+ break;
+ }
+
+ boolean handled = super.onTouchEvent(event);
+
+ switch (action) {
+ case MotionEvent.ACTION_MOVE:
+ if (userDragging) {
+ maybeRestoreSelection();
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ if (userDragging) {
+ maybeRestoreSelection();
+ } else {
+ resumeAutoScroll();
+ }
+ break;
+ default:
+ break;
+ }
+
+ return handled;
+ }
+
+ private void maybeRestoreSelection() {
+ if (userDragging && !restoringSelection) {
+ int selStart = getSelectionStart();
+ int selEnd = getSelectionEnd();
+ if (selStart != downSelectionStart || selEnd != downSelectionEnd) {
+ restoringSelection = true;
+ int targetEnd = downSelectionEnd >= 0 ? downSelectionEnd : downSelectionStart;
+ setSelection(downSelectionStart, targetEnd);
+ }
+ }
+ }
+
+ @Override
+ protected void onSelectionChanged(int selStart, int selEnd) {
+ if (restoringSelection) {
+ restoringSelection = false;
+ super.onSelectionChanged(selStart, selEnd);
+ return;
+ }
+
+ if (userDragging) {
+ if (downSelectionStart >= 0 && (selStart != downSelectionStart || selEnd != downSelectionEnd)) {
+ restoringSelection = true;
+ int targetEnd = downSelectionEnd >= 0 ? downSelectionEnd : downSelectionStart;
+ setSelection(downSelectionStart, targetEnd);
+ return;
+ }
+ }
+
+ downSelectionStart = selStart;
+ downSelectionEnd = selEnd;
+ super.onSelectionChanged(selStart, selEnd);
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt
new file mode 100644
index 0000000..9c74a9e
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt
@@ -0,0 +1,274 @@
+package io.nekohasekai.sfa.compose.screen.settings
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.DeleteForever
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material.icons.outlined.Storage
+import androidx.compose.material.icons.outlined.WarningAmber
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import io.nekohasekai.libbox.Libbox
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.database.Settings
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+@Composable
+fun CoreSettingsScreen(navController: NavController) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ var dataSize by remember { mutableStateOf("") }
+ val version = remember { Libbox.version() }
+ var disableDeprecatedWarnings by remember { mutableStateOf(Settings.disableDeprecatedWarnings) }
+
+ // Calculate data size on launch
+ LaunchedEffect(Unit) {
+ withContext(Dispatchers.IO) {
+ val filesDir = context.getExternalFilesDir(null) ?: context.filesDir
+ val size =
+ filesDir.walkTopDown()
+ .filter { it.isFile }
+ .map { it.length() }
+ .sum()
+ val formattedSize = Libbox.formatBytes(size)
+ dataSize = formattedSize
+ }
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface)
+ .verticalScroll(rememberScrollState())
+ .padding(vertical = 8.dp),
+ ) {
+ // Core Information Card
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ ) {
+ Column {
+ // Version Info
+ ListItem(
+ headlineContent = {
+ Text(
+ stringResource(R.string.core_version_title),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ supportingContent = {
+ Text(
+ version,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.Info,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ modifier = Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
+ colors =
+ ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+
+ // Data Size
+ ListItem(
+ headlineContent = {
+ Text(
+ stringResource(R.string.core_data_size),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ supportingContent = {
+ Text(
+ dataSize.ifEmpty { stringResource(R.string.calculating) },
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.Storage,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ modifier =
+ Modifier.clip(
+ RoundedCornerShape(
+ bottomStart = 12.dp,
+ bottomEnd = 12.dp,
+ ),
+ ),
+ colors =
+ ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+ }
+ }
+
+ // Options Section
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(R.string.options),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
+ )
+
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ ) {
+ ListItem(
+ headlineContent = {
+ Text(
+ stringResource(R.string.disable_deprecated_warnings),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.WarningAmber,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ trailingContent = {
+ Switch(
+ checked = disableDeprecatedWarnings,
+ onCheckedChange = { checked ->
+ disableDeprecatedWarnings = checked
+ scope.launch(Dispatchers.IO) {
+ Settings.disableDeprecatedWarnings = checked
+ }
+ },
+ )
+ },
+ modifier = Modifier.clip(RoundedCornerShape(12.dp)),
+ colors =
+ ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+ }
+
+ // Working Directory Section
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(R.string.working_directory),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
+ )
+
+ // Destroy Data Card - No dialog, immediate deletion
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
+ ),
+ ) {
+ ListItem(
+ headlineContent = {
+ Text(
+ stringResource(R.string.destroy),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error,
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.DeleteForever,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ )
+ },
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(12.dp))
+ .clickable {
+ scope.launch(Dispatchers.IO) {
+ val filesDir = context.getExternalFilesDir(null) ?: context.filesDir
+ filesDir.deleteRecursively()
+ filesDir.mkdirs()
+
+ // Recalculate data size
+ val newSize =
+ filesDir.walkTopDown()
+ .filter { it.isFile }
+ .map { it.length() }
+ .sum()
+ val formattedSize = Libbox.formatBytes(newSize)
+ dataSize = formattedSize
+ }
+ },
+ colors =
+ ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt
new file mode 100644
index 0000000..8c0ac08
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt
@@ -0,0 +1,216 @@
+package io.nekohasekai.sfa.compose.screen.settings
+
+import android.content.Intent
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.FilterList
+import androidx.compose.material.icons.outlined.Route
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.database.Settings
+import io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity
+import io.nekohasekai.sfa.vendor.Vendor
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+@Composable
+fun ProfileOverrideScreen(navController: NavController) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ var autoRedirect by remember { mutableStateOf(Settings.autoRedirect) }
+ var showPerAppProxyDialog by remember { mutableStateOf(false) }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface)
+ .verticalScroll(rememberScrollState())
+ .padding(vertical = 8.dp),
+ ) {
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ ) {
+ Column {
+ // Auto Redirect
+ ListItem(
+ headlineContent = {
+ Text(
+ stringResource(R.string.auto_redirect),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ supportingContent = {
+ Text(
+ stringResource(R.string.auto_redirect_description),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.Route,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ trailingContent = {
+ Switch(
+ checked = autoRedirect,
+ onCheckedChange = { checked ->
+ if (checked && !autoRedirect) {
+ scope.launch {
+ val hasRoot =
+ withContext(Dispatchers.IO) {
+ try {
+ val process = Runtime.getRuntime().exec("su -c id")
+ process.inputStream.close()
+ process.outputStream.close()
+ process.errorStream.close()
+ process.waitFor() == 0
+ } catch (e: Exception) {
+ false
+ }
+ }
+ if (hasRoot) {
+ autoRedirect = true
+ withContext(Dispatchers.IO) {
+ Settings.autoRedirect = true
+ }
+ } else {
+ Toast.makeText(
+ context,
+ context.getString(R.string.root_access_required),
+ Toast.LENGTH_LONG,
+ ).show()
+ }
+ }
+ } else if (!checked) {
+ // Disabling doesn't need root check
+ autoRedirect = false
+ scope.launch(Dispatchers.IO) {
+ Settings.autoRedirect = false
+ }
+ }
+ },
+ )
+ },
+ modifier = Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
+ colors =
+ ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+
+ // Per-App Proxy
+ val isPerAppProxyAvailable = Vendor.isPerAppProxyAvailable()
+ ListItem(
+ headlineContent = {
+ Text(
+ stringResource(R.string.per_app_proxy),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ supportingContent =
+ if (!isPerAppProxyAvailable) {
+ {
+ Text(
+ text = context.getString(R.string.per_app_proxy_disabled_play_store),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ }
+ } else {
+ null
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.FilterList,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
+ .clickable {
+ if (isPerAppProxyAvailable) {
+ // Launch the PerAppProxyActivity
+ val intent = Intent(context, PerAppProxyActivity::class.java)
+ context.startActivity(intent)
+ } else {
+ // Show dialog explaining why it's disabled
+ showPerAppProxyDialog = true
+ }
+ },
+ colors =
+ ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+ }
+ }
+
+ // Dialog for Per-app Proxy disabled message
+ if (showPerAppProxyDialog) {
+ AlertDialog(
+ onDismissRequest = { showPerAppProxyDialog = false },
+ title = {
+ Text(stringResource(R.string.unavailable))
+ },
+ text = {
+ Text(context.getString(R.string.per_app_proxy_disabled_message))
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { showPerAppProxyDialog = false },
+ ) {
+ Text(context.getString(R.string.ok))
+ }
+ },
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt
new file mode 100644
index 0000000..46ace47
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt
@@ -0,0 +1,225 @@
+package io.nekohasekai.sfa.compose.screen.settings
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.PowerManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.BatteryChargingFull
+import androidx.compose.material.icons.outlined.Memory
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Switch
+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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.bg.ServiceConnection
+import io.nekohasekai.sfa.compose.base.GlobalEventBus
+import io.nekohasekai.sfa.compose.base.UiEvent
+import io.nekohasekai.sfa.database.Settings
+import io.nekohasekai.sfa.ktx.launchCustomTab
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+@Composable
+fun ServiceSettingsScreen(
+ navController: NavController,
+ serviceConnection: ServiceConnection? = null,
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ // Check battery optimization status
+ var isBatteryOptimizationIgnored by remember { mutableStateOf(false) }
+ var ignoreMemoryLimit by remember { mutableStateOf(Settings.disableMemoryLimit) }
+
+ // Activity result launcher for battery optimization permission
+ val requestBatteryOptimizationLauncher =
+ rememberLauncherForActivityResult(
+ ActivityResultContracts.StartActivityForResult(),
+ ) { _ ->
+ // Recheck the status after returning from settings
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ val pm = context.getSystemService(PowerManager::class.java)
+ isBatteryOptimizationIgnored =
+ pm?.isIgnoringBatteryOptimizations(context.packageName) == true
+ }
+ }
+
+ // Check battery optimization status on launch
+ LaunchedEffect(Unit) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ val pm = context.getSystemService(PowerManager::class.java)
+ isBatteryOptimizationIgnored =
+ pm?.isIgnoringBatteryOptimizations(context.packageName) == true
+ } else {
+ isBatteryOptimizationIgnored = true
+ }
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface)
+ .verticalScroll(rememberScrollState())
+ .padding(vertical = 8.dp),
+ ) {
+ // Background Permission Card (only show if battery optimization is not ignored)
+ if (!isBatteryOptimizationIgnored && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
+ ),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.BatteryChargingFull,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.padding(end = 12.dp),
+ )
+ Text(
+ stringResource(R.string.background_permission),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onTertiaryContainer,
+ )
+ }
+
+ Text(
+ stringResource(R.string.background_permission_description),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onTertiaryContainer,
+ modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ OutlinedButton(
+ onClick = {
+ context.launchCustomTab("https://dontkillmyapp.com/")
+ },
+ modifier = Modifier.padding(end = 8.dp),
+ ) {
+ Text(stringResource(R.string.read_more))
+ }
+
+ Button(
+ onClick = {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ val intent =
+ Intent(
+ android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
+ Uri.parse("package:${context.packageName}"),
+ )
+ requestBatteryOptimizationLauncher.launch(intent)
+ }
+ },
+ ) {
+ Text(stringResource(R.string.request_background_permission))
+ }
+ }
+ }
+ }
+ }
+
+ // Options Section
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ ) {
+ ListItem(
+ headlineContent = {
+ Text(
+ stringResource(R.string.ignore_memory_limit),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ supportingContent = {
+ Text(
+ stringResource(R.string.ignore_memory_limit_description),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.Memory,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ trailingContent = {
+ Switch(checked = ignoreMemoryLimit, onCheckedChange = { checked ->
+ ignoreMemoryLimit = checked
+ scope.launch(Dispatchers.IO) {
+ Settings.disableMemoryLimit = checked
+ GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect)
+ }
+ })
+ },
+ modifier = Modifier.clip(RoundedCornerShape(12.dp)),
+ colors =
+ ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt
new file mode 100644
index 0000000..3e66363
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt
@@ -0,0 +1,327 @@
+package io.nekohasekai.sfa.compose.screen.settings
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.OpenInNew
+import androidx.compose.material.icons.outlined.Code
+import androidx.compose.material.icons.outlined.Description
+import androidx.compose.material.icons.outlined.Favorite
+import androidx.compose.material.icons.outlined.FilterAlt
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material.icons.outlined.SwapHoriz
+import androidx.compose.material.icons.outlined.Tune
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import io.nekohasekai.sfa.R
+import io.nekohasekai.sfa.database.Settings
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(navController: NavController) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface)
+ .verticalScroll(rememberScrollState())
+ .padding(vertical = 8.dp),
+ ) {
+ // General Settings Group
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ ) {
+ Column {
+ ListItem(
+ headlineContent = {
+ Text(
+ stringResource(R.string.core),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.Settings,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
+ .clickable { navController.navigate("settings/core") },
+ colors =
+ ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+
+ ListItem(
+ headlineContent = {
+ Text(
+ stringResource(R.string.service),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.Tune,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ modifier = Modifier.clickable { navController.navigate("settings/service") },
+ colors =
+ ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+
+ ListItem(
+ headlineContent = {
+ Text(
+ stringResource(R.string.profile_override),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.FilterAlt,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
+ .clickable { navController.navigate("settings/profile_override") },
+ colors =
+ ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+ }
+ }
+
+ // About Section
+ Text(
+ text = stringResource(R.string.about),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
+ )
+
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ ) {
+ Column {
+ ListItem(
+ headlineContent = {
+ Text(
+ stringResource(R.string.service_error_deprecated_warning_documentation),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.Description,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ trailingContent = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
+ .clickable {
+ val intent = android.content.Intent(android.content.Intent.ACTION_VIEW)
+ intent.data = android.net.Uri.parse("https://sing-box.sagernet.org/")
+ context.startActivity(intent)
+ },
+ colors =
+ ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+
+ ListItem(
+ headlineContent = {
+ Text(
+ stringResource(R.string.source_code),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.Code,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ trailingContent = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ modifier =
+ Modifier
+ .clickable {
+ val intent = android.content.Intent(android.content.Intent.ACTION_VIEW)
+ intent.data =
+ android.net.Uri.parse("https://github.com/SagerNet/sing-box-for-android")
+ context.startActivity(intent)
+ },
+ colors =
+ ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+
+ ListItem(
+ headlineContent = {
+ Text(
+ stringResource(R.string.sponsor),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.Favorite,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ trailingContent = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp))
+ .clickable {
+ val intent = android.content.Intent(android.content.Intent.ACTION_VIEW)
+ intent.data = android.net.Uri.parse("https://sekai.icu/sponsors/")
+ context.startActivity(intent)
+ },
+ colors =
+ ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+ }
+ }
+
+ // Debug
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(R.string.title_debug),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
+ )
+
+ Card(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ ) {
+ ListItem(
+ headlineContent = {
+ Text(
+ stringResource(R.string.switch_to_legacy_ui),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.SwapHoriz,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ },
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(12.dp))
+ .clickable {
+ scope.launch(Dispatchers.IO) {
+ Settings.useComposeUI = false
+ val intent =
+ android.content.Intent(
+ context,
+ Class.forName("io.nekohasekai.sfa.ui.MainActivity"),
+ )
+ intent.flags =
+ android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
+ context.startActivity(intent)
+ }
+ },
+ colors =
+ ListItemDefaults.colors(
+ containerColor = Color.Transparent,
+ ),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/theme/Color.kt b/app/src/main/java/io/nekohasekai/sfa/compose/theme/Color.kt
new file mode 100644
index 0000000..485f443
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/theme/Color.kt
@@ -0,0 +1,32 @@
+package io.nekohasekai.sfa.compose.theme
+
+import androidx.compose.ui.graphics.Color
+
+// Primary colors from existing app
+val SingBoxPrimary = Color(0xFFD81B60)
+val SingBoxPrimaryDark = Color(0xFFA00037)
+val SingBoxPrimaryLight = Color(0xFFFF5C8D)
+
+// Service status colors
+val ServiceRunning = Color(0xFF4CAF50)
+val ServiceStopped = Color(0xFF9E9E9E)
+val ServiceError = Color(0xFFF44336)
+
+// Log colors
+val LogRed = Color(0xFFFF2158)
+val LogGreen = Color(0xFF2ECC71)
+val LogYellow = Color(0xFFE5E500)
+val LogBlue = Color(0xFF3498DB)
+val LogPurple = Color(0xFFE500E5)
+val LogRedLight = Color(0xFFE91E63)
+val LogBlueLight = Color(0xFF00A6B2)
+val LogWhite = Color(0xFFECECEC)
+
+// Material You seed color
+val SeedColor = Color(0xFFD81B60)
+
+// Additional semantic colors
+val SuccessGreen = Color(0xFF4CAF50)
+val WarningOrange = Color(0xFFFF9800)
+val ErrorRed = Color(0xFFF44336)
+val InfoBlue = Color(0xFF2196F3)
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/theme/Shape.kt b/app/src/main/java/io/nekohasekai/sfa/compose/theme/Shape.kt
new file mode 100644
index 0000000..6cd9e04
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/theme/Shape.kt
@@ -0,0 +1,14 @@
+package io.nekohasekai.sfa.compose.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Shapes
+import androidx.compose.ui.unit.dp
+
+val Shapes =
+ Shapes(
+ extraSmall = RoundedCornerShape(4.dp),
+ small = RoundedCornerShape(8.dp),
+ medium = RoundedCornerShape(12.dp),
+ large = RoundedCornerShape(16.dp),
+ extraLarge = RoundedCornerShape(28.dp),
+ )
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/theme/Theme.kt b/app/src/main/java/io/nekohasekai/sfa/compose/theme/Theme.kt
new file mode 100644
index 0000000..249c3dc
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/theme/Theme.kt
@@ -0,0 +1,69 @@
+package io.nekohasekai.sfa.compose.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+private val DarkColorScheme =
+ darkColorScheme(
+ primary = SingBoxPrimary,
+ secondary = SingBoxPrimaryLight,
+ tertiary = LogBlue,
+ )
+
+private val LightColorScheme =
+ lightColorScheme(
+ primary = SingBoxPrimary,
+ secondary = SingBoxPrimaryDark,
+ tertiary = LogBlue,
+ )
+
+@Composable
+fun SFATheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit,
+) {
+ val colorScheme =
+ when {
+ dynamicColor && Build.VERSION.SDK_INT >= 31 -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as? Activity)?.window ?: return@SideEffect
+ window.statusBarColor = colorScheme.primary.toArgb()
+ window.navigationBarColor = colorScheme.background.toArgb()
+ WindowCompat.getInsetsController(window, view).apply {
+ isAppearanceLightStatusBars = !darkTheme
+ isAppearanceLightNavigationBars = !darkTheme
+ }
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ shapes = Shapes,
+ content = content,
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/theme/Type.kt b/app/src/main/java/io/nekohasekai/sfa/compose/theme/Type.kt
new file mode 100644
index 0000000..d66330f
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/theme/Type.kt
@@ -0,0 +1,137 @@
+package io.nekohasekai.sfa.compose.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Material 3 Typography
+val Typography =
+ Typography(
+ // Display styles
+ displayLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 57.sp,
+ lineHeight = 64.sp,
+ letterSpacing = (-0.25).sp,
+ ),
+ displayMedium =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 45.sp,
+ lineHeight = 52.sp,
+ letterSpacing = 0.sp,
+ ),
+ displaySmall =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 36.sp,
+ lineHeight = 44.sp,
+ letterSpacing = 0.sp,
+ ),
+ // Headline styles
+ headlineLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 32.sp,
+ lineHeight = 40.sp,
+ letterSpacing = 0.sp,
+ ),
+ headlineMedium =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 28.sp,
+ lineHeight = 36.sp,
+ letterSpacing = 0.sp,
+ ),
+ headlineSmall =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 24.sp,
+ lineHeight = 32.sp,
+ letterSpacing = 0.sp,
+ ),
+ // Title styles
+ titleLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp,
+ ),
+ titleMedium =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.15.sp,
+ ),
+ titleSmall =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ // Body styles
+ bodyLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp,
+ ),
+ bodyMedium =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.25.sp,
+ ),
+ bodySmall =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.4.sp,
+ ),
+ // Label styles
+ labelLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ labelMedium =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp,
+ ),
+ labelSmall =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp,
+ ),
+ )
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/AnsiColorUtils.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/AnsiColorUtils.kt
new file mode 100644
index 0000000..cda24a1
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/AnsiColorUtils.kt
@@ -0,0 +1,118 @@
+package io.nekohasekai.sfa.compose.util
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
+
+object AnsiColorUtils {
+ private val ansiRegex = Regex("\u001B\\[[;\\d]*m")
+
+ private val logRed = Color(0xFFFF2158)
+ private val logGreen = Color(0xFF2ECC71)
+ private val logYellow = Color(0xFFE5E500)
+ private val logBlue = Color(0xFF3498DB)
+ private val logPurple = Color(0xFF9B59B6)
+ private val logBlueLight = Color(0xFF5DADE2)
+ private val logWhite = Color(0xFFECF0F1)
+
+ fun ansiToAnnotatedString(text: String): AnnotatedString {
+ val cleanText = text.replace(ansiRegex, "")
+ val matches = ansiRegex.findAll(text).toList()
+
+ if (matches.isEmpty()) {
+ return AnnotatedString(cleanText)
+ }
+
+ return buildAnnotatedString {
+ append(cleanText)
+
+ var currentStyle: SpanStyle? = null
+ var currentStart = 0
+ var offset = 0
+
+ matches.forEach { match ->
+ val code = match.value
+ val codeStart = match.range.first - offset
+ val decoration = parseAnsiCode(code)
+
+ if (decoration == null) {
+ // Reset code
+ if (currentStyle != null && currentStart < codeStart) {
+ addStyle(currentStyle!!, currentStart, codeStart)
+ }
+ currentStyle = null
+ currentStart = codeStart
+ } else {
+ // Apply previous style if exists
+ if (currentStyle != null && currentStart < codeStart) {
+ addStyle(currentStyle!!, currentStart, codeStart)
+ }
+ currentStyle = decoration
+ currentStart = codeStart
+ }
+
+ offset += code.length
+ }
+
+ // Apply remaining style
+ if (currentStyle != null && currentStart < cleanText.length) {
+ addStyle(currentStyle!!, currentStart, cleanText.length)
+ }
+ }
+ }
+
+ private fun parseAnsiCode(code: String): SpanStyle? {
+ val colorCodes = code.substringAfter('[').substringBefore('m').split(';')
+
+ var color: Color? = null
+ var fontWeight: FontWeight? = null
+ var fontStyle: FontStyle? = null
+ var textDecoration: TextDecoration? = null
+
+ colorCodes.forEach { codeStr ->
+ when (codeStr) {
+ "0" -> return null // Reset
+ "1" -> fontWeight = FontWeight.Bold
+ "3" -> fontStyle = FontStyle.Italic
+ "4" -> textDecoration = TextDecoration.Underline
+ "30" -> color = Color.Black
+ "31" -> color = logRed
+ "32" -> color = logGreen
+ "33" -> color = logYellow
+ "34" -> color = logBlue
+ "35" -> color = logPurple
+ "36" -> color = logBlueLight
+ "37" -> color = logWhite
+ else -> {
+ val codeInt = codeStr.toIntOrNull()
+ if (codeInt != null && codeInt in 38..125) {
+ val adjustedCode = codeInt % 125
+ val row = adjustedCode / 36
+ val column = adjustedCode % 36
+ color =
+ Color(
+ red = row * 51,
+ green = (column / 6) * 51,
+ blue = (column % 6) * 51,
+ )
+ }
+ }
+ }
+ }
+
+ return if (color != null || fontWeight != null || fontStyle != null || textDecoration != null) {
+ SpanStyle(
+ color = color ?: Color.Unspecified,
+ fontWeight = fontWeight,
+ fontStyle = fontStyle,
+ textDecoration = textDecoration,
+ )
+ } else {
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/MaterialIconsLibrary.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/MaterialIconsLibrary.kt
new file mode 100644
index 0000000..2d37d47
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/MaterialIconsLibrary.kt
@@ -0,0 +1,441 @@
+package io.nekohasekai.sfa.compose.util
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.*
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.material.icons.rounded.*
+import androidx.compose.material.icons.sharp.*
+import androidx.compose.material.icons.twotone.*
+import androidx.compose.ui.graphics.vector.ImageVector
+
+data class IconCategory(
+ val name: String,
+ val icons: List,
+)
+
+object MaterialIconsLibrary {
+ val categories =
+ listOf(
+ IconCategory(
+ "Security & Privacy",
+ listOf(
+ ProfileIcon("shield", Icons.Filled.Shield, "Shield"),
+ ProfileIcon("security", Icons.Filled.Security, "Security"),
+ ProfileIcon("lock", Icons.Filled.Lock, "Lock"),
+ ProfileIcon("lock_open", Icons.Filled.LockOpen, "Lock Open"),
+ ProfileIcon("vpn_key", Icons.Filled.VpnKey, "VPN Key"),
+ ProfileIcon("vpn_lock", Icons.Filled.VpnLock, "VPN Lock"),
+ ProfileIcon("key", Icons.Filled.Key, "Key"),
+ ProfileIcon("password", Icons.Filled.Password, "Password"),
+ ProfileIcon("fingerprint", Icons.Filled.Fingerprint, "Fingerprint"),
+ ProfileIcon("verified_user", Icons.Filled.VerifiedUser, "Verified"),
+ ProfileIcon("privacy_tip", Icons.Filled.PrivacyTip, "Privacy"),
+ ProfileIcon("admin_panel", Icons.Filled.AdminPanelSettings, "Admin"),
+ ProfileIcon("policy", Icons.Filled.Policy, "Policy"),
+ ProfileIcon("gpp_good", Icons.Filled.GppGood, "Protected"),
+ ProfileIcon("gpp_maybe", Icons.Filled.GppMaybe, "Maybe Protected"),
+ ProfileIcon("enhanced_encryption", Icons.Filled.EnhancedEncryption, "Encryption"),
+ ProfileIcon("no_encryption", Icons.Filled.NoEncryption, "No Encryption"),
+ ProfileIcon("https", Icons.Filled.Https, "HTTPS"),
+ ProfileIcon("http", Icons.Filled.Http, "HTTP"),
+ ProfileIcon("safety_check", Icons.Filled.SafetyCheck, "Safety Check"),
+ ),
+ ),
+ IconCategory(
+ "Network & Connection",
+ listOf(
+ ProfileIcon("wifi", Icons.Filled.Wifi, "WiFi"),
+ ProfileIcon("wifi_off", Icons.Filled.WifiOff, "WiFi Off"),
+ ProfileIcon("wifi_lock", Icons.Filled.WifiLock, "WiFi Lock"),
+ ProfileIcon("wifi_tethering", Icons.Filled.WifiTethering, "Tethering"),
+ ProfileIcon("signal_wifi_4_bar", Icons.Filled.SignalWifi4Bar, "Strong WiFi"),
+ ProfileIcon("signal_wifi_bad", Icons.Filled.SignalWifiBad, "Bad WiFi"),
+ ProfileIcon("router", Icons.Filled.Router, "Router"),
+ ProfileIcon("network_check", Icons.Filled.NetworkCheck, "Network Check"),
+ ProfileIcon("network_locked", Icons.Filled.NetworkLocked, "Network Locked"),
+ ProfileIcon("network_ping", Icons.Filled.NetworkPing, "Network Ping"),
+ ProfileIcon("hub", Icons.Filled.Hub, "Hub"),
+ ProfileIcon("dns", Icons.Filled.Dns, "DNS"),
+ ProfileIcon("lan", Icons.Filled.Lan, "LAN"),
+ ProfileIcon("cable", Icons.Filled.Cable, "Cable"),
+ ProfileIcon("settings_ethernet", Icons.Filled.SettingsEthernet, "Ethernet"),
+ ProfileIcon("cell_tower", Icons.Filled.CellTower, "Cell Tower"),
+ ProfileIcon("cell_wifi", Icons.Filled.CellWifi, "Cell WiFi"),
+ ProfileIcon("signal_cellular_4_bar", Icons.Filled.SignalCellular4Bar, "4G"),
+ ProfileIcon("signal_cellular_alt", Icons.Filled.SignalCellularAlt, "Cellular"),
+ // Some newer icons might not be available in all versions
+ // ProfileIcon("5g", Icons.Filled.FiveG, "5G"),
+ // ProfileIcon("4g_mobiledata", Icons.Filled.FourGMobiledata, "4G"),
+ // ProfileIcon("lte_mobiledata", Icons.Filled.LteMobiledata, "LTE")
+ ),
+ ),
+ IconCategory(
+ "Global & Cloud",
+ listOf(
+ ProfileIcon("language", Icons.Filled.Language, "Globe"),
+ ProfileIcon("public", Icons.Filled.Public, "Public"),
+ ProfileIcon("public_off", Icons.Filled.PublicOff, "Public Off"),
+ ProfileIcon("travel_explore", Icons.Filled.TravelExplore, "Explore"),
+ ProfileIcon("cloud", Icons.Filled.Cloud, "Cloud"),
+ ProfileIcon("cloud_upload", Icons.Filled.CloudUpload, "Cloud Upload"),
+ ProfileIcon("cloud_download", Icons.Filled.CloudDownload, "Cloud Download"),
+ ProfileIcon("cloud_sync", Icons.Filled.CloudSync, "Cloud Sync"),
+ ProfileIcon("cloud_done", Icons.Filled.CloudDone, "Cloud Done"),
+ ProfileIcon("cloud_off", Icons.Filled.CloudOff, "Cloud Off"),
+ ProfileIcon("cloud_queue", Icons.Filled.CloudQueue, "Cloud Queue"),
+ ProfileIcon("backup", Icons.Filled.Backup, "Backup"),
+ ProfileIcon("satellite", Icons.Filled.Satellite, "Satellite"),
+ ProfileIcon("satellite_alt", Icons.Filled.SatelliteAlt, "Satellite Alt"),
+ ProfileIcon("share", Icons.Filled.Share, "Share"),
+ ProfileIcon("share_location", Icons.Filled.ShareLocation, "Share Location"),
+ ProfileIcon("sync", Icons.Filled.Sync, "Sync"),
+ ProfileIcon("sync_alt", Icons.Filled.SyncAlt, "Sync Alt"),
+ ),
+ ),
+ IconCategory(
+ "Devices",
+ listOf(
+ ProfileIcon("computer", Icons.Filled.Computer, "Computer"),
+ ProfileIcon("desktop_windows", Icons.Filled.DesktopWindows, "Desktop"),
+ ProfileIcon("laptop", Icons.Filled.Laptop, "Laptop"),
+ ProfileIcon("laptop_chromebook", Icons.Filled.LaptopChromebook, "Chromebook"),
+ ProfileIcon("laptop_mac", Icons.Filled.LaptopMac, "MacBook"),
+ ProfileIcon("laptop_windows", Icons.Filled.LaptopWindows, "Windows Laptop"),
+ ProfileIcon("smartphone", Icons.Filled.Smartphone, "Phone"),
+ ProfileIcon("phone_android", Icons.Filled.PhoneAndroid, "Android"),
+ ProfileIcon("phone_iphone", Icons.Filled.PhoneIphone, "iPhone"),
+ ProfileIcon("tablet", Icons.Filled.Tablet, "Tablet"),
+ ProfileIcon("tablet_android", Icons.Filled.TabletAndroid, "Android Tablet"),
+ ProfileIcon("tablet_mac", Icons.Filled.TabletMac, "iPad"),
+ ProfileIcon("watch", Icons.Filled.Watch, "Watch"),
+ ProfileIcon("tv", Icons.Filled.Tv, "TV"),
+ ProfileIcon("smart_display", Icons.Filled.SmartDisplay, "Smart Display"),
+ ProfileIcon("speaker", Icons.Filled.Speaker, "Speaker"),
+ ProfileIcon("headphones", Icons.Filled.Headphones, "Headphones"),
+ ProfileIcon("devices", Icons.Filled.Devices, "Devices"),
+ ProfileIcon("device_hub", Icons.Filled.DeviceHub, "Device Hub"),
+ ProfileIcon("cast", Icons.Filled.Cast, "Cast"),
+ ProfileIcon("cast_connected", Icons.Filled.CastConnected, "Cast Connected"),
+ ),
+ ),
+ IconCategory(
+ "Places & Activities",
+ listOf(
+ ProfileIcon("home", Icons.Filled.Home, "Home"),
+ ProfileIcon("house", Icons.Filled.House, "House"),
+ ProfileIcon("cabin", Icons.Filled.Cabin, "Cabin"),
+ ProfileIcon("apartment", Icons.Filled.Apartment, "Apartment"),
+ ProfileIcon("work", Icons.Filled.Work, "Work"),
+ ProfileIcon("work_outline", Icons.Outlined.Work, "Work Outline"),
+ ProfileIcon("business", Icons.Filled.Business, "Business"),
+ ProfileIcon("business_center", Icons.Filled.BusinessCenter, "Business Center"),
+ ProfileIcon("school", Icons.Filled.School, "School"),
+ ProfileIcon("local_library", Icons.Filled.LocalLibrary, "Library"),
+ ProfileIcon("store", Icons.Filled.Store, "Store"),
+ ProfileIcon("storefront", Icons.Filled.Storefront, "Storefront"),
+ ProfileIcon("restaurant", Icons.Filled.Restaurant, "Restaurant"),
+ ProfileIcon("coffee", Icons.Filled.Coffee, "Coffee"),
+ ProfileIcon("local_cafe", Icons.Filled.LocalCafe, "Cafe"),
+ ProfileIcon("hotel", Icons.Filled.Hotel, "Hotel"),
+ ProfileIcon("flight", Icons.Filled.Flight, "Flight"),
+ ProfileIcon("flight_takeoff", Icons.Filled.FlightTakeoff, "Takeoff"),
+ ProfileIcon("flight_land", Icons.Filled.FlightLand, "Landing"),
+ ProfileIcon("train", Icons.Filled.Train, "Train"),
+ ProfileIcon("directions_car", Icons.Filled.DirectionsCar, "Car"),
+ ProfileIcon("directions_bus", Icons.Filled.DirectionsBus, "Bus"),
+ ProfileIcon("directions_subway", Icons.Filled.DirectionsSubway, "Subway"),
+ ProfileIcon("beach_access", Icons.Filled.BeachAccess, "Beach"),
+ ProfileIcon("park", Icons.Filled.Park, "Park"),
+ ProfileIcon("fitness_center", Icons.Filled.FitnessCenter, "Gym"),
+ ProfileIcon("sports_esports", Icons.Filled.SportsEsports, "Gaming"),
+ ProfileIcon("stadium", Icons.Filled.Stadium, "Stadium"),
+ ),
+ ),
+ IconCategory(
+ "Communication",
+ listOf(
+ ProfileIcon("email", Icons.Filled.Email, "Email"),
+ ProfileIcon("mail", Icons.Filled.Mail, "Mail"),
+ ProfileIcon("message", Icons.Filled.Message, "Message"),
+ ProfileIcon("chat", Icons.Filled.Chat, "Chat"),
+ ProfileIcon("chat_bubble", Icons.Filled.ChatBubble, "Chat Bubble"),
+ ProfileIcon("forum", Icons.Filled.Forum, "Forum"),
+ ProfileIcon("comment", Icons.Filled.Comment, "Comment"),
+ ProfileIcon("call", Icons.Filled.Call, "Call"),
+ ProfileIcon("video_call", Icons.Filled.VideoCall, "Video Call"),
+ ProfileIcon("contacts", Icons.Filled.Contacts, "Contacts"),
+ ProfileIcon("contact_mail", Icons.Filled.ContactMail, "Contact Mail"),
+ ProfileIcon("contact_phone", Icons.Filled.ContactPhone, "Contact Phone"),
+ ProfileIcon("notifications", Icons.Filled.Notifications, "Notifications"),
+ ProfileIcon("notifications_active", Icons.Filled.NotificationsActive, "Active Notif"),
+ ProfileIcon("notification_important", Icons.Filled.NotificationImportant, "Important"),
+ ProfileIcon("announcement", Icons.Filled.Announcement, "Announcement"),
+ ),
+ ),
+ IconCategory(
+ "Media & Entertainment",
+ listOf(
+ ProfileIcon("play_arrow", Icons.Filled.PlayArrow, "Play"),
+ ProfileIcon("play_circle", Icons.Filled.PlayCircle, "Play Circle"),
+ ProfileIcon("pause", Icons.Filled.Pause, "Pause"),
+ ProfileIcon("pause_circle", Icons.Filled.PauseCircle, "Pause Circle"),
+ ProfileIcon("stop", Icons.Filled.Stop, "Stop"),
+ ProfileIcon("skip_next", Icons.Filled.SkipNext, "Next"),
+ ProfileIcon("skip_previous", Icons.Filled.SkipPrevious, "Previous"),
+ ProfileIcon("music_note", Icons.Filled.MusicNote, "Music"),
+ ProfileIcon("audiotrack", Icons.Filled.Audiotrack, "Audio"),
+ ProfileIcon("album", Icons.Filled.Album, "Album"),
+ ProfileIcon("mic", Icons.Filled.Mic, "Microphone"),
+ ProfileIcon("videocam", Icons.Filled.Videocam, "Video"),
+ ProfileIcon("movie", Icons.Filled.Movie, "Movie"),
+ ProfileIcon("theaters", Icons.Filled.Theaters, "Theater"),
+ ProfileIcon("live_tv", Icons.Filled.LiveTv, "Live TV"),
+ ProfileIcon("photo", Icons.Filled.Photo, "Photo"),
+ ProfileIcon("photo_camera", Icons.Filled.PhotoCamera, "Camera"),
+ ProfileIcon("photo_library", Icons.Filled.PhotoLibrary, "Gallery"),
+ ProfileIcon("games", Icons.Filled.Games, "Games"),
+ ProfileIcon("sports_soccer", Icons.Filled.SportsSoccer, "Soccer"),
+ ProfileIcon("sports_basketball", Icons.Filled.SportsBasketball, "Basketball"),
+ ProfileIcon("sports_football", Icons.Filled.SportsFootball, "Football"),
+ ),
+ ),
+ IconCategory(
+ "Files & Folders",
+ listOf(
+ ProfileIcon("folder", Icons.Filled.Folder, "Folder"),
+ ProfileIcon("folder_open", Icons.Filled.FolderOpen, "Folder Open"),
+ ProfileIcon("folder_shared", Icons.Filled.FolderShared, "Shared Folder"),
+ ProfileIcon("folder_special", Icons.Filled.FolderSpecial, "Special Folder"),
+ ProfileIcon("create_new_folder", Icons.Filled.CreateNewFolder, "New Folder"),
+ ProfileIcon("insert_drive_file", Icons.AutoMirrored.Filled.InsertDriveFile, "File"),
+ ProfileIcon("description", Icons.Filled.Description, "Document"),
+ ProfileIcon("article", Icons.AutoMirrored.Filled.Article, "Article"),
+ ProfileIcon("picture_as_pdf", Icons.Filled.PictureAsPdf, "PDF"),
+ ProfileIcon("attach_file", Icons.Filled.AttachFile, "Attachment"),
+ ProfileIcon("file_download", Icons.Filled.FileDownload, "Download"),
+ ProfileIcon("file_upload", Icons.Filled.FileUpload, "Upload"),
+ ProfileIcon("file_copy", Icons.Filled.FileCopy, "Copy"),
+ ProfileIcon("content_copy", Icons.Filled.ContentCopy, "Copy Content"),
+ ProfileIcon("content_paste", Icons.Filled.ContentPaste, "Paste"),
+ ProfileIcon("save", Icons.Filled.Save, "Save"),
+ ProfileIcon("save_alt", Icons.Filled.SaveAlt, "Save Alt"),
+ ProfileIcon("archive", Icons.Filled.Archive, "Archive"),
+ ProfileIcon("inventory", Icons.Filled.Inventory, "Inventory"),
+ ProfileIcon("storage", Icons.Filled.Storage, "Storage"),
+ ),
+ ),
+ IconCategory(
+ "Actions & Tools",
+ listOf(
+ ProfileIcon("settings", Icons.Filled.Settings, "Settings"),
+ ProfileIcon("build", Icons.Filled.Build, "Build"),
+ ProfileIcon("extension", Icons.Filled.Extension, "Extension"),
+ ProfileIcon("search", Icons.Filled.Search, "Search"),
+ ProfileIcon("zoom_in", Icons.Filled.ZoomIn, "Zoom In"),
+ ProfileIcon("zoom_out", Icons.Filled.ZoomOut, "Zoom Out"),
+ ProfileIcon("info", Icons.Filled.Info, "Info"),
+ ProfileIcon("help", Icons.Filled.Help, "Help"),
+ ProfileIcon("help_center", Icons.Filled.HelpCenter, "Help Center"),
+ ProfileIcon("explore", Icons.Filled.Explore, "Explore"),
+ ProfileIcon("bookmark", Icons.Filled.Bookmark, "Bookmark"),
+ ProfileIcon("bookmarks", Icons.Filled.Bookmarks, "Bookmarks"),
+ ProfileIcon("history", Icons.Filled.History, "History"),
+ ProfileIcon("schedule", Icons.Filled.Schedule, "Schedule"),
+ ProfileIcon("alarm", Icons.Filled.Alarm, "Alarm"),
+ ProfileIcon("timer", Icons.Filled.Timer, "Timer"),
+ ProfileIcon("update", Icons.Filled.Update, "Update"),
+ ProfileIcon("upgrade", Icons.Filled.Upgrade, "Upgrade"),
+ ProfileIcon("autorenew", Icons.Filled.Autorenew, "Auto Renew"),
+ ProfileIcon("cached", Icons.Filled.Cached, "Cached"),
+ ProfileIcon("refresh", Icons.Filled.Refresh, "Refresh"),
+ ProfileIcon("sync_problem", Icons.Filled.SyncProblem, "Sync Problem"),
+ ProfileIcon("download", Icons.Filled.Download, "Download"),
+ ProfileIcon("upload", Icons.Filled.Upload, "Upload"),
+ ProfileIcon("print", Icons.Filled.Print, "Print"),
+ ProfileIcon("delete", Icons.Filled.Delete, "Delete"),
+ ),
+ ),
+ IconCategory(
+ "Status & Indicators",
+ listOf(
+ ProfileIcon("check", Icons.Filled.Check, "Check"),
+ ProfileIcon("check_circle", Icons.Filled.CheckCircle, "Check Circle"),
+ ProfileIcon("verified", Icons.Filled.Verified, "Verified"),
+ ProfileIcon("done", Icons.Filled.Done, "Done"),
+ ProfileIcon("done_all", Icons.Filled.DoneAll, "Done All"),
+ ProfileIcon("close", Icons.Filled.Close, "Close"),
+ ProfileIcon("cancel", Icons.Filled.Cancel, "Cancel"),
+ ProfileIcon("error", Icons.Filled.Error, "Error"),
+ ProfileIcon("warning", Icons.Filled.Warning, "Warning"),
+ ProfileIcon("report", Icons.Filled.Report, "Report"),
+ ProfileIcon("flag", Icons.Filled.Flag, "Flag"),
+ ProfileIcon("star", Icons.Filled.Star, "Star"),
+ ProfileIcon("star_half", Icons.Filled.StarHalf, "Half Star"),
+ ProfileIcon("star_outline", Icons.Filled.StarOutline, "Star Outline"),
+ ProfileIcon("favorite", Icons.Filled.Favorite, "Favorite"),
+ ProfileIcon("favorite_border", Icons.Filled.FavoriteBorder, "Favorite Border"),
+ ProfileIcon("thumb_up", Icons.Filled.ThumbUp, "Like"),
+ ProfileIcon("thumb_down", Icons.Filled.ThumbDown, "Dislike"),
+ ProfileIcon("priority_high", Icons.Filled.PriorityHigh, "High Priority"),
+ ProfileIcon("new_releases", Icons.Filled.NewReleases, "New"),
+ ProfileIcon("fiber_new", Icons.Filled.FiberNew, "New Badge"),
+ ProfileIcon("offline_pin", Icons.Filled.OfflinePin, "Offline"),
+ ProfileIcon("online_prediction", Icons.Filled.OnlinePrediction, "Online"),
+ ),
+ ),
+ IconCategory(
+ "Nature & Weather",
+ listOf(
+ ProfileIcon("wb_sunny", Icons.Filled.WbSunny, "Sunny"),
+ ProfileIcon("nights_stay", Icons.Filled.NightsStay, "Night"),
+ ProfileIcon("brightness_high", Icons.Filled.BrightnessHigh, "Bright"),
+ ProfileIcon("wb_cloudy", Icons.Filled.WbCloudy, "Cloudy"),
+ ProfileIcon("cloud", Icons.Filled.Cloud, "Cloud"),
+ ProfileIcon("ac_unit", Icons.Filled.AcUnit, "Snow"),
+ ProfileIcon("thunderstorm", Icons.Filled.Thunderstorm, "Storm"),
+ ProfileIcon("water_drop", Icons.Filled.WaterDrop, "Water"),
+ ProfileIcon("waves", Icons.Filled.Waves, "Waves"),
+ ProfileIcon("eco", Icons.Filled.Eco, "Eco"),
+ ProfileIcon("nature", Icons.Filled.Nature, "Nature"),
+ ProfileIcon("nature_people", Icons.Filled.NaturePeople, "Nature People"),
+ ProfileIcon("forest", Icons.Filled.Forest, "Forest"),
+ ProfileIcon("grass", Icons.Filled.Grass, "Grass"),
+ ProfileIcon("local_florist", Icons.Filled.LocalFlorist, "Flower"),
+ ProfileIcon("pets", Icons.Filled.Pets, "Pets"),
+ ProfileIcon("bug_report", Icons.Filled.BugReport, "Bug"),
+ ProfileIcon("spa", Icons.Filled.Spa, "Spa"),
+ ProfileIcon("pool", Icons.Filled.Pool, "Pool"),
+ ProfileIcon("hot_tub", Icons.Filled.HotTub, "Hot Tub"),
+ ),
+ ),
+ IconCategory(
+ "Transportation",
+ listOf(
+ ProfileIcon("local_shipping", Icons.Filled.LocalShipping, "Shipping"),
+ ProfileIcon("local_taxi", Icons.Filled.LocalTaxi, "Taxi"),
+ ProfileIcon("directions_bike", Icons.Filled.DirectionsBike, "Bike"),
+ ProfileIcon("directions_boat", Icons.Filled.DirectionsBoat, "Boat"),
+ ProfileIcon("directions_railway", Icons.Filled.DirectionsRailway, "Railway"),
+ ProfileIcon("directions_transit", Icons.Filled.DirectionsTransit, "Transit"),
+ ProfileIcon("directions_walk", Icons.Filled.DirectionsWalk, "Walk"),
+ ProfileIcon("directions_run", Icons.Filled.DirectionsRun, "Run"),
+ ProfileIcon("electric_car", Icons.Filled.ElectricCar, "Electric Car"),
+ ProfileIcon("electric_bike", Icons.Filled.ElectricBike, "E-Bike"),
+ ProfileIcon("electric_scooter", Icons.Filled.ElectricScooter, "E-Scooter"),
+ ProfileIcon("two_wheeler", Icons.Filled.TwoWheeler, "Two Wheeler"),
+ ProfileIcon("motorcycle", Icons.Filled.Motorcycle, "Motorcycle"),
+ ProfileIcon("airport_shuttle", Icons.Filled.AirportShuttle, "Shuttle"),
+ ProfileIcon("commute", Icons.Filled.Commute, "Commute"),
+ ProfileIcon("rocket", Icons.Filled.Rocket, "Rocket"),
+ ProfileIcon("rocket_launch", Icons.Filled.RocketLaunch, "Rocket Launch"),
+ ProfileIcon("sailing", Icons.Filled.Sailing, "Sailing"),
+ ),
+ ),
+ IconCategory(
+ "Shopping & Finance",
+ listOf(
+ ProfileIcon("shopping_cart", Icons.Filled.ShoppingCart, "Cart"),
+ ProfileIcon("shopping_bag", Icons.Filled.ShoppingBag, "Shopping Bag"),
+ ProfileIcon("shopping_basket", Icons.Filled.ShoppingBasket, "Basket"),
+ ProfileIcon("add_shopping_cart", Icons.Filled.AddShoppingCart, "Add to Cart"),
+ ProfileIcon("local_mall", Icons.Filled.LocalMall, "Mall"),
+ ProfileIcon("local_grocery_store", Icons.Filled.LocalGroceryStore, "Grocery"),
+ ProfileIcon("payment", Icons.Filled.Payment, "Payment"),
+ ProfileIcon("credit_card", Icons.Filled.CreditCard, "Credit Card"),
+ ProfileIcon("account_balance", Icons.Filled.AccountBalance, "Bank"),
+ ProfileIcon("account_balance_wallet", Icons.Filled.AccountBalanceWallet, "Wallet"),
+ ProfileIcon("wallet", Icons.Filled.Wallet, "Wallet"),
+ ProfileIcon("savings", Icons.Filled.Savings, "Savings"),
+ ProfileIcon("attach_money", Icons.Filled.AttachMoney, "Money"),
+ ProfileIcon("money", Icons.Filled.Money, "Cash"),
+ ProfileIcon("paid", Icons.Filled.Paid, "Paid"),
+ ProfileIcon("currency_bitcoin", Icons.Filled.CurrencyBitcoin, "Bitcoin"),
+ ProfileIcon("currency_exchange", Icons.Filled.CurrencyExchange, "Exchange"),
+ ProfileIcon("receipt", Icons.Filled.Receipt, "Receipt"),
+ ProfileIcon("receipt_long", Icons.Filled.ReceiptLong, "Receipt Long"),
+ ProfileIcon("sell", Icons.Filled.Sell, "Sell"),
+ ProfileIcon("discount", Icons.Filled.Discount, "Discount"),
+ ProfileIcon("redeem", Icons.Filled.Redeem, "Redeem"),
+ ),
+ ),
+ IconCategory(
+ "Health & Wellness",
+ listOf(
+ ProfileIcon("medical_services", Icons.Filled.MedicalServices, "Medical"),
+ ProfileIcon("medication", Icons.Filled.Medication, "Medication"),
+ ProfileIcon("vaccines", Icons.Filled.Vaccines, "Vaccine"),
+ ProfileIcon("healing", Icons.Filled.Healing, "Healing"),
+ ProfileIcon("health_and_safety", Icons.Filled.HealthAndSafety, "Health & Safety"),
+ ProfileIcon("local_hospital", Icons.Filled.LocalHospital, "Hospital"),
+ ProfileIcon("local_pharmacy", Icons.Filled.LocalPharmacy, "Pharmacy"),
+ ProfileIcon("monitor_heart", Icons.Filled.MonitorHeart, "Heart Monitor"),
+ ProfileIcon("bloodtype", Icons.Filled.Bloodtype, "Blood Type"),
+ ProfileIcon("emergency", Icons.Filled.Emergency, "Emergency"),
+ ProfileIcon("medical_information", Icons.Filled.MedicalInformation, "Medical Info"),
+ ProfileIcon("psychology", Icons.Filled.Psychology, "Psychology"),
+ ProfileIcon("self_improvement", Icons.Filled.SelfImprovement, "Self Improvement"),
+ ProfileIcon("mood", Icons.Filled.Mood, "Happy"),
+ ProfileIcon("mood_bad", Icons.Filled.MoodBad, "Sad"),
+ ProfileIcon("sentiment_satisfied", Icons.Filled.SentimentSatisfied, "Satisfied"),
+ ProfileIcon("sentiment_dissatisfied", Icons.Filled.SentimentDissatisfied, "Dissatisfied"),
+ ProfileIcon("sick", Icons.Filled.Sick, "Sick"),
+ ProfileIcon("masks", Icons.Filled.Masks, "Masks"),
+ ProfileIcon("sanitizer", Icons.Filled.Sanitizer, "Sanitizer"),
+ ProfileIcon("clean_hands", Icons.Filled.CleanHands, "Clean Hands"),
+ ProfileIcon("coronavirus", Icons.Filled.Coronavirus, "Virus"),
+ ),
+ ),
+ IconCategory(
+ "Food & Dining",
+ listOf(
+ ProfileIcon("restaurant_menu", Icons.Filled.RestaurantMenu, "Menu"),
+ ProfileIcon("fastfood", Icons.Filled.Fastfood, "Fast Food"),
+ ProfileIcon("lunch_dining", Icons.Filled.LunchDining, "Lunch"),
+ ProfileIcon("dinner_dining", Icons.Filled.DinnerDining, "Dinner"),
+ ProfileIcon("breakfast_dining", Icons.Filled.BreakfastDining, "Breakfast"),
+ ProfileIcon("brunch_dining", Icons.Filled.BrunchDining, "Brunch"),
+ ProfileIcon("bakery_dining", Icons.Filled.BakeryDining, "Bakery"),
+ ProfileIcon("icecream", Icons.Filled.Icecream, "Ice Cream"),
+ ProfileIcon("cake", Icons.Filled.Cake, "Cake"),
+ ProfileIcon("local_pizza", Icons.Filled.LocalPizza, "Pizza"),
+ ProfileIcon("local_bar", Icons.Filled.LocalBar, "Bar"),
+ ProfileIcon("local_drink", Icons.Filled.LocalDrink, "Drink"),
+ ProfileIcon("liquor", Icons.Filled.Liquor, "Liquor"),
+ ProfileIcon("wine_bar", Icons.Filled.WineBar, "Wine"),
+ ProfileIcon("sports_bar", Icons.Filled.SportsBar, "Sports Bar"),
+ ProfileIcon("kitchen", Icons.Filled.Kitchen, "Kitchen"),
+ ProfileIcon("dining", Icons.Filled.Dining, "Dining"),
+ ProfileIcon("food_bank", Icons.Filled.FoodBank, "Food Bank"),
+ ProfileIcon("ramen_dining", Icons.Filled.RamenDining, "Ramen"),
+ ProfileIcon("rice_bowl", Icons.Filled.RiceBowl, "Rice Bowl"),
+ ProfileIcon("soup_kitchen", Icons.Filled.SoupKitchen, "Soup"),
+ ProfileIcon("takeout_dining", Icons.Filled.TakeoutDining, "Takeout"),
+ ProfileIcon("delivery_dining", Icons.Filled.DeliveryDining, "Delivery"),
+ ),
+ ),
+ )
+
+ fun getAllIcons(): List {
+ return categories.flatMap { it.icons }
+ }
+
+ fun getIconById(id: String?): ImageVector? {
+ if (id == null) return null
+ return getAllIcons().find { it.id == id }?.icon
+ }
+
+ fun getCategoryForIcon(iconId: String): String? {
+ return categories.find { category ->
+ category.icons.any { it.id == iconId }
+ }?.name
+ }
+
+ fun searchIcons(query: String): List {
+ val lowercaseQuery = query.lowercase()
+ return getAllIcons().filter { icon ->
+ icon.id.contains(lowercaseQuery) ||
+ icon.label.lowercase().contains(lowercaseQuery)
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/ProfileIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/ProfileIcons.kt
new file mode 100644
index 0000000..19c0367
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/ProfileIcons.kt
@@ -0,0 +1,38 @@
+package io.nekohasekai.sfa.compose.util
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
+import androidx.compose.ui.graphics.vector.ImageVector
+import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary
+
+data class ProfileIcon(
+ val id: String,
+ val icon: ImageVector,
+ val label: String,
+)
+
+object ProfileIcons {
+ // Use the complete Material Icons library with all available icons
+ val availableIcons: List
+ get() = MaterialIconsLibrary.getAllIcons()
+
+ fun getIconById(id: String?): ImageVector? {
+ if (id == null) return null
+ return MaterialIconsLibrary.getIconById(id)
+ }
+
+ fun getDefaultIconForType(isRemote: Boolean): ImageVector {
+ // Use the same default icon for all profile types
+ return Icons.AutoMirrored.Default.InsertDriveFile
+ }
+
+ fun getCategoryForIcon(iconId: String): String? {
+ return MaterialIconsLibrary.getCategoryForIcon(iconId)
+ }
+
+ fun searchIcons(query: String): List {
+ return MaterialIconsLibrary.searchIcons(query)
+ }
+
+ fun getCategories() = MaterialIconsLibrary.categories
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeGenerator.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeGenerator.kt
new file mode 100644
index 0000000..dea381f
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeGenerator.kt
@@ -0,0 +1,38 @@
+package io.nekohasekai.sfa.compose.util
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.qrcode.QRCodeWriter
+
+object QRCodeGenerator {
+ fun generate(
+ content: String,
+ size: Int = 512,
+ foregroundColor: Int = Color.BLACK,
+ backgroundColor: Int = Color.WHITE,
+ ): Bitmap {
+ val writer = QRCodeWriter()
+ val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, size, size)
+
+ val width = bitMatrix.width
+ val height = bitMatrix.height
+ val pixels = IntArray(width * height)
+
+ for (y in 0 until height) {
+ val offset = y * width
+ for (x in 0 until width) {
+ pixels[offset + x] =
+ if (bitMatrix.get(x, y)) {
+ foregroundColor
+ } else {
+ backgroundColor
+ }
+ }
+ }
+
+ return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply {
+ setPixels(pixels, 0, width, 0, 0, width, height)
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeUtils.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeUtils.kt
new file mode 100644
index 0000000..cb224d1
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeUtils.kt
@@ -0,0 +1,146 @@
+package io.nekohasekai.sfa.compose.util
+
+import android.content.ContentValues
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import android.widget.Toast
+import androidx.core.content.FileProvider
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+
+suspend fun saveQRCodeToGallery(
+ context: Context,
+ bitmap: Bitmap,
+ profileName: String,
+) = withContext(Dispatchers.IO) {
+ try {
+ val filename = "SingBox_QR_${profileName}_${System.currentTimeMillis()}.png"
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ // For Android 10 and above, use MediaStore
+ val contentValues =
+ ContentValues().apply {
+ put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
+ put(MediaStore.MediaColumns.MIME_TYPE, "image/png")
+ put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/SingBox")
+ put(MediaStore.Images.Media.IS_PENDING, 1)
+ }
+
+ val resolver = context.contentResolver
+ val imageUri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
+
+ imageUri?.let { uri ->
+ resolver.openOutputStream(uri)?.use { outputStream ->
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
+ }
+
+ contentValues.clear()
+ contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
+ resolver.update(uri, contentValues, null, null)
+
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ context,
+ context.getString(io.nekohasekai.sfa.R.string.qr_code_saved_to_gallery),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ } else {
+ // For older Android versions
+ val imagesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
+ val singboxDir = File(imagesDir, "SingBox")
+ if (!singboxDir.exists()) {
+ singboxDir.mkdirs()
+ }
+
+ val imageFile = File(singboxDir, filename)
+ FileOutputStream(imageFile).use { outputStream ->
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
+ }
+
+ // Notify gallery about the new image
+ MediaStore.Images.Media.insertImage(
+ context.contentResolver,
+ imageFile.absolutePath,
+ filename,
+ "SingBox QR Code",
+ )
+
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, "QR code saved to gallery", Toast.LENGTH_SHORT).show()
+ }
+ }
+ } catch (e: Exception) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ context,
+ context.getString(io.nekohasekai.sfa.R.string.failed_to_save_qr_code, e.message),
+ Toast.LENGTH_LONG,
+ ).show()
+ e.printStackTrace()
+ }
+ }
+}
+
+suspend fun shareQRCodeImage(
+ context: Context,
+ bitmap: Bitmap,
+ profileName: String,
+) = withContext(Dispatchers.IO) {
+ try {
+ // Save bitmap to cache directory
+ val cachePath = File(context.cacheDir, "images")
+ cachePath.mkdirs()
+ val file = File(cachePath, "qr_${profileName}_${System.currentTimeMillis()}.png")
+
+ FileOutputStream(file).use { stream ->
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
+ }
+
+ // Get URI for the file
+ val contentUri =
+ FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.cache",
+ file,
+ )
+
+ // Create share intent
+ val shareIntent =
+ Intent().apply {
+ action = Intent.ACTION_SEND
+ type = "image/png"
+ putExtra(Intent.EXTRA_STREAM, contentUri)
+ putExtra(
+ Intent.EXTRA_TEXT,
+ context.getString(io.nekohasekai.sfa.R.string.profile_qr_code_text, profileName),
+ )
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+
+ withContext(Dispatchers.Main) {
+ context.startActivity(
+ Intent.createChooser(
+ shareIntent,
+ context.getString(io.nekohasekai.sfa.R.string.intent_share_qr_code),
+ ),
+ )
+ }
+ } catch (e: Exception) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ context,
+ context.getString(io.nekohasekai.sfa.R.string.failed_to_share_qr_code, e.message),
+ Toast.LENGTH_LONG,
+ ).show()
+ e.printStackTrace()
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/RelativeTimeFormatter.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/RelativeTimeFormatter.kt
new file mode 100644
index 0000000..a12f2c9
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/RelativeTimeFormatter.kt
@@ -0,0 +1,98 @@
+package io.nekohasekai.sfa.compose.util
+
+import android.content.Context
+import io.nekohasekai.sfa.R
+import java.text.DateFormat
+import java.util.Date
+import java.util.concurrent.TimeUnit
+
+object RelativeTimeFormatter {
+ /**
+ * Formats a date as relative time for recent dates (within 7 days)
+ * or as full date/time for older dates.
+ */
+ fun format(
+ context: Context,
+ date: Date?,
+ ): String {
+ if (date == null) return ""
+
+ val now = System.currentTimeMillis()
+ val diff = now - date.time
+
+ // Handle negative differences (future dates)
+ if (diff < 0) {
+ return DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date)
+ }
+
+ val seconds = TimeUnit.MILLISECONDS.toSeconds(diff)
+ val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
+ val hours = TimeUnit.MILLISECONDS.toHours(diff)
+ val days = TimeUnit.MILLISECONDS.toDays(diff)
+
+ return when {
+ seconds < 60 -> context.getString(R.string.time_just_now)
+ minutes < 60 ->
+ context.resources.getQuantityString(
+ R.plurals.time_minutes_ago,
+ minutes.toInt(),
+ minutes,
+ )
+ hours < 24 ->
+ context.resources.getQuantityString(
+ R.plurals.time_hours_ago,
+ hours.toInt(),
+ hours,
+ )
+ days == 1L -> context.getString(R.string.time_yesterday)
+ days < 7 ->
+ context.resources.getQuantityString(
+ R.plurals.time_days_ago,
+ days.toInt(),
+ days,
+ )
+ else -> DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date)
+ }
+ }
+
+ /**
+ * Formats a date as short relative time for compact displays.
+ * Uses shorter format like "2h" instead of "2 hours ago".
+ */
+ fun formatShort(
+ context: Context,
+ date: Date?,
+ ): String {
+ if (date == null) return ""
+
+ val now = System.currentTimeMillis()
+ val diff = now - date.time
+
+ // Handle negative differences (future dates)
+ if (diff < 0) {
+ return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date)
+ }
+
+ val seconds = TimeUnit.MILLISECONDS.toSeconds(diff)
+ val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
+ val hours = TimeUnit.MILLISECONDS.toHours(diff)
+ val days = TimeUnit.MILLISECONDS.toDays(diff)
+
+ return when {
+ seconds < 60 -> context.getString(R.string.time_now)
+ minutes < 60 -> context.getString(R.string.time_minutes_short, minutes)
+ hours < 24 -> context.getString(R.string.time_hours_short, hours)
+ days == 1L -> context.getString(R.string.time_yesterday_short)
+ days < 7 -> context.getString(R.string.time_days_short, days)
+ else -> DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date)
+ }
+ }
+
+ /**
+ * Gets the exact date/time string for tooltips or detailed views.
+ */
+ fun formatExact(date: Date?): String {
+ if (date == null) return ""
+ return DateFormat.getDateTimeInstance(DateFormat.FULL, DateFormat.MEDIUM).format(date)
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AVIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AVIcons.kt
new file mode 100644
index 0000000..46fd80b
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AVIcons.kt
@@ -0,0 +1,306 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.FeaturedPlayList
+import androidx.compose.material.icons.automirrored.filled.FeaturedVideo
+import androidx.compose.material.icons.automirrored.filled.Note
+import androidx.compose.material.icons.automirrored.filled.QueueMusic
+import androidx.compose.material.icons.automirrored.filled.VolumeDown
+import androidx.compose.material.icons.automirrored.filled.VolumeMute
+import androidx.compose.material.icons.automirrored.filled.VolumeOff
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
+import androidx.compose.material.icons.filled.AddToQueue
+import androidx.compose.material.icons.filled.Airplay
+import androidx.compose.material.icons.filled.Album
+import androidx.compose.material.icons.filled.ArtTrack
+import androidx.compose.material.icons.filled.AudioFile
+import androidx.compose.material.icons.filled.AvTimer
+import androidx.compose.material.icons.filled.BrandingWatermark
+import androidx.compose.material.icons.filled.CallToAction
+import androidx.compose.material.icons.filled.ClosedCaption
+import androidx.compose.material.icons.filled.ClosedCaptionDisabled
+import androidx.compose.material.icons.filled.ClosedCaptionOff
+import androidx.compose.material.icons.filled.ControlCamera
+import androidx.compose.material.icons.filled.Equalizer
+import androidx.compose.material.icons.filled.Explicit
+import androidx.compose.material.icons.filled.FastForward
+import androidx.compose.material.icons.filled.FastRewind
+import androidx.compose.material.icons.filled.FiberDvr
+import androidx.compose.material.icons.filled.FiberManualRecord
+import androidx.compose.material.icons.filled.FiberNew
+import androidx.compose.material.icons.filled.FiberPin
+import androidx.compose.material.icons.filled.FiberSmartRecord
+import androidx.compose.material.icons.filled.Forward10
+import androidx.compose.material.icons.filled.Forward30
+import androidx.compose.material.icons.filled.Forward5
+import androidx.compose.material.icons.filled.Games
+import androidx.compose.material.icons.filled.Hd
+import androidx.compose.material.icons.filled.Hearing
+import androidx.compose.material.icons.filled.HearingDisabled
+import androidx.compose.material.icons.filled.HighQuality
+import androidx.compose.material.icons.filled.InterpreterMode
+import androidx.compose.material.icons.filled.LibraryAdd
+import androidx.compose.material.icons.filled.LibraryAddCheck
+import androidx.compose.material.icons.filled.LibraryBooks
+import androidx.compose.material.icons.filled.LibraryMusic
+import androidx.compose.material.icons.filled.Loop
+import androidx.compose.material.icons.filled.Lyrics
+import androidx.compose.material.icons.filled.Mic
+import androidx.compose.material.icons.filled.MicExternalOff
+import androidx.compose.material.icons.filled.MicExternalOn
+import androidx.compose.material.icons.filled.MicNone
+import androidx.compose.material.icons.filled.MicOff
+import androidx.compose.material.icons.filled.MissedVideoCall
+import androidx.compose.material.icons.filled.Movie
+import androidx.compose.material.icons.filled.MovieCreation
+import androidx.compose.material.icons.filled.MovieFilter
+import androidx.compose.material.icons.filled.MusicNote
+import androidx.compose.material.icons.filled.MusicOff
+import androidx.compose.material.icons.filled.MusicVideo
+import androidx.compose.material.icons.filled.NewReleases
+import androidx.compose.material.icons.filled.NotInterested
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PauseCircle
+import androidx.compose.material.icons.filled.PauseCircleFilled
+import androidx.compose.material.icons.filled.PauseCircleOutline
+import androidx.compose.material.icons.filled.PausePresentation
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.PlayCircle
+import androidx.compose.material.icons.filled.PlayCircleFilled
+import androidx.compose.material.icons.filled.PlayCircleOutline
+import androidx.compose.material.icons.filled.PlayDisabled
+import androidx.compose.material.icons.filled.PlayLesson
+import androidx.compose.material.icons.filled.PlaylistAdd
+import androidx.compose.material.icons.filled.PlaylistAddCheck
+import androidx.compose.material.icons.filled.PlaylistAddCheckCircle
+import androidx.compose.material.icons.filled.PlaylistAddCircle
+import androidx.compose.material.icons.filled.PlaylistPlay
+import androidx.compose.material.icons.filled.PlaylistRemove
+import androidx.compose.material.icons.filled.Queue
+import androidx.compose.material.icons.filled.QueuePlayNext
+import androidx.compose.material.icons.filled.Radio
+import androidx.compose.material.icons.filled.RecentActors
+import androidx.compose.material.icons.filled.RemoveFromQueue
+import androidx.compose.material.icons.filled.Repeat
+import androidx.compose.material.icons.filled.RepeatOn
+import androidx.compose.material.icons.filled.RepeatOne
+import androidx.compose.material.icons.filled.RepeatOneOn
+import androidx.compose.material.icons.filled.Replay
+import androidx.compose.material.icons.filled.Replay10
+import androidx.compose.material.icons.filled.Replay30
+import androidx.compose.material.icons.filled.Replay5
+import androidx.compose.material.icons.filled.ReplayCircleFilled
+import androidx.compose.material.icons.filled.Sd
+import androidx.compose.material.icons.filled.SdCard
+import androidx.compose.material.icons.filled.Shuffle
+import androidx.compose.material.icons.filled.ShuffleOn
+import androidx.compose.material.icons.filled.SkipNext
+import androidx.compose.material.icons.filled.SkipPrevious
+import androidx.compose.material.icons.filled.SlowMotionVideo
+import androidx.compose.material.icons.filled.Snooze
+import androidx.compose.material.icons.filled.SortByAlpha
+import androidx.compose.material.icons.filled.Speed
+import androidx.compose.material.icons.filled.Stop
+import androidx.compose.material.icons.filled.StopCircle
+import androidx.compose.material.icons.filled.StopScreenShare
+import androidx.compose.material.icons.filled.Subscriptions
+import androidx.compose.material.icons.filled.Subtitles
+import androidx.compose.material.icons.filled.SurroundSound
+import androidx.compose.material.icons.filled.VideoCall
+import androidx.compose.material.icons.filled.VideoCameraBack
+import androidx.compose.material.icons.filled.VideoCameraFront
+import androidx.compose.material.icons.filled.VideoFile
+import androidx.compose.material.icons.filled.VideoLabel
+import androidx.compose.material.icons.filled.VideoLibrary
+import androidx.compose.material.icons.filled.VideoSettings
+import androidx.compose.material.icons.filled.VideoStable
+import androidx.compose.material.icons.filled.Videocam
+import androidx.compose.material.icons.filled.VideocamOff
+import androidx.compose.material.icons.filled.VideogameAsset
+import androidx.compose.material.icons.filled.VideogameAssetOff
+import androidx.compose.material.icons.filled.Web
+import androidx.compose.material.icons.filled.WebAsset
+import androidx.compose.material.icons.filled.WebAssetOff
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * AV (Audio/Video) category icons - Media controls and playback
+ * Based on Google's Material Design Icons taxonomy
+ */
+object AVIcons {
+ val icons =
+ listOf(
+ // ProfileIcon("10k", Icons.Filled.TenK, "10K"), // Not available in compose-material-icons-extended
+ // ProfileIcon("10mp", Icons.Filled.TenMp, "10MP"),
+ // ProfileIcon("11mp", Icons.Filled.ElevenMp, "11MP"),
+ // ProfileIcon("12mp", Icons.Filled.TwelveMp, "12MP"),
+ // ProfileIcon("13mp", Icons.Filled.ThirteenMp, "13MP"),
+ // ProfileIcon("14mp", Icons.Filled.FourteenMp, "14MP"),
+ // ProfileIcon("15mp", Icons.Filled.FifteenMp, "15MP"),
+ // ProfileIcon("16mp", Icons.Filled.SixteenMp, "16MP"),
+ // ProfileIcon("17mp", Icons.Filled.SeventeenMp, "17MP"),
+ // ProfileIcon("18mp", Icons.Filled.EighteenMp, "18MP"),
+ // ProfileIcon("19mp", Icons.Filled.NineteenMp, "19MP"),
+ // ProfileIcon("1k", Icons.Filled.OneK, "1K"),
+ // ProfileIcon("1k_plus", Icons.Filled.OneKPlus, "1K+"),
+ // ProfileIcon("20mp", Icons.Filled.TwentyMp, "20MP"),
+ // ProfileIcon("21mp", Icons.Filled.TwentyOneMp, "21MP"),
+ // ProfileIcon("22mp", Icons.Filled.TwentyTwoMp, "22MP"),
+ // ProfileIcon("23mp", Icons.Filled.TwentyThreeMp, "23MP"),
+ // ProfileIcon("24mp", Icons.Filled.TwentyFourMp, "24MP"),
+ // ProfileIcon("2k", Icons.Filled.TwoK, "2K"),
+ // ProfileIcon("2k_plus", Icons.Filled.TwoKPlus, "2K+"),
+ // ProfileIcon("2mp", Icons.Filled.TwoMp, "2MP"),
+ // ProfileIcon("3k", Icons.Filled.ThreeK, "3K"),
+ // ProfileIcon("3k_plus", Icons.Filled.ThreeKPlus, "3K+"),
+ // ProfileIcon("3mp", Icons.Filled.ThreeMp, "3MP"),
+ // ProfileIcon("4k", Icons.Filled.FourK, "4K"), // Not available
+ // ProfileIcon("4k_plus", Icons.Filled.FourKPlus, "4K+"), // Not available
+ // ProfileIcon("4mp", Icons.Filled.FourMp, "4MP"),
+ // ProfileIcon("5g", Icons.Filled.FiveG, "5G"),
+ // ProfileIcon("5k", Icons.Filled.FiveK, "5K"),
+ // ProfileIcon("5k_plus", Icons.Filled.FiveKPlus, "5K+"),
+ // ProfileIcon("5mp", Icons.Filled.FiveMp, "5MP"),
+ // ProfileIcon("6k", Icons.Filled.SixK, "6K"),
+ // ProfileIcon("6k_plus", Icons.Filled.SixKPlus, "6K+"),
+ // ProfileIcon("6mp", Icons.Filled.SixMp, "6MP"),
+ // ProfileIcon("7k", Icons.Filled.SevenK, "7K"),
+ // ProfileIcon("7k_plus", Icons.Filled.SevenKPlus, "7K+"),
+ // ProfileIcon("7mp", Icons.Filled.SevenMp, "7MP"),
+ // ProfileIcon("8k", Icons.Filled.EightK, "8K"),
+ // ProfileIcon("8k_plus", Icons.Filled.EightKPlus, "8K+"),
+ // ProfileIcon("8mp", Icons.Filled.EightMp, "8MP"),
+ // ProfileIcon("9k", Icons.Filled.NineK, "9K"),
+ // ProfileIcon("9k_plus", Icons.Filled.NineKPlus, "9K+"),
+ // ProfileIcon("9mp", Icons.Filled.NineMp, "9MP"),
+ ProfileIcon("add_to_queue", Icons.Filled.AddToQueue, "Add to Queue"),
+ ProfileIcon("airplay", Icons.Filled.Airplay, "Airplay"),
+ ProfileIcon("album", Icons.Filled.Album, "Album"),
+ ProfileIcon("art_track", Icons.Filled.ArtTrack, "Art Track"),
+ ProfileIcon("audio_file", Icons.Filled.AudioFile, "Audio File"),
+ ProfileIcon("av_timer", Icons.Filled.AvTimer, "AV Timer"),
+ ProfileIcon("branding_watermark", Icons.Filled.BrandingWatermark, "Watermark"),
+ ProfileIcon("call_to_action", Icons.Filled.CallToAction, "Call to Action"),
+ ProfileIcon("closed_caption", Icons.Filled.ClosedCaption, "Closed Caption"),
+ ProfileIcon("closed_caption_disabled", Icons.Filled.ClosedCaptionDisabled, "CC Disabled"),
+ ProfileIcon("closed_caption_off", Icons.Filled.ClosedCaptionOff, "CC Off"),
+ ProfileIcon("control_camera", Icons.Filled.ControlCamera, "Control Camera"),
+ ProfileIcon("equalizer", Icons.Filled.Equalizer, "Equalizer"),
+ ProfileIcon("explicit", Icons.Filled.Explicit, "Explicit"),
+ ProfileIcon("fast_forward", Icons.Filled.FastForward, "Fast Forward"),
+ ProfileIcon("fast_rewind", Icons.Filled.FastRewind, "Fast Rewind"),
+ ProfileIcon(
+ "featured_play_list",
+ Icons.AutoMirrored.Filled.FeaturedPlayList,
+ "Featured Playlist",
+ ),
+ ProfileIcon("featured_video", Icons.AutoMirrored.Filled.FeaturedVideo, "Featured Video"),
+ ProfileIcon("fiber_dvr", Icons.Filled.FiberDvr, "DVR"),
+ ProfileIcon("fiber_manual_record", Icons.Filled.FiberManualRecord, "Record"),
+ ProfileIcon("fiber_new", Icons.Filled.FiberNew, "New"),
+ ProfileIcon("fiber_pin", Icons.Filled.FiberPin, "Pin"),
+ ProfileIcon("fiber_smart_record", Icons.Filled.FiberSmartRecord, "Smart Record"),
+ ProfileIcon("forward_10", Icons.Filled.Forward10, "Forward 10"),
+ ProfileIcon("forward_30", Icons.Filled.Forward30, "Forward 30"),
+ ProfileIcon("forward_5", Icons.Filled.Forward5, "Forward 5"),
+ ProfileIcon("games", Icons.Filled.Games, "Games"),
+ ProfileIcon("hd", Icons.Filled.Hd, "HD"),
+ ProfileIcon("hearing", Icons.Filled.Hearing, "Hearing"),
+ ProfileIcon("hearing_disabled", Icons.Filled.HearingDisabled, "Hearing Disabled"),
+ ProfileIcon("high_quality", Icons.Filled.HighQuality, "High Quality"),
+ ProfileIcon("interpreter_mode", Icons.Filled.InterpreterMode, "Interpreter Mode"),
+ ProfileIcon("library_add", Icons.Filled.LibraryAdd, "Library Add"),
+ ProfileIcon("library_add_check", Icons.Filled.LibraryAddCheck, "Library Check"),
+ ProfileIcon("library_books", Icons.Filled.LibraryBooks, "Library Books"),
+ ProfileIcon("library_music", Icons.Filled.LibraryMusic, "Library Music"),
+ ProfileIcon("loop", Icons.Filled.Loop, "Loop"),
+ ProfileIcon("lyrics", Icons.Filled.Lyrics, "Lyrics"),
+ ProfileIcon("mic", Icons.Filled.Mic, "Mic"),
+ ProfileIcon("mic_external_off", Icons.Filled.MicExternalOff, "Mic External Off"),
+ ProfileIcon("mic_external_on", Icons.Filled.MicExternalOn, "Mic External On"),
+ ProfileIcon("mic_none", Icons.Filled.MicNone, "Mic None"),
+ ProfileIcon("mic_off", Icons.Filled.MicOff, "Mic Off"),
+ ProfileIcon("missed_video_call", Icons.Filled.MissedVideoCall, "Missed Video Call"),
+ ProfileIcon("movie", Icons.Filled.Movie, "Movie"),
+ ProfileIcon("movie_creation", Icons.Filled.MovieCreation, "Movie Creation"),
+ ProfileIcon("movie_filter", Icons.Filled.MovieFilter, "Movie Filter"),
+ ProfileIcon("music_note", Icons.Filled.MusicNote, "Music Note"),
+ ProfileIcon("music_off", Icons.Filled.MusicOff, "Music Off"),
+ ProfileIcon("music_video", Icons.Filled.MusicVideo, "Music Video"),
+ ProfileIcon("new_releases", Icons.Filled.NewReleases, "New Releases"),
+ ProfileIcon("not_interested", Icons.Filled.NotInterested, "Not Interested"),
+ ProfileIcon("note", Icons.AutoMirrored.Filled.Note, "Note"),
+ ProfileIcon("pause", Icons.Filled.Pause, "Pause"),
+ ProfileIcon("pause_circle", Icons.Filled.PauseCircle, "Pause Circle"),
+ ProfileIcon("pause_circle_filled", Icons.Filled.PauseCircleFilled, "Pause Filled"),
+ ProfileIcon("pause_circle_outline", Icons.Filled.PauseCircleOutline, "Pause Outline"),
+ ProfileIcon("pause_presentation", Icons.Filled.PausePresentation, "Pause Presentation"),
+ ProfileIcon("play_arrow", Icons.Filled.PlayArrow, "Play"),
+ ProfileIcon("play_circle", Icons.Filled.PlayCircle, "Play Circle"),
+ ProfileIcon("play_circle_filled", Icons.Filled.PlayCircleFilled, "Play Filled"),
+ ProfileIcon("play_circle_outline", Icons.Filled.PlayCircleOutline, "Play Outline"),
+ ProfileIcon("play_disabled", Icons.Filled.PlayDisabled, "Play Disabled"),
+ ProfileIcon("play_lesson", Icons.Filled.PlayLesson, "Play Lesson"),
+ ProfileIcon("playlist_add", Icons.Filled.PlaylistAdd, "Playlist Add"),
+ ProfileIcon("playlist_add_check", Icons.Filled.PlaylistAddCheck, "Playlist Check"),
+ ProfileIcon(
+ "playlist_add_check_circle",
+ Icons.Filled.PlaylistAddCheckCircle,
+ "Playlist Circle",
+ ),
+ ProfileIcon("playlist_add_circle", Icons.Filled.PlaylistAddCircle, "Add Circle"),
+ ProfileIcon("playlist_play", Icons.Filled.PlaylistPlay, "Playlist Play"),
+ ProfileIcon("playlist_remove", Icons.Filled.PlaylistRemove, "Playlist Remove"),
+ ProfileIcon("queue", Icons.Filled.Queue, "Queue"),
+ ProfileIcon("queue_music", Icons.AutoMirrored.Filled.QueueMusic, "Queue Music"),
+ ProfileIcon("queue_play_next", Icons.Filled.QueuePlayNext, "Play Next"),
+ ProfileIcon("radio", Icons.Filled.Radio, "Radio"),
+ ProfileIcon("recent_actors", Icons.Filled.RecentActors, "Recent Actors"),
+ ProfileIcon("remove_from_queue", Icons.Filled.RemoveFromQueue, "Remove Queue"),
+ ProfileIcon("repeat", Icons.Filled.Repeat, "Repeat"),
+ ProfileIcon("repeat_on", Icons.Filled.RepeatOn, "Repeat On"),
+ ProfileIcon("repeat_one", Icons.Filled.RepeatOne, "Repeat One"),
+ ProfileIcon("repeat_one_on", Icons.Filled.RepeatOneOn, "Repeat One On"),
+ ProfileIcon("replay", Icons.Filled.Replay, "Replay"),
+ ProfileIcon("replay_10", Icons.Filled.Replay10, "Replay 10"),
+ ProfileIcon("replay_30", Icons.Filled.Replay30, "Replay 30"),
+ ProfileIcon("replay_5", Icons.Filled.Replay5, "Replay 5"),
+ ProfileIcon("replay_circle_filled", Icons.Filled.ReplayCircleFilled, "Replay Circle"),
+ ProfileIcon("sd", Icons.Filled.Sd, "SD"),
+ ProfileIcon("sd_card", Icons.Filled.SdCard, "SD Card"),
+ ProfileIcon("shuffle", Icons.Filled.Shuffle, "Shuffle"),
+ ProfileIcon("shuffle_on", Icons.Filled.ShuffleOn, "Shuffle On"),
+ ProfileIcon("skip_next", Icons.Filled.SkipNext, "Skip Next"),
+ ProfileIcon("skip_previous", Icons.Filled.SkipPrevious, "Skip Previous"),
+ ProfileIcon("slow_motion_video", Icons.Filled.SlowMotionVideo, "Slow Motion"),
+ ProfileIcon("snooze", Icons.Filled.Snooze, "Snooze"),
+ ProfileIcon("sort_by_alpha", Icons.Filled.SortByAlpha, "Sort Alpha"),
+ ProfileIcon("speed", Icons.Filled.Speed, "Speed"),
+ ProfileIcon("stop", Icons.Filled.Stop, "Stop"),
+ ProfileIcon("stop_circle", Icons.Filled.StopCircle, "Stop Circle"),
+ ProfileIcon("stop_screen_share", Icons.Filled.StopScreenShare, "Stop Share"),
+ ProfileIcon("subscriptions", Icons.Filled.Subscriptions, "Subscriptions"),
+ ProfileIcon("subtitles", Icons.Filled.Subtitles, "Subtitles"),
+ ProfileIcon("surround_sound", Icons.Filled.SurroundSound, "Surround Sound"),
+ ProfileIcon("video_call", Icons.Filled.VideoCall, "Video Call"),
+ ProfileIcon("video_camera_back", Icons.Filled.VideoCameraBack, "Camera Back"),
+ ProfileIcon("video_camera_front", Icons.Filled.VideoCameraFront, "Camera Front"),
+ // ProfileIcon("video_collection", Icons.Filled.VideoCollection, "Video Collection"),
+ ProfileIcon("video_file", Icons.Filled.VideoFile, "Video File"),
+ ProfileIcon("video_label", Icons.Filled.VideoLabel, "Video Label"),
+ ProfileIcon("video_library", Icons.Filled.VideoLibrary, "Video Library"),
+ ProfileIcon("video_settings", Icons.Filled.VideoSettings, "Video Settings"),
+ ProfileIcon("video_stable", Icons.Filled.VideoStable, "Video Stable"),
+ ProfileIcon("videocam", Icons.Filled.Videocam, "Videocam"),
+ ProfileIcon("videocam_off", Icons.Filled.VideocamOff, "Videocam Off"),
+ ProfileIcon("videogame_asset", Icons.Filled.VideogameAsset, "Videogame"),
+ ProfileIcon("videogame_asset_off", Icons.Filled.VideogameAssetOff, "Videogame Off"),
+ ProfileIcon("volume_down", Icons.AutoMirrored.Filled.VolumeDown, "Volume Down"),
+ ProfileIcon("volume_mute", Icons.AutoMirrored.Filled.VolumeMute, "Mute"),
+ ProfileIcon("volume_off", Icons.AutoMirrored.Filled.VolumeOff, "Volume Off"),
+ ProfileIcon("volume_up", Icons.AutoMirrored.Filled.VolumeUp, "Volume Up"),
+ ProfileIcon("web", Icons.Filled.Web, "Web"),
+ ProfileIcon("web_asset", Icons.Filled.WebAsset, "Web Asset"),
+ ProfileIcon("web_asset_off", Icons.Filled.WebAssetOff, "Web Asset Off"),
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ActionIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ActionIcons.kt
new file mode 100644
index 0000000..7512b0d
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ActionIcons.kt
@@ -0,0 +1,983 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Announcement
+import androidx.compose.material.icons.automirrored.filled.Article
+import androidx.compose.material.icons.automirrored.filled.AssignmentReturn
+import androidx.compose.material.icons.automirrored.filled.ExitToApp
+import androidx.compose.material.icons.automirrored.filled.FactCheck
+import androidx.compose.material.icons.automirrored.filled.Grading
+import androidx.compose.material.icons.automirrored.filled.Help
+import androidx.compose.material.icons.automirrored.filled.HelpCenter
+import androidx.compose.material.icons.automirrored.filled.HelpOutline
+import androidx.compose.material.icons.automirrored.filled.Input
+import androidx.compose.material.icons.automirrored.filled.Label
+import androidx.compose.material.icons.automirrored.filled.LabelImportant
+import androidx.compose.material.icons.automirrored.filled.LabelOff
+import androidx.compose.material.icons.automirrored.filled.Launch
+import androidx.compose.material.icons.automirrored.filled.List
+import androidx.compose.material.icons.automirrored.filled.ListAlt
+import androidx.compose.material.icons.automirrored.filled.Login
+import androidx.compose.material.icons.automirrored.filled.Logout
+import androidx.compose.material.icons.automirrored.filled.NextPlan
+import androidx.compose.material.icons.automirrored.filled.NoteAdd
+import androidx.compose.material.icons.automirrored.filled.ReceiptLong
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.automirrored.filled.Subject
+import androidx.compose.material.icons.automirrored.filled.Toc
+import androidx.compose.material.icons.automirrored.filled.TrendingDown
+import androidx.compose.material.icons.automirrored.filled.TrendingFlat
+import androidx.compose.material.icons.automirrored.filled.TrendingUp
+import androidx.compose.material.icons.automirrored.filled.ViewList
+import androidx.compose.material.icons.automirrored.filled.ViewQuilt
+import androidx.compose.material.icons.automirrored.filled.ViewSidebar
+import androidx.compose.material.icons.filled.Accessibility
+import androidx.compose.material.icons.filled.AccessibilityNew
+import androidx.compose.material.icons.filled.Accessible
+import androidx.compose.material.icons.filled.AccessibleForward
+import androidx.compose.material.icons.filled.AccountBalance
+import androidx.compose.material.icons.filled.AccountBalanceWallet
+import androidx.compose.material.icons.filled.AccountBox
+import androidx.compose.material.icons.filled.AccountCircle
+import androidx.compose.material.icons.filled.AddShoppingCart
+import androidx.compose.material.icons.filled.AddTask
+import androidx.compose.material.icons.filled.AddToDrive
+import androidx.compose.material.icons.filled.Addchart
+import androidx.compose.material.icons.filled.AdminPanelSettings
+import androidx.compose.material.icons.filled.AdsClick
+import androidx.compose.material.icons.filled.Alarm
+import androidx.compose.material.icons.filled.AlarmAdd
+import androidx.compose.material.icons.filled.AlarmOff
+import androidx.compose.material.icons.filled.AlarmOn
+import androidx.compose.material.icons.filled.AllInbox
+import androidx.compose.material.icons.filled.AllOut
+import androidx.compose.material.icons.filled.Analytics
+import androidx.compose.material.icons.filled.Anchor
+import androidx.compose.material.icons.filled.Android
+import androidx.compose.material.icons.filled.Api
+import androidx.compose.material.icons.filled.AppBlocking
+import androidx.compose.material.icons.filled.AppRegistration
+import androidx.compose.material.icons.filled.AppSettingsAlt
+import androidx.compose.material.icons.filled.AppShortcut
+import androidx.compose.material.icons.filled.Approval
+import androidx.compose.material.icons.filled.Apps
+import androidx.compose.material.icons.filled.AppsOutage
+import androidx.compose.material.icons.filled.ArrowCircleDown
+import androidx.compose.material.icons.filled.ArrowCircleLeft
+import androidx.compose.material.icons.filled.ArrowCircleRight
+import androidx.compose.material.icons.filled.ArrowCircleUp
+import androidx.compose.material.icons.filled.ArrowOutward
+import androidx.compose.material.icons.filled.AspectRatio
+import androidx.compose.material.icons.filled.Assessment
+import androidx.compose.material.icons.filled.Assignment
+import androidx.compose.material.icons.filled.AssignmentInd
+import androidx.compose.material.icons.filled.AssignmentLate
+import androidx.compose.material.icons.filled.AssignmentReturned
+import androidx.compose.material.icons.filled.AssignmentTurnedIn
+import androidx.compose.material.icons.filled.AssuredWorkload
+import androidx.compose.material.icons.filled.Attachment
+import androidx.compose.material.icons.filled.Autorenew
+import androidx.compose.material.icons.filled.Backup
+import androidx.compose.material.icons.filled.BackupTable
+import androidx.compose.material.icons.filled.Balance
+import androidx.compose.material.icons.filled.BatchPrediction
+import androidx.compose.material.icons.filled.Book
+import androidx.compose.material.icons.filled.BookOnline
+import androidx.compose.material.icons.filled.Bookmark
+import androidx.compose.material.icons.filled.BookmarkAdd
+import androidx.compose.material.icons.filled.BookmarkAdded
+import androidx.compose.material.icons.filled.BookmarkBorder
+import androidx.compose.material.icons.filled.BookmarkRemove
+import androidx.compose.material.icons.filled.Bookmarks
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material.icons.filled.Build
+import androidx.compose.material.icons.filled.BuildCircle
+import androidx.compose.material.icons.filled.Cached
+import androidx.compose.material.icons.filled.CalendarMonth
+import androidx.compose.material.icons.filled.CalendarToday
+import androidx.compose.material.icons.filled.CalendarViewDay
+import androidx.compose.material.icons.filled.CalendarViewMonth
+import androidx.compose.material.icons.filled.CalendarViewWeek
+import androidx.compose.material.icons.filled.CameraEnhance
+import androidx.compose.material.icons.filled.CancelScheduleSend
+import androidx.compose.material.icons.filled.CardGiftcard
+import androidx.compose.material.icons.filled.CardMembership
+import androidx.compose.material.icons.filled.CardTravel
+import androidx.compose.material.icons.filled.ChangeCircle
+import androidx.compose.material.icons.filled.ChangeHistory
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.CheckCircleOutline
+import androidx.compose.material.icons.filled.ChromeReaderMode
+import androidx.compose.material.icons.filled.CircleNotifications
+import androidx.compose.material.icons.filled.Class
+import androidx.compose.material.icons.filled.CloseFullscreen
+import androidx.compose.material.icons.filled.Code
+import androidx.compose.material.icons.filled.CodeOff
+import androidx.compose.material.icons.filled.CommentBank
+import androidx.compose.material.icons.filled.Commute
+import androidx.compose.material.icons.filled.CompareArrows
+import androidx.compose.material.icons.filled.Compress
+import androidx.compose.material.icons.filled.ContactPage
+import androidx.compose.material.icons.filled.ContactSupport
+import androidx.compose.material.icons.filled.Contactless
+import androidx.compose.material.icons.filled.Copyright
+import androidx.compose.material.icons.filled.CreditCard
+import androidx.compose.material.icons.filled.CreditCardOff
+import androidx.compose.material.icons.filled.CreditScore
+import androidx.compose.material.icons.filled.Css
+import androidx.compose.material.icons.filled.CurrencyExchange
+import androidx.compose.material.icons.filled.Dangerous
+import androidx.compose.material.icons.filled.Dashboard
+import androidx.compose.material.icons.filled.DashboardCustomize
+import androidx.compose.material.icons.filled.DataExploration
+import androidx.compose.material.icons.filled.DataThresholding
+import androidx.compose.material.icons.filled.DateRange
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.DeleteForever
+import androidx.compose.material.icons.filled.DeleteOutline
+import androidx.compose.material.icons.filled.DeleteSweep
+import androidx.compose.material.icons.filled.DensityLarge
+import androidx.compose.material.icons.filled.DensityMedium
+import androidx.compose.material.icons.filled.DensitySmall
+import androidx.compose.material.icons.filled.Description
+import androidx.compose.material.icons.filled.DisabledByDefault
+import androidx.compose.material.icons.filled.DisabledVisible
+import androidx.compose.material.icons.filled.DisplaySettings
+import androidx.compose.material.icons.filled.Dns
+import androidx.compose.material.icons.filled.Done
+import androidx.compose.material.icons.filled.DoneAll
+import androidx.compose.material.icons.filled.DoneOutline
+import androidx.compose.material.icons.filled.DonutLarge
+import androidx.compose.material.icons.filled.DonutSmall
+import androidx.compose.material.icons.filled.DragIndicator
+import androidx.compose.material.icons.filled.DynamicForm
+import androidx.compose.material.icons.filled.Eco
+import androidx.compose.material.icons.filled.EditCalendar
+import androidx.compose.material.icons.filled.EditNote
+import androidx.compose.material.icons.filled.EditOff
+import androidx.compose.material.icons.filled.Eject
+import androidx.compose.material.icons.filled.Euro
+import androidx.compose.material.icons.filled.Event
+import androidx.compose.material.icons.filled.EventRepeat
+import androidx.compose.material.icons.filled.EventSeat
+import androidx.compose.material.icons.filled.Expand
+import androidx.compose.material.icons.filled.Explore
+import androidx.compose.material.icons.filled.ExploreOff
+import androidx.compose.material.icons.filled.Extension
+import androidx.compose.material.icons.filled.ExtensionOff
+import androidx.compose.material.icons.filled.Face
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.FavoriteBorder
+import androidx.compose.material.icons.filled.Fax
+import androidx.compose.material.icons.filled.Feedback
+import androidx.compose.material.icons.filled.FileDownload
+import androidx.compose.material.icons.filled.FileDownloadDone
+import androidx.compose.material.icons.filled.FileDownloadOff
+import androidx.compose.material.icons.filled.FileOpen
+import androidx.compose.material.icons.filled.FilePresent
+import androidx.compose.material.icons.filled.FileUpload
+import androidx.compose.material.icons.filled.FilterAlt
+import androidx.compose.material.icons.filled.FilterAltOff
+import androidx.compose.material.icons.filled.FilterList
+import androidx.compose.material.icons.filled.FilterListOff
+import androidx.compose.material.icons.filled.FindInPage
+import androidx.compose.material.icons.filled.FindReplace
+import androidx.compose.material.icons.filled.Fingerprint
+import androidx.compose.material.icons.filled.FitScreen
+import androidx.compose.material.icons.filled.Flaky
+import androidx.compose.material.icons.filled.FlightLand
+import androidx.compose.material.icons.filled.FlightTakeoff
+import androidx.compose.material.icons.filled.FlipToBack
+import androidx.compose.material.icons.filled.FlipToFront
+import androidx.compose.material.icons.filled.FlutterDash
+import androidx.compose.material.icons.filled.FreeCancellation
+import androidx.compose.material.icons.filled.GTranslate
+import androidx.compose.material.icons.filled.Gavel
+import androidx.compose.material.icons.filled.GeneratingTokens
+import androidx.compose.material.icons.filled.GetApp
+import androidx.compose.material.icons.filled.Gif
+import androidx.compose.material.icons.filled.GifBox
+import androidx.compose.material.icons.filled.Grade
+import androidx.compose.material.icons.filled.GroupWork
+import androidx.compose.material.icons.filled.HideSource
+import androidx.compose.material.icons.filled.HighlightAlt
+import androidx.compose.material.icons.filled.HighlightOff
+import androidx.compose.material.icons.filled.History
+import androidx.compose.material.icons.filled.HistoryToggleOff
+import androidx.compose.material.icons.filled.Hls
+import androidx.compose.material.icons.filled.HlsOff
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.HorizontalSplit
+import androidx.compose.material.icons.filled.HourglassDisabled
+import androidx.compose.material.icons.filled.HourglassEmpty
+import androidx.compose.material.icons.filled.HourglassFull
+import androidx.compose.material.icons.filled.Html
+import androidx.compose.material.icons.filled.Http
+import androidx.compose.material.icons.filled.Https
+import androidx.compose.material.icons.filled.ImportantDevices
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.InstallDesktop
+import androidx.compose.material.icons.filled.InstallMobile
+import androidx.compose.material.icons.filled.IntegrationInstructions
+import androidx.compose.material.icons.filled.InvertColors
+import androidx.compose.material.icons.filled.Javascript
+import androidx.compose.material.icons.filled.JoinFull
+import androidx.compose.material.icons.filled.JoinInner
+import androidx.compose.material.icons.filled.JoinLeft
+import androidx.compose.material.icons.filled.JoinRight
+import androidx.compose.material.icons.filled.Language
+import androidx.compose.material.icons.filled.Leaderboard
+import androidx.compose.material.icons.filled.Lightbulb
+import androidx.compose.material.icons.filled.LightbulbCircle
+import androidx.compose.material.icons.filled.LineStyle
+import androidx.compose.material.icons.filled.LineWeight
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.LockClock
+import androidx.compose.material.icons.filled.LockOpen
+import androidx.compose.material.icons.filled.LockPerson
+import androidx.compose.material.icons.filled.LockReset
+import androidx.compose.material.icons.filled.Loyalty
+import androidx.compose.material.icons.filled.ManageAccounts
+import androidx.compose.material.icons.filled.ManageHistory
+import androidx.compose.material.icons.filled.ManageSearch
+import androidx.compose.material.icons.filled.MarkAsUnread
+import androidx.compose.material.icons.filled.MarkunreadMailbox
+import androidx.compose.material.icons.filled.Maximize
+import androidx.compose.material.icons.filled.Mediation
+import androidx.compose.material.icons.filled.Minimize
+import androidx.compose.material.icons.filled.ModelTraining
+import androidx.compose.material.icons.filled.Nightlight
+import androidx.compose.material.icons.filled.NightlightRound
+import androidx.compose.material.icons.filled.NoAccounts
+import androidx.compose.material.icons.filled.NotStarted
+import androidx.compose.material.icons.filled.OfflineBolt
+import androidx.compose.material.icons.filled.OfflinePin
+import androidx.compose.material.icons.filled.OnlinePrediction
+import androidx.compose.material.icons.filled.Opacity
+import androidx.compose.material.icons.filled.OpenInBrowser
+import androidx.compose.material.icons.filled.OpenInFull
+import androidx.compose.material.icons.filled.OpenInNew
+import androidx.compose.material.icons.filled.OpenInNewOff
+import androidx.compose.material.icons.filled.OpenWith
+import androidx.compose.material.icons.filled.Outbond
+import androidx.compose.material.icons.filled.Outlet
+import androidx.compose.material.icons.filled.Output
+import androidx.compose.material.icons.filled.Pageview
+import androidx.compose.material.icons.filled.Paid
+import androidx.compose.material.icons.filled.PanTool
+import androidx.compose.material.icons.filled.PanToolAlt
+import androidx.compose.material.icons.filled.Payment
+import androidx.compose.material.icons.filled.Pending
+import androidx.compose.material.icons.filled.PendingActions
+import androidx.compose.material.icons.filled.Percent
+import androidx.compose.material.icons.filled.PermCameraMic
+import androidx.compose.material.icons.filled.PermContactCalendar
+import androidx.compose.material.icons.filled.PermDataSetting
+import androidx.compose.material.icons.filled.PermDeviceInformation
+import androidx.compose.material.icons.filled.PermIdentity
+import androidx.compose.material.icons.filled.PermMedia
+import androidx.compose.material.icons.filled.PermPhoneMsg
+import androidx.compose.material.icons.filled.PermScanWifi
+import androidx.compose.material.icons.filled.Pets
+import androidx.compose.material.icons.filled.Php
+import androidx.compose.material.icons.filled.PictureInPicture
+import androidx.compose.material.icons.filled.PictureInPictureAlt
+import androidx.compose.material.icons.filled.PinEnd
+import androidx.compose.material.icons.filled.PinInvoke
+import androidx.compose.material.icons.filled.Plagiarism
+import androidx.compose.material.icons.filled.PlayForWork
+import androidx.compose.material.icons.filled.Polymer
+import androidx.compose.material.icons.filled.PowerSettingsNew
+import androidx.compose.material.icons.filled.PregnantWoman
+import androidx.compose.material.icons.filled.Preview
+import androidx.compose.material.icons.filled.Print
+import androidx.compose.material.icons.filled.PrintDisabled
+import androidx.compose.material.icons.filled.PrivacyTip
+import androidx.compose.material.icons.filled.ProductionQuantityLimits
+import androidx.compose.material.icons.filled.PublishedWithChanges
+import androidx.compose.material.icons.filled.QueryBuilder
+import androidx.compose.material.icons.filled.QuestionAnswer
+import androidx.compose.material.icons.filled.QuestionMark
+import androidx.compose.material.icons.filled.Quickreply
+import androidx.compose.material.icons.filled.Receipt
+import androidx.compose.material.icons.filled.RecordVoiceOver
+import androidx.compose.material.icons.filled.Redeem
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.RemoveDone
+import androidx.compose.material.icons.filled.RemoveShoppingCart
+import androidx.compose.material.icons.filled.Reorder
+import androidx.compose.material.icons.filled.Repartition
+import androidx.compose.material.icons.filled.ReportProblem
+import androidx.compose.material.icons.filled.RequestPage
+import androidx.compose.material.icons.filled.RequestQuote
+import androidx.compose.material.icons.filled.Restore
+import androidx.compose.material.icons.filled.RestoreFromTrash
+import androidx.compose.material.icons.filled.RestorePage
+import androidx.compose.material.icons.filled.Rocket
+import androidx.compose.material.icons.filled.RocketLaunch
+import androidx.compose.material.icons.filled.Room
+import androidx.compose.material.icons.filled.RoundedCorner
+import androidx.compose.material.icons.filled.Rowing
+import androidx.compose.material.icons.filled.Rule
+import androidx.compose.material.icons.filled.SatelliteAlt
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material.icons.filled.SaveAlt
+import androidx.compose.material.icons.filled.SaveAs
+import androidx.compose.material.icons.filled.SavedSearch
+import androidx.compose.material.icons.filled.Savings
+import androidx.compose.material.icons.filled.Schedule
+import androidx.compose.material.icons.filled.ScheduleSend
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.SearchOff
+import androidx.compose.material.icons.filled.Segment
+import androidx.compose.material.icons.filled.SendAndArchive
+import androidx.compose.material.icons.filled.Sensors
+import androidx.compose.material.icons.filled.SensorsOff
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.SettingsAccessibility
+import androidx.compose.material.icons.filled.SettingsApplications
+import androidx.compose.material.icons.filled.SettingsBackupRestore
+import androidx.compose.material.icons.filled.SettingsBluetooth
+import androidx.compose.material.icons.filled.SettingsBrightness
+import androidx.compose.material.icons.filled.SettingsCell
+import androidx.compose.material.icons.filled.SettingsEthernet
+import androidx.compose.material.icons.filled.SettingsInputAntenna
+import androidx.compose.material.icons.filled.SettingsInputComponent
+import androidx.compose.material.icons.filled.SettingsInputComposite
+import androidx.compose.material.icons.filled.SettingsInputHdmi
+import androidx.compose.material.icons.filled.SettingsInputSvideo
+import androidx.compose.material.icons.filled.SettingsOverscan
+import androidx.compose.material.icons.filled.SettingsPhone
+import androidx.compose.material.icons.filled.SettingsPower
+import androidx.compose.material.icons.filled.SettingsRemote
+import androidx.compose.material.icons.filled.SettingsVoice
+import androidx.compose.material.icons.filled.Shop
+import androidx.compose.material.icons.filled.Shop2
+import androidx.compose.material.icons.filled.ShopTwo
+import androidx.compose.material.icons.filled.ShoppingBag
+import androidx.compose.material.icons.filled.ShoppingBasket
+import androidx.compose.material.icons.filled.ShoppingCart
+import androidx.compose.material.icons.filled.ShoppingCartCheckout
+import androidx.compose.material.icons.filled.SmartButton
+import androidx.compose.material.icons.filled.Source
+import androidx.compose.material.icons.filled.SpaceDashboard
+import androidx.compose.material.icons.filled.SpatialAudio
+import androidx.compose.material.icons.filled.SpatialAudioOff
+import androidx.compose.material.icons.filled.SpatialTracking
+import androidx.compose.material.icons.filled.SpeakerNotes
+import androidx.compose.material.icons.filled.SpeakerNotesOff
+import androidx.compose.material.icons.filled.Spellcheck
+import androidx.compose.material.icons.filled.StarRate
+import androidx.compose.material.icons.filled.Stars
+import androidx.compose.material.icons.filled.StickyNote2
+import androidx.compose.material.icons.filled.Store
+import androidx.compose.material.icons.filled.SubtitlesOff
+import androidx.compose.material.icons.filled.SupervisedUserCircle
+import androidx.compose.material.icons.filled.SupervisorAccount
+import androidx.compose.material.icons.filled.Support
+import androidx.compose.material.icons.filled.SwapHoriz
+import androidx.compose.material.icons.filled.SwapHorizontalCircle
+import androidx.compose.material.icons.filled.SwapVert
+import androidx.compose.material.icons.filled.SwapVerticalCircle
+import androidx.compose.material.icons.filled.Swipe
+import androidx.compose.material.icons.filled.SwipeDown
+import androidx.compose.material.icons.filled.SwipeDownAlt
+import androidx.compose.material.icons.filled.SwipeLeft
+import androidx.compose.material.icons.filled.SwipeLeftAlt
+import androidx.compose.material.icons.filled.SwipeRight
+import androidx.compose.material.icons.filled.SwipeRightAlt
+import androidx.compose.material.icons.filled.SwipeUp
+import androidx.compose.material.icons.filled.SwipeUpAlt
+import androidx.compose.material.icons.filled.SwipeVertical
+import androidx.compose.material.icons.filled.SwitchAccessShortcut
+import androidx.compose.material.icons.filled.SwitchAccessShortcutAdd
+import androidx.compose.material.icons.filled.SyncAlt
+import androidx.compose.material.icons.filled.SystemUpdateAlt
+import androidx.compose.material.icons.filled.Tab
+import androidx.compose.material.icons.filled.TabUnselected
+import androidx.compose.material.icons.filled.TableView
+import androidx.compose.material.icons.filled.TagFaces
+import androidx.compose.material.icons.filled.TaskAlt
+import androidx.compose.material.icons.filled.Terminal
+import androidx.compose.material.icons.filled.TextRotateUp
+import androidx.compose.material.icons.filled.TextRotateVertical
+import androidx.compose.material.icons.filled.TextRotationAngledown
+import androidx.compose.material.icons.filled.TextRotationAngleup
+import androidx.compose.material.icons.filled.TextRotationDown
+import androidx.compose.material.icons.filled.TextRotationNone
+import androidx.compose.material.icons.filled.Theaters
+import androidx.compose.material.icons.filled.ThumbDown
+import androidx.compose.material.icons.filled.ThumbDownOffAlt
+import androidx.compose.material.icons.filled.ThumbUp
+import androidx.compose.material.icons.filled.ThumbUpOffAlt
+import androidx.compose.material.icons.filled.ThumbsUpDown
+import androidx.compose.material.icons.filled.Timeline
+import androidx.compose.material.icons.filled.TipsAndUpdates
+import androidx.compose.material.icons.filled.Today
+import androidx.compose.material.icons.filled.Token
+import androidx.compose.material.icons.filled.Toll
+import androidx.compose.material.icons.filled.TouchApp
+import androidx.compose.material.icons.filled.Tour
+import androidx.compose.material.icons.filled.TrackChanges
+import androidx.compose.material.icons.filled.Transcribe
+import androidx.compose.material.icons.filled.Translate
+import androidx.compose.material.icons.filled.Troubleshoot
+import androidx.compose.material.icons.filled.TurnedIn
+import androidx.compose.material.icons.filled.TurnedInNot
+import androidx.compose.material.icons.filled.UnfoldLessDouble
+import androidx.compose.material.icons.filled.UnfoldMoreDouble
+import androidx.compose.material.icons.filled.Unpublished
+import androidx.compose.material.icons.filled.Update
+import androidx.compose.material.icons.filled.UpdateDisabled
+import androidx.compose.material.icons.filled.Upgrade
+import androidx.compose.material.icons.filled.Verified
+import androidx.compose.material.icons.filled.VerifiedUser
+import androidx.compose.material.icons.filled.VerticalSplit
+import androidx.compose.material.icons.filled.ViewAgenda
+import androidx.compose.material.icons.filled.ViewArray
+import androidx.compose.material.icons.filled.ViewCarousel
+import androidx.compose.material.icons.filled.ViewColumn
+import androidx.compose.material.icons.filled.ViewComfy
+import androidx.compose.material.icons.filled.ViewComfyAlt
+import androidx.compose.material.icons.filled.ViewCompact
+import androidx.compose.material.icons.filled.ViewCompactAlt
+import androidx.compose.material.icons.filled.ViewCozy
+import androidx.compose.material.icons.filled.ViewDay
+import androidx.compose.material.icons.filled.ViewHeadline
+import androidx.compose.material.icons.filled.ViewInAr
+import androidx.compose.material.icons.filled.ViewKanban
+import androidx.compose.material.icons.filled.ViewModule
+import androidx.compose.material.icons.filled.ViewStream
+import androidx.compose.material.icons.filled.ViewTimeline
+import androidx.compose.material.icons.filled.ViewWeek
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material.icons.filled.VoiceOverOff
+import androidx.compose.material.icons.filled.WatchLater
+import androidx.compose.material.icons.filled.Webhook
+import androidx.compose.material.icons.filled.WidthFull
+import androidx.compose.material.icons.filled.WidthNormal
+import androidx.compose.material.icons.filled.WidthWide
+import androidx.compose.material.icons.filled.WifiProtectedSetup
+import androidx.compose.material.icons.filled.Work
+import androidx.compose.material.icons.filled.WorkHistory
+import androidx.compose.material.icons.filled.WorkOff
+import androidx.compose.material.icons.filled.WorkOutline
+import androidx.compose.material.icons.filled.Wysiwyg
+import androidx.compose.material.icons.filled.ZoomIn
+import androidx.compose.material.icons.filled.ZoomOut
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * Action category icons - User actions and common UI operations
+ * Based on Google's Material Design Icons taxonomy
+ */
+object ActionIcons {
+ val icons =
+ listOf(
+ // ProfileIcon("3d_rotation", Icons.Filled.ThreeDRotation, "3D Rotation"),
+ ProfileIcon("accessibility", Icons.Filled.Accessibility, "Accessibility"),
+ ProfileIcon("accessibility_new", Icons.Filled.AccessibilityNew, "Accessibility New"),
+ ProfileIcon("accessible", Icons.Filled.Accessible, "Accessible"),
+ ProfileIcon("accessible_forward", Icons.Filled.AccessibleForward, "Accessible Forward"),
+ ProfileIcon("account_balance", Icons.Filled.AccountBalance, "Account Balance"),
+ ProfileIcon("account_balance_wallet", Icons.Filled.AccountBalanceWallet, "Wallet"),
+ ProfileIcon("account_box", Icons.Filled.AccountBox, "Account Box"),
+ ProfileIcon("account_circle", Icons.Filled.AccountCircle, "Account"),
+ ProfileIcon("add_shopping_cart", Icons.Filled.AddShoppingCart, "Add Cart"),
+ ProfileIcon("add_task", Icons.Filled.AddTask, "Add Task"),
+ ProfileIcon("add_to_drive", Icons.Filled.AddToDrive, "Add to Drive"),
+ ProfileIcon("addchart", Icons.Filled.Addchart, "Add Chart"),
+ ProfileIcon("admin_panel_settings", Icons.Filled.AdminPanelSettings, "Admin Panel"),
+ ProfileIcon("ads_click", Icons.Filled.AdsClick, "Ads Click"),
+ ProfileIcon("alarm", Icons.Filled.Alarm, "Alarm"),
+ ProfileIcon("alarm_add", Icons.Filled.AlarmAdd, "Add Alarm"),
+ ProfileIcon("alarm_off", Icons.Filled.AlarmOff, "Alarm Off"),
+ ProfileIcon("alarm_on", Icons.Filled.AlarmOn, "Alarm On"),
+ ProfileIcon("all_inbox", Icons.Filled.AllInbox, "All Inbox"),
+ ProfileIcon("all_out", Icons.Filled.AllOut, "All Out"),
+ ProfileIcon("analytics", Icons.Filled.Analytics, "Analytics"),
+ ProfileIcon("anchor", Icons.Filled.Anchor, "Anchor"),
+ ProfileIcon("android", Icons.Filled.Android, "Android"),
+ ProfileIcon("announcement", Icons.AutoMirrored.Filled.Announcement, "Announcement"),
+ ProfileIcon("api", Icons.Filled.Api, "API"),
+ ProfileIcon("app_blocking", Icons.Filled.AppBlocking, "App Blocking"),
+ ProfileIcon("app_registration", Icons.Filled.AppRegistration, "App Registration"),
+ ProfileIcon("app_settings_alt", Icons.Filled.AppSettingsAlt, "App Settings"),
+ ProfileIcon("app_shortcut", Icons.Filled.AppShortcut, "App Shortcut"),
+ ProfileIcon("approval", Icons.Filled.Approval, "Approval"),
+ ProfileIcon("apps", Icons.Filled.Apps, "Apps"),
+ ProfileIcon("apps_outage", Icons.Filled.AppsOutage, "Apps Outage"),
+ ProfileIcon("arrow_circle_down", Icons.Filled.ArrowCircleDown, "Arrow Down"),
+ ProfileIcon("arrow_circle_left", Icons.Filled.ArrowCircleLeft, "Arrow Left"),
+ ProfileIcon("arrow_circle_right", Icons.Filled.ArrowCircleRight, "Arrow Right"),
+ ProfileIcon("arrow_circle_up", Icons.Filled.ArrowCircleUp, "Arrow Up"),
+ ProfileIcon("arrow_outward", Icons.Filled.ArrowOutward, "Arrow Outward"),
+ ProfileIcon("article", Icons.AutoMirrored.Filled.Article, "Article"),
+ ProfileIcon("aspect_ratio", Icons.Filled.AspectRatio, "Aspect Ratio"),
+ ProfileIcon("assessment", Icons.Filled.Assessment, "Assessment"),
+ ProfileIcon("assignment", Icons.Filled.Assignment, "Assignment"),
+ ProfileIcon("assignment_ind", Icons.Filled.AssignmentInd, "Assignment Ind"),
+ ProfileIcon("assignment_late", Icons.Filled.AssignmentLate, "Assignment Late"),
+ ProfileIcon(
+ "assignment_return",
+ Icons.AutoMirrored.Filled.AssignmentReturn,
+ "Assignment Return",
+ ),
+ ProfileIcon("assignment_returned", Icons.Filled.AssignmentReturned, "Assignment Returned"),
+ ProfileIcon("assignment_turned_in", Icons.Filled.AssignmentTurnedIn, "Done"),
+ ProfileIcon("assured_workload", Icons.Filled.AssuredWorkload, "Assured Workload"),
+ ProfileIcon("attachment", Icons.Filled.Attachment, "Attachment"),
+ ProfileIcon("autorenew", Icons.Filled.Autorenew, "Auto Renew"),
+ ProfileIcon("backup", Icons.Filled.Backup, "Backup"),
+ ProfileIcon("backup_table", Icons.Filled.BackupTable, "Backup Table"),
+ ProfileIcon("balance", Icons.Filled.Balance, "Balance"),
+ ProfileIcon("batch_prediction", Icons.Filled.BatchPrediction, "Batch Prediction"),
+ ProfileIcon("book", Icons.Filled.Book, "Book"),
+ ProfileIcon("book_online", Icons.Filled.BookOnline, "Book Online"),
+ ProfileIcon("bookmark", Icons.Filled.Bookmark, "Bookmark"),
+ ProfileIcon("bookmark_add", Icons.Filled.BookmarkAdd, "Bookmark Add"),
+ ProfileIcon("bookmark_added", Icons.Filled.BookmarkAdded, "Bookmark Added"),
+ ProfileIcon("bookmark_border", Icons.Filled.BookmarkBorder, "Bookmark Border"),
+ ProfileIcon("bookmark_remove", Icons.Filled.BookmarkRemove, "Bookmark Remove"),
+ ProfileIcon("bookmarks", Icons.Filled.Bookmarks, "Bookmarks"),
+ ProfileIcon("bug_report", Icons.Filled.BugReport, "Bug Report"),
+ ProfileIcon("build", Icons.Filled.Build, "Build"),
+ ProfileIcon("build_circle", Icons.Filled.BuildCircle, "Build Circle"),
+ ProfileIcon("cached", Icons.Filled.Cached, "Cached"),
+ ProfileIcon("calendar_month", Icons.Filled.CalendarMonth, "Calendar Month"),
+ ProfileIcon("calendar_today", Icons.Filled.CalendarToday, "Calendar Today"),
+ ProfileIcon("calendar_view_day", Icons.Filled.CalendarViewDay, "Calendar Day"),
+ ProfileIcon("calendar_view_month", Icons.Filled.CalendarViewMonth, "Calendar Month View"),
+ ProfileIcon("calendar_view_week", Icons.Filled.CalendarViewWeek, "Calendar Week"),
+ ProfileIcon("camera_enhance", Icons.Filled.CameraEnhance, "Camera Enhance"),
+ ProfileIcon("cancel_schedule_send", Icons.Filled.CancelScheduleSend, "Cancel Schedule"),
+ ProfileIcon("card_giftcard", Icons.Filled.CardGiftcard, "Gift Card"),
+ ProfileIcon("card_membership", Icons.Filled.CardMembership, "Membership"),
+ ProfileIcon("card_travel", Icons.Filled.CardTravel, "Travel Card"),
+ ProfileIcon("change_circle", Icons.Filled.ChangeCircle, "Change Circle"),
+ ProfileIcon("change_history", Icons.Filled.ChangeHistory, "Change History"),
+ ProfileIcon("check_circle", Icons.Filled.CheckCircle, "Check Circle"),
+ ProfileIcon(
+ "check_circle_outline",
+ Icons.Filled.CheckCircleOutline,
+ "Check Circle Outline",
+ ),
+ ProfileIcon("chrome_reader_mode", Icons.Filled.ChromeReaderMode, "Reader Mode"),
+ ProfileIcon(
+ "circle_notifications",
+ Icons.Filled.CircleNotifications,
+ "Circle Notifications",
+ ),
+ ProfileIcon("class", Icons.Filled.Class, "Class"),
+ ProfileIcon("close_fullscreen", Icons.Filled.CloseFullscreen, "Close Fullscreen"),
+ ProfileIcon("code", Icons.Filled.Code, "Code"),
+ ProfileIcon("code_off", Icons.Filled.CodeOff, "Code Off"),
+ ProfileIcon("comment_bank", Icons.Filled.CommentBank, "Comment Bank"),
+ ProfileIcon("commute", Icons.Filled.Commute, "Commute"),
+ ProfileIcon("compare_arrows", Icons.Filled.CompareArrows, "Compare"),
+ ProfileIcon("compress", Icons.Filled.Compress, "Compress"),
+ ProfileIcon("contact_page", Icons.Filled.ContactPage, "Contact Page"),
+ ProfileIcon("contact_support", Icons.Filled.ContactSupport, "Contact Support"),
+ ProfileIcon("contactless", Icons.Filled.Contactless, "Contactless"),
+ ProfileIcon("copyright", Icons.Filled.Copyright, "Copyright"),
+ ProfileIcon("credit_card", Icons.Filled.CreditCard, "Credit Card"),
+ ProfileIcon("credit_card_off", Icons.Filled.CreditCardOff, "Credit Card Off"),
+ ProfileIcon("credit_score", Icons.Filled.CreditScore, "Credit Score"),
+ ProfileIcon("css", Icons.Filled.Css, "CSS"),
+ ProfileIcon("currency_exchange", Icons.Filled.CurrencyExchange, "Currency Exchange"),
+ ProfileIcon("dangerous", Icons.Filled.Dangerous, "Dangerous"),
+ ProfileIcon("dashboard", Icons.Filled.Dashboard, "Dashboard"),
+ ProfileIcon("dashboard_customize", Icons.Filled.DashboardCustomize, "Dashboard Customize"),
+ ProfileIcon("data_exploration", Icons.Filled.DataExploration, "Data Exploration"),
+ ProfileIcon("data_thresholding", Icons.Filled.DataThresholding, "Data Thresholding"),
+ ProfileIcon("date_range", Icons.Filled.DateRange, "Date Range"),
+ ProfileIcon("delete", Icons.Filled.Delete, "Delete"),
+ ProfileIcon("delete_forever", Icons.Filled.DeleteForever, "Delete Forever"),
+ ProfileIcon("delete_outline", Icons.Filled.DeleteOutline, "Delete Outline"),
+ ProfileIcon("delete_sweep", Icons.Filled.DeleteSweep, "Delete Sweep"),
+ ProfileIcon("density_large", Icons.Filled.DensityLarge, "Density Large"),
+ ProfileIcon("density_medium", Icons.Filled.DensityMedium, "Density Medium"),
+ ProfileIcon("density_small", Icons.Filled.DensitySmall, "Density Small"),
+ ProfileIcon("description", Icons.Filled.Description, "Description"),
+ ProfileIcon("disabled_by_default", Icons.Filled.DisabledByDefault, "Disabled"),
+ ProfileIcon("disabled_visible", Icons.Filled.DisabledVisible, "Disabled Visible"),
+ ProfileIcon("display_settings", Icons.Filled.DisplaySettings, "Display Settings"),
+ ProfileIcon("dns", Icons.Filled.Dns, "DNS"),
+ ProfileIcon("done", Icons.Filled.Done, "Done"),
+ ProfileIcon("done_all", Icons.Filled.DoneAll, "Done All"),
+ ProfileIcon("done_outline", Icons.Filled.DoneOutline, "Done Outline"),
+ ProfileIcon("donut_large", Icons.Filled.DonutLarge, "Donut Large"),
+ ProfileIcon("donut_small", Icons.Filled.DonutSmall, "Donut Small"),
+ ProfileIcon("drag_indicator", Icons.Filled.DragIndicator, "Drag"),
+ ProfileIcon("dynamic_form", Icons.Filled.DynamicForm, "Dynamic Form"),
+ ProfileIcon("eco", Icons.Filled.Eco, "Eco"),
+ ProfileIcon("edit_calendar", Icons.Filled.EditCalendar, "Edit Calendar"),
+ ProfileIcon("edit_note", Icons.Filled.EditNote, "Edit Note"),
+ ProfileIcon("edit_off", Icons.Filled.EditOff, "Edit Off"),
+ ProfileIcon("eject", Icons.Filled.Eject, "Eject"),
+ ProfileIcon("euro_symbol", Icons.Filled.Euro, "Euro"),
+ ProfileIcon("event", Icons.Filled.Event, "Event"),
+ ProfileIcon("event_repeat", Icons.Filled.EventRepeat, "Event Repeat"),
+ ProfileIcon("event_seat", Icons.Filled.EventSeat, "Event Seat"),
+ ProfileIcon("exit_to_app", Icons.AutoMirrored.Filled.ExitToApp, "Exit"),
+ ProfileIcon("expand", Icons.Filled.Expand, "Expand"),
+ ProfileIcon("explore", Icons.Filled.Explore, "Explore"),
+ ProfileIcon("explore_off", Icons.Filled.ExploreOff, "Explore Off"),
+ ProfileIcon("extension", Icons.Filled.Extension, "Extension"),
+ ProfileIcon("extension_off", Icons.Filled.ExtensionOff, "Extension Off"),
+ ProfileIcon("face", Icons.Filled.Face, "Face"),
+ // ProfileIcon("face_unlock", Icons.Filled.FaceUnlock, "Face Unlock"),
+ ProfileIcon("fact_check", Icons.AutoMirrored.Filled.FactCheck, "Fact Check"),
+ ProfileIcon("favorite", Icons.Filled.Favorite, "Favorite"),
+ ProfileIcon("favorite_border", Icons.Filled.FavoriteBorder, "Favorite Border"),
+ ProfileIcon("fax", Icons.Filled.Fax, "Fax"),
+ ProfileIcon("feedback", Icons.Filled.Feedback, "Feedback"),
+ ProfileIcon("file_download", Icons.Filled.FileDownload, "Download"),
+ ProfileIcon("file_download_done", Icons.Filled.FileDownloadDone, "Download Done"),
+ ProfileIcon("file_download_off", Icons.Filled.FileDownloadOff, "Download Off"),
+ ProfileIcon("file_open", Icons.Filled.FileOpen, "File Open"),
+ ProfileIcon("file_present", Icons.Filled.FilePresent, "File Present"),
+ ProfileIcon("file_upload", Icons.Filled.FileUpload, "Upload"),
+ ProfileIcon("filter_alt", Icons.Filled.FilterAlt, "Filter Alt"),
+ ProfileIcon("filter_alt_off", Icons.Filled.FilterAltOff, "Filter Alt Off"),
+ ProfileIcon("filter_list", Icons.Filled.FilterList, "Filter"),
+ ProfileIcon("filter_list_off", Icons.Filled.FilterListOff, "Filter Off"),
+ ProfileIcon("find_in_page", Icons.Filled.FindInPage, "Find"),
+ ProfileIcon("find_replace", Icons.Filled.FindReplace, "Find Replace"),
+ ProfileIcon("fingerprint", Icons.Filled.Fingerprint, "Fingerprint"),
+ ProfileIcon("fit_screen", Icons.Filled.FitScreen, "Fit Screen"),
+ ProfileIcon("flaky", Icons.Filled.Flaky, "Flaky"),
+ ProfileIcon("flight_land", Icons.Filled.FlightLand, "Landing"),
+ ProfileIcon("flight_takeoff", Icons.Filled.FlightTakeoff, "Takeoff"),
+ ProfileIcon("flip_to_back", Icons.Filled.FlipToBack, "Flip Back"),
+ ProfileIcon("flip_to_front", Icons.Filled.FlipToFront, "Flip Front"),
+ ProfileIcon("flutter_dash", Icons.Filled.FlutterDash, "Flutter Dash"),
+ ProfileIcon("free_cancellation", Icons.Filled.FreeCancellation, "Free Cancellation"),
+ ProfileIcon("g_translate", Icons.Filled.GTranslate, "Translate"),
+ ProfileIcon("gavel", Icons.Filled.Gavel, "Gavel"),
+ ProfileIcon("generating_tokens", Icons.Filled.GeneratingTokens, "Generating Tokens"),
+ ProfileIcon("get_app", Icons.Filled.GetApp, "Get App"),
+ ProfileIcon("gif", Icons.Filled.Gif, "GIF"),
+ ProfileIcon("gif_box", Icons.Filled.GifBox, "GIF Box"),
+ ProfileIcon("grade", Icons.Filled.Grade, "Grade"),
+ ProfileIcon("grading", Icons.AutoMirrored.Filled.Grading, "Grading"),
+ ProfileIcon("group_work", Icons.Filled.GroupWork, "Group Work"),
+ ProfileIcon("help", Icons.AutoMirrored.Filled.Help, "Help"),
+ ProfileIcon("help_center", Icons.AutoMirrored.Filled.HelpCenter, "Help Center"),
+ ProfileIcon("help_outline", Icons.AutoMirrored.Filled.HelpOutline, "Help Outline"),
+ ProfileIcon("hide_source", Icons.Filled.HideSource, "Hide Source"),
+ ProfileIcon("highlight_alt", Icons.Filled.HighlightAlt, "Highlight Alt"),
+ ProfileIcon("highlight_off", Icons.Filled.HighlightOff, "Highlight Off"),
+ ProfileIcon("history", Icons.Filled.History, "History"),
+ ProfileIcon("history_toggle_off", Icons.Filled.HistoryToggleOff, "History Off"),
+ ProfileIcon("hls", Icons.Filled.Hls, "HLS"),
+ ProfileIcon("hls_off", Icons.Filled.HlsOff, "HLS Off"),
+ ProfileIcon("home", Icons.Filled.Home, "Home"),
+ ProfileIcon("home_filled", Icons.Filled.Home, "Home Filled"),
+ ProfileIcon("horizontal_split", Icons.Filled.HorizontalSplit, "Horizontal Split"),
+ ProfileIcon("hourglass_disabled", Icons.Filled.HourglassDisabled, "Hourglass Disabled"),
+ ProfileIcon("hourglass_empty", Icons.Filled.HourglassEmpty, "Hourglass Empty"),
+ ProfileIcon("hourglass_full", Icons.Filled.HourglassFull, "Hourglass Full"),
+ ProfileIcon("html", Icons.Filled.Html, "HTML"),
+ ProfileIcon("http", Icons.Filled.Http, "HTTP"),
+ ProfileIcon("https", Icons.Filled.Https, "HTTPS"),
+ ProfileIcon("important_devices", Icons.Filled.ImportantDevices, "Important Devices"),
+ ProfileIcon("info", Icons.Filled.Info, "Info"),
+ // ProfileIcon("info_outline", Icons.Filled.InfoOutline, "Info Outline"),
+ ProfileIcon("input", Icons.AutoMirrored.Filled.Input, "Input"),
+ ProfileIcon("install_desktop", Icons.Filled.InstallDesktop, "Install Desktop"),
+ ProfileIcon("install_mobile", Icons.Filled.InstallMobile, "Install Mobile"),
+ ProfileIcon(
+ "integration_instructions",
+ Icons.Filled.IntegrationInstructions,
+ "Integration",
+ ),
+ ProfileIcon("invert_colors", Icons.Filled.InvertColors, "Invert Colors"),
+ ProfileIcon("javascript", Icons.Filled.Javascript, "JavaScript"),
+ ProfileIcon("join_full", Icons.Filled.JoinFull, "Join Full"),
+ ProfileIcon("join_inner", Icons.Filled.JoinInner, "Join Inner"),
+ ProfileIcon("join_left", Icons.Filled.JoinLeft, "Join Left"),
+ ProfileIcon("join_right", Icons.Filled.JoinRight, "Join Right"),
+ ProfileIcon("label", Icons.AutoMirrored.Filled.Label, "Label"),
+ ProfileIcon("label_important", Icons.AutoMirrored.Filled.LabelImportant, "Important"),
+ ProfileIcon("label_off", Icons.AutoMirrored.Filled.LabelOff, "Label Off"),
+ ProfileIcon("language", Icons.Filled.Language, "Language"),
+ ProfileIcon("launch", Icons.AutoMirrored.Filled.Launch, "Launch"),
+ ProfileIcon("leaderboard", Icons.Filled.Leaderboard, "Leaderboard"),
+ ProfileIcon("lightbulb", Icons.Filled.Lightbulb, "Lightbulb"),
+ ProfileIcon("lightbulb_circle", Icons.Filled.LightbulbCircle, "Lightbulb Circle"),
+ // ProfileIcon("lightbulb_outline", Icons.Filled.LightbulbOutline, "Lightbulb Outline"),
+ ProfileIcon("line_style", Icons.Filled.LineStyle, "Line Style"),
+ ProfileIcon("line_weight", Icons.Filled.LineWeight, "Line Weight"),
+ ProfileIcon("list", Icons.AutoMirrored.Filled.List, "List"),
+ ProfileIcon("list_alt", Icons.AutoMirrored.Filled.ListAlt, "List Alt"),
+ ProfileIcon("lock", Icons.Filled.Lock, "Lock"),
+ ProfileIcon("lock_clock", Icons.Filled.LockClock, "Lock Clock"),
+ ProfileIcon("lock_open", Icons.Filled.LockOpen, "Lock Open"),
+ // ProfileIcon("lock_outline", Icons.Filled.LockOutline, "Lock Outline"),
+ ProfileIcon("lock_person", Icons.Filled.LockPerson, "Lock Person"),
+ ProfileIcon("lock_reset", Icons.Filled.LockReset, "Lock Reset"),
+ ProfileIcon("login", Icons.AutoMirrored.Filled.Login, "Login"),
+ ProfileIcon("logout", Icons.AutoMirrored.Filled.Logout, "Logout"),
+ ProfileIcon("loyalty", Icons.Filled.Loyalty, "Loyalty"),
+ ProfileIcon("manage_accounts", Icons.Filled.ManageAccounts, "Manage Accounts"),
+ ProfileIcon("manage_history", Icons.Filled.ManageHistory, "Manage History"),
+ ProfileIcon("manage_search", Icons.Filled.ManageSearch, "Manage Search"),
+ ProfileIcon("mark_as_unread", Icons.Filled.MarkAsUnread, "Mark Unread"),
+ ProfileIcon("markunread_mailbox", Icons.Filled.MarkunreadMailbox, "Unread Mailbox"),
+ ProfileIcon("maximize", Icons.Filled.Maximize, "Maximize"),
+ ProfileIcon("mediation", Icons.Filled.Mediation, "Mediation"),
+ ProfileIcon("minimize", Icons.Filled.Minimize, "Minimize"),
+ ProfileIcon("model_training", Icons.Filled.ModelTraining, "Model Training"),
+ ProfileIcon("next_plan", Icons.AutoMirrored.Filled.NextPlan, "Next Plan"),
+ ProfileIcon("nightlight", Icons.Filled.Nightlight, "Nightlight"),
+ ProfileIcon("nightlight_round", Icons.Filled.NightlightRound, "Nightlight Round"),
+ ProfileIcon("no_accounts", Icons.Filled.NoAccounts, "No Accounts"),
+ ProfileIcon("not_started", Icons.Filled.NotStarted, "Not Started"),
+ ProfileIcon("note_add", Icons.AutoMirrored.Filled.NoteAdd, "Note Add"),
+ ProfileIcon("offline_bolt", Icons.Filled.OfflineBolt, "Offline Bolt"),
+ ProfileIcon("offline_pin", Icons.Filled.OfflinePin, "Offline Pin"),
+ ProfileIcon("online_prediction", Icons.Filled.OnlinePrediction, "Online Prediction"),
+ ProfileIcon("opacity", Icons.Filled.Opacity, "Opacity"),
+ ProfileIcon("open_in_browser", Icons.Filled.OpenInBrowser, "Open Browser"),
+ ProfileIcon("open_in_full", Icons.Filled.OpenInFull, "Open Full"),
+ ProfileIcon("open_in_new", Icons.Filled.OpenInNew, "Open New"),
+ ProfileIcon("open_in_new_off", Icons.Filled.OpenInNewOff, "Open New Off"),
+ ProfileIcon("open_with", Icons.Filled.OpenWith, "Open With"),
+ ProfileIcon("outbond", Icons.Filled.Outbond, "Outbond"),
+ ProfileIcon("outlet", Icons.Filled.Outlet, "Outlet"),
+ ProfileIcon("output", Icons.Filled.Output, "Output"),
+ ProfileIcon("pageview", Icons.Filled.Pageview, "Pageview"),
+ ProfileIcon("paid", Icons.Filled.Paid, "Paid"),
+ ProfileIcon("pan_tool", Icons.Filled.PanTool, "Pan Tool"),
+ ProfileIcon("pan_tool_alt", Icons.Filled.PanToolAlt, "Pan Tool Alt"),
+ ProfileIcon("payment", Icons.Filled.Payment, "Payment"),
+ ProfileIcon("pending", Icons.Filled.Pending, "Pending"),
+ ProfileIcon("pending_actions", Icons.Filled.PendingActions, "Pending Actions"),
+ ProfileIcon("percent", Icons.Filled.Percent, "Percent"),
+ ProfileIcon("perm_camera_mic", Icons.Filled.PermCameraMic, "Camera Mic"),
+ ProfileIcon("perm_contact_calendar", Icons.Filled.PermContactCalendar, "Contact Calendar"),
+ ProfileIcon("perm_data_setting", Icons.Filled.PermDataSetting, "Data Setting"),
+ ProfileIcon("perm_device_information", Icons.Filled.PermDeviceInformation, "Device Info"),
+ ProfileIcon("perm_identity", Icons.Filled.PermIdentity, "Identity"),
+ ProfileIcon("perm_media", Icons.Filled.PermMedia, "Media"),
+ ProfileIcon("perm_phone_msg", Icons.Filled.PermPhoneMsg, "Phone Message"),
+ ProfileIcon("perm_scan_wifi", Icons.Filled.PermScanWifi, "Scan WiFi"),
+ ProfileIcon("pets", Icons.Filled.Pets, "Pets"),
+ ProfileIcon("php", Icons.Filled.Php, "PHP"),
+ ProfileIcon("picture_in_picture", Icons.Filled.PictureInPicture, "Picture in Picture"),
+ ProfileIcon("picture_in_picture_alt", Icons.Filled.PictureInPictureAlt, "PiP Alt"),
+ ProfileIcon("pin_end", Icons.Filled.PinEnd, "Pin End"),
+ ProfileIcon("pin_invoke", Icons.Filled.PinInvoke, "Pin Invoke"),
+ ProfileIcon("plagiarism", Icons.Filled.Plagiarism, "Plagiarism"),
+ ProfileIcon("play_for_work", Icons.Filled.PlayForWork, "Play Work"),
+ ProfileIcon("polymer", Icons.Filled.Polymer, "Polymer"),
+ ProfileIcon("power_settings_new", Icons.Filled.PowerSettingsNew, "Power"),
+ ProfileIcon("pregnant_woman", Icons.Filled.PregnantWoman, "Pregnant Woman"),
+ ProfileIcon("preview", Icons.Filled.Preview, "Preview"),
+ ProfileIcon("print", Icons.Filled.Print, "Print"),
+ ProfileIcon("print_disabled", Icons.Filled.PrintDisabled, "Print Disabled"),
+ ProfileIcon("privacy_tip", Icons.Filled.PrivacyTip, "Privacy Tip"),
+ ProfileIcon(
+ "production_quantity_limits",
+ Icons.Filled.ProductionQuantityLimits,
+ "Quantity Limits",
+ ),
+ ProfileIcon("published_with_changes", Icons.Filled.PublishedWithChanges, "Published"),
+ ProfileIcon("query_builder", Icons.Filled.QueryBuilder, "Query Builder"),
+ ProfileIcon("question_answer", Icons.Filled.QuestionAnswer, "Q&A"),
+ ProfileIcon("question_mark", Icons.Filled.QuestionMark, "Question Mark"),
+ ProfileIcon("quickreply", Icons.Filled.Quickreply, "Quick Reply"),
+ ProfileIcon("receipt", Icons.Filled.Receipt, "Receipt"),
+ ProfileIcon("receipt_long", Icons.AutoMirrored.Filled.ReceiptLong, "Receipt Long"),
+ ProfileIcon("record_voice_over", Icons.Filled.RecordVoiceOver, "Voice Over"),
+ ProfileIcon("redeem", Icons.Filled.Redeem, "Redeem"),
+ ProfileIcon("refresh", Icons.Filled.Refresh, "Refresh"),
+ ProfileIcon("remove_done", Icons.Filled.RemoveDone, "Remove Done"),
+ ProfileIcon("remove_shopping_cart", Icons.Filled.RemoveShoppingCart, "Remove Cart"),
+ ProfileIcon("reorder", Icons.Filled.Reorder, "Reorder"),
+ ProfileIcon("repartition", Icons.Filled.Repartition, "Repartition"),
+ ProfileIcon("report_problem", Icons.Filled.ReportProblem, "Report Problem"),
+ ProfileIcon("request_page", Icons.Filled.RequestPage, "Request Page"),
+ ProfileIcon("request_quote", Icons.Filled.RequestQuote, "Request Quote"),
+ ProfileIcon("restore", Icons.Filled.Restore, "Restore"),
+ ProfileIcon("restore_from_trash", Icons.Filled.RestoreFromTrash, "Restore Trash"),
+ ProfileIcon("restore_page", Icons.Filled.RestorePage, "Restore Page"),
+ ProfileIcon("rocket", Icons.Filled.Rocket, "Rocket"),
+ ProfileIcon("rocket_launch", Icons.Filled.RocketLaunch, "Rocket Launch"),
+ ProfileIcon("room", Icons.Filled.Room, "Room"),
+ ProfileIcon("rounded_corner", Icons.Filled.RoundedCorner, "Rounded Corner"),
+ ProfileIcon("rowing", Icons.Filled.Rowing, "Rowing"),
+ ProfileIcon("rule", Icons.Filled.Rule, "Rule"),
+ ProfileIcon("satellite_alt", Icons.Filled.SatelliteAlt, "Satellite"),
+ ProfileIcon("save", Icons.Filled.Save, "Save"),
+ ProfileIcon("save_alt", Icons.Filled.SaveAlt, "Save Alt"),
+ ProfileIcon("save_as", Icons.Filled.SaveAs, "Save As"),
+ ProfileIcon("saved_search", Icons.Filled.SavedSearch, "Saved Search"),
+ ProfileIcon("savings", Icons.Filled.Savings, "Savings"),
+ ProfileIcon("schedule", Icons.Filled.Schedule, "Schedule"),
+ ProfileIcon("schedule_send", Icons.Filled.ScheduleSend, "Schedule Send"),
+ ProfileIcon("search", Icons.Filled.Search, "Search"),
+ ProfileIcon("search_off", Icons.Filled.SearchOff, "Search Off"),
+ ProfileIcon("segment", Icons.Filled.Segment, "Segment"),
+ ProfileIcon("send", Icons.AutoMirrored.Filled.Send, "Send"),
+ ProfileIcon("send_and_archive", Icons.Filled.SendAndArchive, "Send Archive"),
+ ProfileIcon("sensors", Icons.Filled.Sensors, "Sensors"),
+ ProfileIcon("sensors_off", Icons.Filled.SensorsOff, "Sensors Off"),
+ ProfileIcon("settings", Icons.Filled.Settings, "Settings"),
+ ProfileIcon("settings_accessibility", Icons.Filled.SettingsAccessibility, "Accessibility"),
+ ProfileIcon("settings_applications", Icons.Filled.SettingsApplications, "Applications"),
+ ProfileIcon("settings_backup_restore", Icons.Filled.SettingsBackupRestore, "Backup"),
+ ProfileIcon("settings_bluetooth", Icons.Filled.SettingsBluetooth, "Bluetooth"),
+ ProfileIcon("settings_brightness", Icons.Filled.SettingsBrightness, "Brightness"),
+ ProfileIcon("settings_cell", Icons.Filled.SettingsCell, "Cell"),
+ ProfileIcon("settings_ethernet", Icons.Filled.SettingsEthernet, "Ethernet"),
+ ProfileIcon("settings_input_antenna", Icons.Filled.SettingsInputAntenna, "Antenna"),
+ ProfileIcon("settings_input_component", Icons.Filled.SettingsInputComponent, "Component"),
+ ProfileIcon("settings_input_composite", Icons.Filled.SettingsInputComposite, "Composite"),
+ ProfileIcon("settings_input_hdmi", Icons.Filled.SettingsInputHdmi, "HDMI"),
+ ProfileIcon("settings_input_svideo", Icons.Filled.SettingsInputSvideo, "S-Video"),
+ ProfileIcon("settings_overscan", Icons.Filled.SettingsOverscan, "Overscan"),
+ ProfileIcon("settings_phone", Icons.Filled.SettingsPhone, "Phone"),
+ ProfileIcon("settings_power", Icons.Filled.SettingsPower, "Power"),
+ ProfileIcon("settings_remote", Icons.Filled.SettingsRemote, "Remote"),
+ ProfileIcon("settings_voice", Icons.Filled.SettingsVoice, "Voice"),
+ ProfileIcon("shop", Icons.Filled.Shop, "Shop"),
+ ProfileIcon("shop_2", Icons.Filled.Shop2, "Shop 2"),
+ ProfileIcon("shop_two", Icons.Filled.ShopTwo, "Shop Two"),
+ ProfileIcon("shopping_bag", Icons.Filled.ShoppingBag, "Shopping Bag"),
+ ProfileIcon("shopping_basket", Icons.Filled.ShoppingBasket, "Shopping Basket"),
+ ProfileIcon("shopping_cart", Icons.Filled.ShoppingCart, "Shopping Cart"),
+ ProfileIcon("shopping_cart_checkout", Icons.Filled.ShoppingCartCheckout, "Checkout"),
+ ProfileIcon("smart_button", Icons.Filled.SmartButton, "Smart Button"),
+ ProfileIcon("source", Icons.Filled.Source, "Source"),
+ ProfileIcon("space_dashboard", Icons.Filled.SpaceDashboard, "Space Dashboard"),
+ ProfileIcon("spatial_audio", Icons.Filled.SpatialAudio, "Spatial Audio"),
+ ProfileIcon("spatial_audio_off", Icons.Filled.SpatialAudioOff, "Spatial Audio Off"),
+ ProfileIcon("spatial_tracking", Icons.Filled.SpatialTracking, "Spatial Tracking"),
+ ProfileIcon("speaker_notes", Icons.Filled.SpeakerNotes, "Speaker Notes"),
+ ProfileIcon("speaker_notes_off", Icons.Filled.SpeakerNotesOff, "Speaker Notes Off"),
+ ProfileIcon("spellcheck", Icons.Filled.Spellcheck, "Spellcheck"),
+ ProfileIcon("star_rate", Icons.Filled.StarRate, "Star Rate"),
+ ProfileIcon("stars", Icons.Filled.Stars, "Stars"),
+ ProfileIcon("sticky_note_2", Icons.Filled.StickyNote2, "Sticky Note"),
+ ProfileIcon("store", Icons.Filled.Store, "Store"),
+ ProfileIcon("subject", Icons.AutoMirrored.Filled.Subject, "Subject"),
+ ProfileIcon("subtitles_off", Icons.Filled.SubtitlesOff, "Subtitles Off"),
+ ProfileIcon("supervised_user_circle", Icons.Filled.SupervisedUserCircle, "Supervised User"),
+ ProfileIcon("supervisor_account", Icons.Filled.SupervisorAccount, "Supervisor"),
+ ProfileIcon("support", Icons.Filled.Support, "Support"),
+ ProfileIcon("swap_horiz", Icons.Filled.SwapHoriz, "Swap Horizontal"),
+ ProfileIcon("swap_horizontal_circle", Icons.Filled.SwapHorizontalCircle, "Swap Circle"),
+ ProfileIcon("swap_vert", Icons.Filled.SwapVert, "Swap Vertical"),
+ ProfileIcon(
+ "swap_vertical_circle",
+ Icons.Filled.SwapVerticalCircle,
+ "Swap Vertical Circle",
+ ),
+ ProfileIcon("swipe", Icons.Filled.Swipe, "Swipe"),
+ ProfileIcon("swipe_down", Icons.Filled.SwipeDown, "Swipe Down"),
+ ProfileIcon("swipe_down_alt", Icons.Filled.SwipeDownAlt, "Swipe Down Alt"),
+ ProfileIcon("swipe_left", Icons.Filled.SwipeLeft, "Swipe Left"),
+ ProfileIcon("swipe_left_alt", Icons.Filled.SwipeLeftAlt, "Swipe Left Alt"),
+ ProfileIcon("swipe_right", Icons.Filled.SwipeRight, "Swipe Right"),
+ ProfileIcon("swipe_right_alt", Icons.Filled.SwipeRightAlt, "Swipe Right Alt"),
+ ProfileIcon("swipe_up", Icons.Filled.SwipeUp, "Swipe Up"),
+ ProfileIcon("swipe_up_alt", Icons.Filled.SwipeUpAlt, "Swipe Up Alt"),
+ ProfileIcon("swipe_vertical", Icons.Filled.SwipeVertical, "Swipe Vertical"),
+ ProfileIcon("switch_access_shortcut", Icons.Filled.SwitchAccessShortcut, "Switch Shortcut"),
+ ProfileIcon(
+ "switch_access_shortcut_add",
+ Icons.Filled.SwitchAccessShortcutAdd,
+ "Add Shortcut",
+ ),
+ ProfileIcon("sync_alt", Icons.Filled.SyncAlt, "Sync Alt"),
+ ProfileIcon("system_update_alt", Icons.Filled.SystemUpdateAlt, "System Update"),
+ ProfileIcon("tab", Icons.Filled.Tab, "Tab"),
+ ProfileIcon("tab_unselected", Icons.Filled.TabUnselected, "Tab Unselected"),
+ ProfileIcon("table_view", Icons.Filled.TableView, "Table View"),
+ ProfileIcon("tag_faces", Icons.Filled.TagFaces, "Tag Faces"),
+ ProfileIcon("task_alt", Icons.Filled.TaskAlt, "Task Alt"),
+ ProfileIcon("terminal", Icons.Filled.Terminal, "Terminal"),
+ ProfileIcon("text_rotate_up", Icons.Filled.TextRotateUp, "Text Rotate Up"),
+ ProfileIcon("text_rotate_vertical", Icons.Filled.TextRotateVertical, "Text Vertical"),
+ ProfileIcon(
+ "text_rotation_angledown",
+ Icons.Filled.TextRotationAngledown,
+ "Text Angledown",
+ ),
+ ProfileIcon("text_rotation_angleup", Icons.Filled.TextRotationAngleup, "Text Angleup"),
+ ProfileIcon("text_rotation_down", Icons.Filled.TextRotationDown, "Text Down"),
+ ProfileIcon("text_rotation_none", Icons.Filled.TextRotationNone, "Text None"),
+ ProfileIcon("theaters", Icons.Filled.Theaters, "Theaters"),
+ ProfileIcon("thumb_down", Icons.Filled.ThumbDown, "Thumb Down"),
+ ProfileIcon("thumb_down_off_alt", Icons.Filled.ThumbDownOffAlt, "Thumb Down Alt"),
+ ProfileIcon("thumb_up", Icons.Filled.ThumbUp, "Thumb Up"),
+ ProfileIcon("thumb_up_off_alt", Icons.Filled.ThumbUpOffAlt, "Thumb Up Alt"),
+ ProfileIcon("thumbs_up_down", Icons.Filled.ThumbsUpDown, "Thumbs Up Down"),
+ ProfileIcon("timeline", Icons.Filled.Timeline, "Timeline"),
+ ProfileIcon("tips_and_updates", Icons.Filled.TipsAndUpdates, "Tips & Updates"),
+ ProfileIcon("toc", Icons.AutoMirrored.Filled.Toc, "Table of Contents"),
+ ProfileIcon("today", Icons.Filled.Today, "Today"),
+ ProfileIcon("token", Icons.Filled.Token, "Token"),
+ ProfileIcon("toll", Icons.Filled.Toll, "Toll"),
+ ProfileIcon("touch_app", Icons.Filled.TouchApp, "Touch App"),
+ ProfileIcon("tour", Icons.Filled.Tour, "Tour"),
+ ProfileIcon("track_changes", Icons.Filled.TrackChanges, "Track Changes"),
+ ProfileIcon("transcribe", Icons.Filled.Transcribe, "Transcribe"),
+ ProfileIcon("translate", Icons.Filled.Translate, "Translate"),
+ ProfileIcon("trending_down", Icons.AutoMirrored.Filled.TrendingDown, "Trending Down"),
+ ProfileIcon("trending_flat", Icons.AutoMirrored.Filled.TrendingFlat, "Trending Flat"),
+ ProfileIcon("trending_up", Icons.AutoMirrored.Filled.TrendingUp, "Trending Up"),
+ ProfileIcon("troubleshoot", Icons.Filled.Troubleshoot, "Troubleshoot"),
+ // ProfileIcon("try_sms_star", Icons.Filled.TrySmsStar, "Try SMS Star"),
+ ProfileIcon("turned_in", Icons.Filled.TurnedIn, "Turned In"),
+ ProfileIcon("turned_in_not", Icons.Filled.TurnedInNot, "Turned In Not"),
+ ProfileIcon("unfold_less_double", Icons.Filled.UnfoldLessDouble, "Unfold Less Double"),
+ ProfileIcon("unfold_more_double", Icons.Filled.UnfoldMoreDouble, "Unfold More Double"),
+ ProfileIcon("unpublished", Icons.Filled.Unpublished, "Unpublished"),
+ ProfileIcon("update", Icons.Filled.Update, "Update"),
+ ProfileIcon("update_disabled", Icons.Filled.UpdateDisabled, "Update Disabled"),
+ ProfileIcon("upgrade", Icons.Filled.Upgrade, "Upgrade"),
+ ProfileIcon("verified", Icons.Filled.Verified, "Verified"),
+ ProfileIcon("verified_user", Icons.Filled.VerifiedUser, "Verified User"),
+ ProfileIcon("vertical_split", Icons.Filled.VerticalSplit, "Vertical Split"),
+ ProfileIcon("view_agenda", Icons.Filled.ViewAgenda, "View Agenda"),
+ ProfileIcon("view_array", Icons.Filled.ViewArray, "View Array"),
+ ProfileIcon("view_carousel", Icons.Filled.ViewCarousel, "View Carousel"),
+ ProfileIcon("view_column", Icons.Filled.ViewColumn, "View Column"),
+ ProfileIcon("view_comfy", Icons.Filled.ViewComfy, "View Comfy"),
+ ProfileIcon("view_comfy_alt", Icons.Filled.ViewComfyAlt, "View Comfy Alt"),
+ ProfileIcon("view_compact", Icons.Filled.ViewCompact, "View Compact"),
+ ProfileIcon("view_compact_alt", Icons.Filled.ViewCompactAlt, "View Compact Alt"),
+ ProfileIcon("view_cozy", Icons.Filled.ViewCozy, "View Cozy"),
+ ProfileIcon("view_day", Icons.Filled.ViewDay, "View Day"),
+ ProfileIcon("view_headline", Icons.Filled.ViewHeadline, "View Headline"),
+ ProfileIcon("view_in_ar", Icons.Filled.ViewInAr, "View in AR"),
+ ProfileIcon("view_kanban", Icons.Filled.ViewKanban, "View Kanban"),
+ ProfileIcon("view_list", Icons.AutoMirrored.Filled.ViewList, "View List"),
+ ProfileIcon("view_module", Icons.Filled.ViewModule, "View Module"),
+ ProfileIcon("view_quilt", Icons.AutoMirrored.Filled.ViewQuilt, "View Quilt"),
+ ProfileIcon("view_sidebar", Icons.AutoMirrored.Filled.ViewSidebar, "View Sidebar"),
+ ProfileIcon("view_stream", Icons.Filled.ViewStream, "View Stream"),
+ ProfileIcon("view_timeline", Icons.Filled.ViewTimeline, "View Timeline"),
+ ProfileIcon("view_week", Icons.Filled.ViewWeek, "View Week"),
+ ProfileIcon("visibility", Icons.Filled.Visibility, "Visibility"),
+ ProfileIcon("visibility_off", Icons.Filled.VisibilityOff, "Visibility Off"),
+ ProfileIcon("voice_over_off", Icons.Filled.VoiceOverOff, "Voice Over Off"),
+ ProfileIcon("watch_later", Icons.Filled.WatchLater, "Watch Later"),
+ ProfileIcon("webhook", Icons.Filled.Webhook, "Webhook"),
+ ProfileIcon("width_full", Icons.Filled.WidthFull, "Width Full"),
+ ProfileIcon("width_normal", Icons.Filled.WidthNormal, "Width Normal"),
+ ProfileIcon("width_wide", Icons.Filled.WidthWide, "Width Wide"),
+ ProfileIcon("wifi_protected_setup", Icons.Filled.WifiProtectedSetup, "WiFi Setup"),
+ ProfileIcon("work", Icons.Filled.Work, "Work"),
+ ProfileIcon("work_history", Icons.Filled.WorkHistory, "Work History"),
+ ProfileIcon("work_off", Icons.Filled.WorkOff, "Work Off"),
+ ProfileIcon("work_outline", Icons.Filled.WorkOutline, "Work Outline"),
+ ProfileIcon("wysiwyg", Icons.Filled.Wysiwyg, "WYSIWYG"),
+ ProfileIcon("zoom_in", Icons.Filled.ZoomIn, "Zoom In"),
+ ProfileIcon("zoom_out", Icons.Filled.ZoomOut, "Zoom Out"),
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AlertIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AlertIcons.kt
new file mode 100644
index 0000000..216e951
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AlertIcons.kt
@@ -0,0 +1,28 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AddAlert
+import androidx.compose.material.icons.filled.AutoDelete
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material.icons.filled.ErrorOutline
+import androidx.compose.material.icons.filled.NotificationImportant
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material.icons.filled.WarningAmber
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * Alert category icons - Warnings, errors, and notifications
+ * Based on Google's Material Design Icons taxonomy
+ */
+object AlertIcons {
+ val icons =
+ listOf(
+ ProfileIcon("add_alert", Icons.Filled.AddAlert, "Add Alert"),
+ ProfileIcon("auto_delete", Icons.Filled.AutoDelete, "Auto Delete"),
+ ProfileIcon("error", Icons.Filled.Error, "Error"),
+ ProfileIcon("error_outline", Icons.Filled.ErrorOutline, "Error Outline"),
+ ProfileIcon("notification_important", Icons.Filled.NotificationImportant, "Important"),
+ ProfileIcon("warning", Icons.Filled.Warning, "Warning"),
+ ProfileIcon("warning_amber", Icons.Filled.WarningAmber, "Warning Amber"),
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/CommunicationIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/CommunicationIcons.kt
new file mode 100644
index 0000000..fda3847
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/CommunicationIcons.kt
@@ -0,0 +1,218 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Chat
+import androidx.compose.material.icons.automirrored.filled.ListAlt
+import androidx.compose.material.icons.automirrored.filled.LiveHelp
+import androidx.compose.material.icons.automirrored.filled.Message
+import androidx.compose.material.icons.automirrored.filled.ReadMore
+import androidx.compose.material.icons.filled.AddIcCall
+import androidx.compose.material.icons.filled.AlternateEmail
+import androidx.compose.material.icons.filled.AppRegistration
+import androidx.compose.material.icons.filled.Business
+import androidx.compose.material.icons.filled.Call
+import androidx.compose.material.icons.filled.CallEnd
+import androidx.compose.material.icons.filled.CallMade
+import androidx.compose.material.icons.filled.CallMerge
+import androidx.compose.material.icons.filled.CallMissed
+import androidx.compose.material.icons.filled.CallMissedOutgoing
+import androidx.compose.material.icons.filled.CallReceived
+import androidx.compose.material.icons.filled.CallSplit
+import androidx.compose.material.icons.filled.CancelPresentation
+import androidx.compose.material.icons.filled.CellWifi
+import androidx.compose.material.icons.filled.ChatBubble
+import androidx.compose.material.icons.filled.ChatBubbleOutline
+import androidx.compose.material.icons.filled.ClearAll
+import androidx.compose.material.icons.filled.CoPresent
+import androidx.compose.material.icons.filled.Comment
+import androidx.compose.material.icons.filled.CommentsDisabled
+import androidx.compose.material.icons.filled.ContactEmergency
+import androidx.compose.material.icons.filled.ContactMail
+import androidx.compose.material.icons.filled.ContactPhone
+import androidx.compose.material.icons.filled.Contacts
+import androidx.compose.material.icons.filled.DesktopAccessDisabled
+import androidx.compose.material.icons.filled.DialerSip
+import androidx.compose.material.icons.filled.Dialpad
+import androidx.compose.material.icons.filled.DocumentScanner
+import androidx.compose.material.icons.filled.DomainDisabled
+import androidx.compose.material.icons.filled.DomainVerification
+import androidx.compose.material.icons.filled.Duo
+import androidx.compose.material.icons.filled.Email
+import androidx.compose.material.icons.filled.Forum
+import androidx.compose.material.icons.filled.ForwardToInbox
+import androidx.compose.material.icons.filled.HourglassBottom
+import androidx.compose.material.icons.filled.HourglassTop
+import androidx.compose.material.icons.filled.Hub
+import androidx.compose.material.icons.filled.ImportContacts
+import androidx.compose.material.icons.filled.ImportExport
+import androidx.compose.material.icons.filled.Inbox
+import androidx.compose.material.icons.filled.InvertColorsOff
+import androidx.compose.material.icons.filled.Key
+import androidx.compose.material.icons.filled.KeyOff
+import androidx.compose.material.icons.filled.LocationOff
+import androidx.compose.material.icons.filled.LocationOn
+import androidx.compose.material.icons.filled.Mail
+import androidx.compose.material.icons.filled.MailLock
+import androidx.compose.material.icons.filled.MailOutline
+import androidx.compose.material.icons.filled.MarkChatRead
+import androidx.compose.material.icons.filled.MarkChatUnread
+import androidx.compose.material.icons.filled.MarkEmailRead
+import androidx.compose.material.icons.filled.MarkEmailUnread
+import androidx.compose.material.icons.filled.MarkUnreadChatAlt
+import androidx.compose.material.icons.filled.MobileScreenShare
+import androidx.compose.material.icons.filled.MoreTime
+import androidx.compose.material.icons.filled.Nat
+import androidx.compose.material.icons.filled.NoSim
+import androidx.compose.material.icons.filled.PausePresentation
+import androidx.compose.material.icons.filled.PersonAddDisabled
+import androidx.compose.material.icons.filled.PersonSearch
+import androidx.compose.material.icons.filled.Phone
+import androidx.compose.material.icons.filled.PhoneDisabled
+import androidx.compose.material.icons.filled.PhoneEnabled
+import androidx.compose.material.icons.filled.PhonelinkErase
+import androidx.compose.material.icons.filled.PhonelinkLock
+import androidx.compose.material.icons.filled.PhonelinkRing
+import androidx.compose.material.icons.filled.PhonelinkSetup
+import androidx.compose.material.icons.filled.PortableWifiOff
+import androidx.compose.material.icons.filled.PresentToAll
+import androidx.compose.material.icons.filled.PrintDisabled
+import androidx.compose.material.icons.filled.QrCode
+import androidx.compose.material.icons.filled.QrCode2
+import androidx.compose.material.icons.filled.QrCodeScanner
+import androidx.compose.material.icons.filled.RingVolume
+import androidx.compose.material.icons.filled.RssFeed
+import androidx.compose.material.icons.filled.Rtt
+import androidx.compose.material.icons.filled.ScreenShare
+import androidx.compose.material.icons.filled.SendTimeExtension
+import androidx.compose.material.icons.filled.SentimentSatisfiedAlt
+import androidx.compose.material.icons.filled.Sip
+import androidx.compose.material.icons.filled.SpeakerPhone
+import androidx.compose.material.icons.filled.Spoke
+import androidx.compose.material.icons.filled.StayCurrentLandscape
+import androidx.compose.material.icons.filled.StayCurrentPortrait
+import androidx.compose.material.icons.filled.StayPrimaryLandscape
+import androidx.compose.material.icons.filled.StayPrimaryPortrait
+import androidx.compose.material.icons.filled.StopScreenShare
+import androidx.compose.material.icons.filled.SwapCalls
+import androidx.compose.material.icons.filled.Textsms
+import androidx.compose.material.icons.filled.Unsubscribe
+import androidx.compose.material.icons.filled.Voicemail
+import androidx.compose.material.icons.filled.VpnKey
+import androidx.compose.material.icons.filled.VpnKeyOff
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * Communication category icons - Messaging, calls, emails
+ * Based on Google's Material Design Icons taxonomy
+ */
+object CommunicationIcons {
+ val icons =
+ listOf(
+ ProfileIcon("add_ic_call", Icons.Filled.AddIcCall, "Add Call"),
+ ProfileIcon("alternate_email", Icons.Filled.AlternateEmail, "Alt Email"),
+ ProfileIcon("app_registration", Icons.Filled.AppRegistration, "App Registration"),
+ ProfileIcon("business", Icons.Filled.Business, "Business"),
+ ProfileIcon("call", Icons.Filled.Call, "Call"),
+ ProfileIcon("call_end", Icons.Filled.CallEnd, "Call End"),
+ ProfileIcon("call_made", Icons.Filled.CallMade, "Call Made"),
+ ProfileIcon("call_merge", Icons.Filled.CallMerge, "Call Merge"),
+ ProfileIcon("call_missed", Icons.Filled.CallMissed, "Call Missed"),
+ ProfileIcon("call_missed_outgoing", Icons.Filled.CallMissedOutgoing, "Missed Outgoing"),
+ ProfileIcon("call_received", Icons.Filled.CallReceived, "Call Received"),
+ ProfileIcon("call_split", Icons.Filled.CallSplit, "Call Split"),
+ ProfileIcon("cancel_presentation", Icons.Filled.CancelPresentation, "Cancel Presentation"),
+ ProfileIcon("cell_wifi", Icons.Filled.CellWifi, "Cell WiFi"),
+ ProfileIcon("chat", Icons.AutoMirrored.Filled.Chat, "Chat"),
+ ProfileIcon("chat_bubble", Icons.Filled.ChatBubble, "Chat Bubble"),
+ ProfileIcon("chat_bubble_outline", Icons.Filled.ChatBubbleOutline, "Chat Outline"),
+ ProfileIcon("clear_all", Icons.Filled.ClearAll, "Clear All"),
+ ProfileIcon("co_present", Icons.Filled.CoPresent, "Co-Present"),
+ ProfileIcon("comment", Icons.Filled.Comment, "Comment"),
+ ProfileIcon("comments_disabled", Icons.Filled.CommentsDisabled, "Comments Disabled"),
+ ProfileIcon("contact_emergency", Icons.Filled.ContactEmergency, "Emergency Contact"),
+ ProfileIcon("contact_mail", Icons.Filled.ContactMail, "Contact Mail"),
+ ProfileIcon("contact_phone", Icons.Filled.ContactPhone, "Contact Phone"),
+ ProfileIcon("contacts", Icons.Filled.Contacts, "Contacts"),
+ ProfileIcon(
+ "desktop_access_disabled",
+ Icons.Filled.DesktopAccessDisabled,
+ "Desktop Disabled",
+ ),
+ ProfileIcon("dialer_sip", Icons.Filled.DialerSip, "Dialer SIP"),
+ ProfileIcon("dialpad", Icons.Filled.Dialpad, "Dialpad"),
+ ProfileIcon("document_scanner", Icons.Filled.DocumentScanner, "Document Scanner"),
+ ProfileIcon("domain_disabled", Icons.Filled.DomainDisabled, "Domain Disabled"),
+ ProfileIcon("domain_verification", Icons.Filled.DomainVerification, "Domain Verification"),
+ ProfileIcon("duo", Icons.Filled.Duo, "Duo"),
+ ProfileIcon("email", Icons.Filled.Email, "Email"),
+ ProfileIcon("forward_to_inbox", Icons.Filled.ForwardToInbox, "Forward to Inbox"),
+ ProfileIcon("forum", Icons.Filled.Forum, "Forum"),
+ ProfileIcon("hourglass_bottom", Icons.Filled.HourglassBottom, "Hourglass Bottom"),
+ ProfileIcon("hourglass_top", Icons.Filled.HourglassTop, "Hourglass Top"),
+ ProfileIcon("hub", Icons.Filled.Hub, "Hub"),
+ ProfileIcon("import_contacts", Icons.Filled.ImportContacts, "Import Contacts"),
+ ProfileIcon("import_export", Icons.Filled.ImportExport, "Import Export"),
+ ProfileIcon("inbox", Icons.Filled.Inbox, "Inbox"),
+ ProfileIcon("invert_colors_off", Icons.Filled.InvertColorsOff, "Invert Colors Off"),
+ ProfileIcon("key", Icons.Filled.Key, "Key"),
+ ProfileIcon("key_off", Icons.Filled.KeyOff, "Key Off"),
+ ProfileIcon("list_alt", Icons.AutoMirrored.Filled.ListAlt, "List Alt"),
+ ProfileIcon("live_help", Icons.AutoMirrored.Filled.LiveHelp, "Live Help"),
+ ProfileIcon("location_off", Icons.Filled.LocationOff, "Location Off"),
+ ProfileIcon("location_on", Icons.Filled.LocationOn, "Location On"),
+ ProfileIcon("mail", Icons.Filled.Mail, "Mail"),
+ ProfileIcon("mail_lock", Icons.Filled.MailLock, "Mail Lock"),
+ ProfileIcon("mail_outline", Icons.Filled.MailOutline, "Mail Outline"),
+ ProfileIcon("mark_chat_read", Icons.Filled.MarkChatRead, "Mark Chat Read"),
+ ProfileIcon("mark_chat_unread", Icons.Filled.MarkChatUnread, "Mark Chat Unread"),
+ ProfileIcon("mark_email_read", Icons.Filled.MarkEmailRead, "Mark Email Read"),
+ ProfileIcon("mark_email_unread", Icons.Filled.MarkEmailUnread, "Mark Email Unread"),
+ ProfileIcon("mark_unread_chat_alt", Icons.Filled.MarkUnreadChatAlt, "Mark Unread Alt"),
+ ProfileIcon("message", Icons.AutoMirrored.Filled.Message, "Message"),
+ ProfileIcon("mobile_screen_share", Icons.Filled.MobileScreenShare, "Mobile Share"),
+ ProfileIcon("more_time", Icons.Filled.MoreTime, "More Time"),
+ ProfileIcon("nat", Icons.Filled.Nat, "NAT"),
+ ProfileIcon("no_sim", Icons.Filled.NoSim, "No SIM"),
+ ProfileIcon("pause_presentation", Icons.Filled.PausePresentation, "Pause Presentation"),
+ ProfileIcon("person_add_disabled", Icons.Filled.PersonAddDisabled, "Person Disabled"),
+ ProfileIcon("person_search", Icons.Filled.PersonSearch, "Person Search"),
+ ProfileIcon("phone", Icons.Filled.Phone, "Phone"),
+ ProfileIcon("phone_disabled", Icons.Filled.PhoneDisabled, "Phone Disabled"),
+ ProfileIcon("phone_enabled", Icons.Filled.PhoneEnabled, "Phone Enabled"),
+ ProfileIcon("phonelink_erase", Icons.Filled.PhonelinkErase, "Phone Erase"),
+ ProfileIcon("phonelink_lock", Icons.Filled.PhonelinkLock, "Phone Lock"),
+ ProfileIcon("phonelink_ring", Icons.Filled.PhonelinkRing, "Phone Ring"),
+ ProfileIcon("phonelink_setup", Icons.Filled.PhonelinkSetup, "Phone Setup"),
+ ProfileIcon("portable_wifi_off", Icons.Filled.PortableWifiOff, "Portable WiFi Off"),
+ ProfileIcon("present_to_all", Icons.Filled.PresentToAll, "Present to All"),
+ ProfileIcon("print_disabled", Icons.Filled.PrintDisabled, "Print Disabled"),
+ ProfileIcon("qr_code", Icons.Filled.QrCode, "QR Code"),
+ ProfileIcon("qr_code_2", Icons.Filled.QrCode2, "QR Code 2"),
+ ProfileIcon("qr_code_scanner", Icons.Filled.QrCodeScanner, "QR Scanner"),
+ ProfileIcon("read_more", Icons.AutoMirrored.Filled.ReadMore, "Read More"),
+ ProfileIcon("ring_volume", Icons.Filled.RingVolume, "Ring Volume"),
+ ProfileIcon("rss_feed", Icons.Filled.RssFeed, "RSS Feed"),
+ ProfileIcon("rtt", Icons.Filled.Rtt, "RTT"),
+ ProfileIcon("screen_share", Icons.Filled.ScreenShare, "Screen Share"),
+ ProfileIcon("send_time_extension", Icons.Filled.SendTimeExtension, "Send Extension"),
+ ProfileIcon("sentiment_satisfied_alt", Icons.Filled.SentimentSatisfiedAlt, "Satisfied"),
+ ProfileIcon("sip", Icons.Filled.Sip, "SIP"),
+ ProfileIcon("speaker_phone", Icons.Filled.SpeakerPhone, "Speaker Phone"),
+ ProfileIcon("spoke", Icons.Filled.Spoke, "Spoke"),
+ ProfileIcon("stay_current_landscape", Icons.Filled.StayCurrentLandscape, "Stay Landscape"),
+ ProfileIcon("stay_current_portrait", Icons.Filled.StayCurrentPortrait, "Stay Portrait"),
+ ProfileIcon(
+ "stay_primary_landscape",
+ Icons.Filled.StayPrimaryLandscape,
+ "Primary Landscape",
+ ),
+ ProfileIcon("stay_primary_portrait", Icons.Filled.StayPrimaryPortrait, "Primary Portrait"),
+ ProfileIcon("stop_screen_share", Icons.Filled.StopScreenShare, "Stop Screen Share"),
+ ProfileIcon("swap_calls", Icons.Filled.SwapCalls, "Swap Calls"),
+ ProfileIcon("textsms", Icons.Filled.Textsms, "Text SMS"),
+ ProfileIcon("unsubscribe", Icons.Filled.Unsubscribe, "Unsubscribe"),
+ ProfileIcon("voicemail", Icons.Filled.Voicemail, "Voicemail"),
+ ProfileIcon("vpn_key", Icons.Filled.VpnKey, "VPN Key"),
+ ProfileIcon("vpn_key_off", Icons.Filled.VpnKeyOff, "VPN Key Off"),
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ContentIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ContentIcons.kt
new file mode 100644
index 0000000..938ad51
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ContentIcons.kt
@@ -0,0 +1,187 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Backspace
+import androidx.compose.material.icons.automirrored.filled.NextWeek
+import androidx.compose.material.icons.automirrored.filled.Redo
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.automirrored.filled.Sort
+import androidx.compose.material.icons.automirrored.filled.Undo
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.AddBox
+import androidx.compose.material.icons.filled.AddCircle
+import androidx.compose.material.icons.filled.AddCircleOutline
+import androidx.compose.material.icons.filled.AddLink
+import androidx.compose.material.icons.filled.Archive
+import androidx.compose.material.icons.filled.Ballot
+import androidx.compose.material.icons.filled.Biotech
+import androidx.compose.material.icons.filled.Block
+import androidx.compose.material.icons.filled.Bolt
+import androidx.compose.material.icons.filled.Calculate
+import androidx.compose.material.icons.filled.ChangeCircle
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.ContentCut
+import androidx.compose.material.icons.filled.ContentPaste
+import androidx.compose.material.icons.filled.ContentPasteGo
+import androidx.compose.material.icons.filled.ContentPasteOff
+import androidx.compose.material.icons.filled.ContentPasteSearch
+import androidx.compose.material.icons.filled.CopyAll
+import androidx.compose.material.icons.filled.Create
+import androidx.compose.material.icons.filled.Deselect
+import androidx.compose.material.icons.filled.Drafts
+import androidx.compose.material.icons.filled.DynamicFeed
+import androidx.compose.material.icons.filled.FileCopy
+import androidx.compose.material.icons.filled.Filter1
+import androidx.compose.material.icons.filled.Filter2
+import androidx.compose.material.icons.filled.Filter3
+import androidx.compose.material.icons.filled.Filter4
+import androidx.compose.material.icons.filled.Filter5
+import androidx.compose.material.icons.filled.Filter6
+import androidx.compose.material.icons.filled.Filter7
+import androidx.compose.material.icons.filled.Filter8
+import androidx.compose.material.icons.filled.Filter9
+import androidx.compose.material.icons.filled.Filter9Plus
+import androidx.compose.material.icons.filled.Flag
+import androidx.compose.material.icons.filled.FlagCircle
+import androidx.compose.material.icons.filled.FontDownload
+import androidx.compose.material.icons.filled.FontDownloadOff
+import androidx.compose.material.icons.filled.Forward
+import androidx.compose.material.icons.filled.Gesture
+import androidx.compose.material.icons.filled.HowToReg
+import androidx.compose.material.icons.filled.HowToVote
+import androidx.compose.material.icons.filled.Inbox
+import androidx.compose.material.icons.filled.Insights
+import androidx.compose.material.icons.filled.Inventory
+import androidx.compose.material.icons.filled.Inventory2
+import androidx.compose.material.icons.filled.Link
+import androidx.compose.material.icons.filled.LinkOff
+import androidx.compose.material.icons.filled.LowPriority
+import androidx.compose.material.icons.filled.Mail
+import androidx.compose.material.icons.filled.Markunread
+import androidx.compose.material.icons.filled.MoveToInbox
+import androidx.compose.material.icons.filled.OutlinedFlag
+import androidx.compose.material.icons.filled.Policy
+import androidx.compose.material.icons.filled.PushPin
+import androidx.compose.material.icons.filled.Remove
+import androidx.compose.material.icons.filled.RemoveCircle
+import androidx.compose.material.icons.filled.RemoveCircleOutline
+import androidx.compose.material.icons.filled.Reply
+import androidx.compose.material.icons.filled.ReplyAll
+import androidx.compose.material.icons.filled.Report
+import androidx.compose.material.icons.filled.ReportGmailerrorred
+import androidx.compose.material.icons.filled.ReportOff
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material.icons.filled.SaveAlt
+import androidx.compose.material.icons.filled.SaveAs
+import androidx.compose.material.icons.filled.SelectAll
+import androidx.compose.material.icons.filled.Shield
+import androidx.compose.material.icons.filled.SquareFoot
+import androidx.compose.material.icons.filled.StackedBarChart
+import androidx.compose.material.icons.filled.Stream
+import androidx.compose.material.icons.filled.Tag
+import androidx.compose.material.icons.filled.TextFormat
+import androidx.compose.material.icons.filled.Unarchive
+import androidx.compose.material.icons.filled.Upcoming
+import androidx.compose.material.icons.filled.Waves
+import androidx.compose.material.icons.filled.WebStories
+import androidx.compose.material.icons.filled.Weekend
+import androidx.compose.material.icons.filled.WhereToVote
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * Content category icons - Content creation and management
+ * Based on Google's Material Design Icons taxonomy
+ */
+object ContentIcons {
+ val icons =
+ listOf(
+ ProfileIcon("add", Icons.Filled.Add, "Add"),
+ ProfileIcon("add_box", Icons.Filled.AddBox, "Add Box"),
+ ProfileIcon("add_circle", Icons.Filled.AddCircle, "Add Circle"),
+ ProfileIcon("add_circle_outline", Icons.Filled.AddCircleOutline, "Add Outline"),
+ ProfileIcon("add_link", Icons.Filled.AddLink, "Add Link"),
+ ProfileIcon("archive", Icons.Filled.Archive, "Archive"),
+ ProfileIcon("backspace", Icons.AutoMirrored.Filled.Backspace, "Backspace"),
+ ProfileIcon("ballot", Icons.Filled.Ballot, "Ballot"),
+ ProfileIcon("biotech", Icons.Filled.Biotech, "Biotech"),
+ ProfileIcon("block", Icons.Filled.Block, "Block"),
+ ProfileIcon("block_flipped", Icons.Filled.Block, "Block Flipped"),
+ ProfileIcon("bolt", Icons.Filled.Bolt, "Bolt"),
+ ProfileIcon("calculate", Icons.Filled.Calculate, "Calculate"),
+ ProfileIcon("change_circle", Icons.Filled.ChangeCircle, "Change Circle"),
+ ProfileIcon("clear", Icons.Filled.Clear, "Clear"),
+ ProfileIcon("content_copy", Icons.Filled.ContentCopy, "Copy"),
+ ProfileIcon("content_cut", Icons.Filled.ContentCut, "Cut"),
+ ProfileIcon("content_paste", Icons.Filled.ContentPaste, "Paste"),
+ ProfileIcon("content_paste_go", Icons.Filled.ContentPasteGo, "Paste Go"),
+ ProfileIcon("content_paste_off", Icons.Filled.ContentPasteOff, "Paste Off"),
+ ProfileIcon("content_paste_search", Icons.Filled.ContentPasteSearch, "Paste Search"),
+ ProfileIcon("copy_all", Icons.Filled.CopyAll, "Copy All"),
+ ProfileIcon("create", Icons.Filled.Create, "Create"),
+ ProfileIcon("deselect", Icons.Filled.Deselect, "Deselect"),
+ ProfileIcon("drafts", Icons.Filled.Drafts, "Drafts"),
+ ProfileIcon("dynamic_feed", Icons.Filled.DynamicFeed, "Dynamic Feed"),
+ ProfileIcon("file_copy", Icons.Filled.FileCopy, "File Copy"),
+ ProfileIcon("filter_1", Icons.Filled.Filter1, "Filter 1"),
+ ProfileIcon("filter_2", Icons.Filled.Filter2, "Filter 2"),
+ ProfileIcon("filter_3", Icons.Filled.Filter3, "Filter 3"),
+ ProfileIcon("filter_4", Icons.Filled.Filter4, "Filter 4"),
+ ProfileIcon("filter_5", Icons.Filled.Filter5, "Filter 5"),
+ ProfileIcon("filter_6", Icons.Filled.Filter6, "Filter 6"),
+ ProfileIcon("filter_7", Icons.Filled.Filter7, "Filter 7"),
+ ProfileIcon("filter_8", Icons.Filled.Filter8, "Filter 8"),
+ ProfileIcon("filter_9", Icons.Filled.Filter9, "Filter 9"),
+ ProfileIcon("filter_9_plus", Icons.Filled.Filter9Plus, "Filter 9+"),
+ ProfileIcon("flag", Icons.Filled.Flag, "Flag"),
+ ProfileIcon("flag_circle", Icons.Filled.FlagCircle, "Flag Circle"),
+ ProfileIcon("font_download", Icons.Filled.FontDownload, "Font Download"),
+ ProfileIcon("font_download_off", Icons.Filled.FontDownloadOff, "Font Download Off"),
+ ProfileIcon("forward", Icons.Filled.Forward, "Forward"),
+ ProfileIcon("gesture", Icons.Filled.Gesture, "Gesture"),
+ ProfileIcon("how_to_reg", Icons.Filled.HowToReg, "How to Register"),
+ ProfileIcon("how_to_vote", Icons.Filled.HowToVote, "How to Vote"),
+ ProfileIcon("inbox", Icons.Filled.Inbox, "Inbox"),
+ ProfileIcon("insights", Icons.Filled.Insights, "Insights"),
+ ProfileIcon("inventory", Icons.Filled.Inventory, "Inventory"),
+ ProfileIcon("inventory_2", Icons.Filled.Inventory2, "Inventory 2"),
+ ProfileIcon("link", Icons.Filled.Link, "Link"),
+ ProfileIcon("link_off", Icons.Filled.LinkOff, "Link Off"),
+ ProfileIcon("low_priority", Icons.Filled.LowPriority, "Low Priority"),
+ ProfileIcon("mail", Icons.Filled.Mail, "Mail"),
+ ProfileIcon("markunread", Icons.Filled.Markunread, "Mark Unread"),
+ ProfileIcon("move_to_inbox", Icons.Filled.MoveToInbox, "Move to Inbox"),
+ ProfileIcon("next_week", Icons.AutoMirrored.Filled.NextWeek, "Next Week"),
+ ProfileIcon("outlined_flag", Icons.Filled.OutlinedFlag, "Outlined Flag"),
+ ProfileIcon("policy", Icons.Filled.Policy, "Policy"),
+ ProfileIcon("push_pin", Icons.Filled.PushPin, "Push Pin"),
+ ProfileIcon("redo", Icons.AutoMirrored.Filled.Redo, "Redo"),
+ ProfileIcon("remove", Icons.Filled.Remove, "Remove"),
+ ProfileIcon("remove_circle", Icons.Filled.RemoveCircle, "Remove Circle"),
+ ProfileIcon("remove_circle_outline", Icons.Filled.RemoveCircleOutline, "Remove Outline"),
+ ProfileIcon("reply", Icons.Filled.Reply, "Reply"),
+ ProfileIcon("reply_all", Icons.Filled.ReplyAll, "Reply All"),
+ ProfileIcon("report", Icons.Filled.Report, "Report"),
+ ProfileIcon("report_gmailerrorred", Icons.Filled.ReportGmailerrorred, "Report Error"),
+ ProfileIcon("report_off", Icons.Filled.ReportOff, "Report Off"),
+ ProfileIcon("save", Icons.Filled.Save, "Save"),
+ ProfileIcon("save_alt", Icons.Filled.SaveAlt, "Save Alt"),
+ ProfileIcon("save_as", Icons.Filled.SaveAs, "Save As"),
+ ProfileIcon("select_all", Icons.Filled.SelectAll, "Select All"),
+ ProfileIcon("send", Icons.AutoMirrored.Filled.Send, "Send"),
+ ProfileIcon("shield", Icons.Filled.Shield, "Shield"),
+ ProfileIcon("sort", Icons.AutoMirrored.Filled.Sort, "Sort"),
+ ProfileIcon("square_foot", Icons.Filled.SquareFoot, "Square Foot"),
+ ProfileIcon("stacked_bar_chart", Icons.Filled.StackedBarChart, "Stacked Chart"),
+ ProfileIcon("stream", Icons.Filled.Stream, "Stream"),
+ ProfileIcon("tag", Icons.Filled.Tag, "Tag"),
+ ProfileIcon("text_format", Icons.Filled.TextFormat, "Text Format"),
+ ProfileIcon("unarchive", Icons.Filled.Unarchive, "Unarchive"),
+ ProfileIcon("undo", Icons.AutoMirrored.Filled.Undo, "Undo"),
+ ProfileIcon("upcoming", Icons.Filled.Upcoming, "Upcoming"),
+ ProfileIcon("waves", Icons.Filled.Waves, "Waves"),
+ ProfileIcon("web_stories", Icons.Filled.WebStories, "Web Stories"),
+ ProfileIcon("weekend", Icons.Filled.Weekend, "Weekend"),
+ ProfileIcon("where_to_vote", Icons.Filled.WhereToVote, "Where to Vote"),
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/DeviceIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/DeviceIcons.kt
new file mode 100644
index 0000000..0ba4b41
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/DeviceIcons.kt
@@ -0,0 +1,469 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccessTime
+import androidx.compose.material.icons.filled.AccessTimeFilled
+import androidx.compose.material.icons.filled.AdUnits
+import androidx.compose.material.icons.filled.AddAlarm
+import androidx.compose.material.icons.filled.AddToHomeScreen
+import androidx.compose.material.icons.filled.Air
+import androidx.compose.material.icons.filled.AirplaneTicket
+import androidx.compose.material.icons.filled.AirplanemodeActive
+import androidx.compose.material.icons.filled.AirplanemodeInactive
+import androidx.compose.material.icons.filled.Aod
+import androidx.compose.material.icons.filled.Battery0Bar
+import androidx.compose.material.icons.filled.Battery1Bar
+import androidx.compose.material.icons.filled.Battery2Bar
+import androidx.compose.material.icons.filled.Battery3Bar
+import androidx.compose.material.icons.filled.Battery4Bar
+import androidx.compose.material.icons.filled.Battery5Bar
+import androidx.compose.material.icons.filled.Battery6Bar
+import androidx.compose.material.icons.filled.BatteryAlert
+import androidx.compose.material.icons.filled.BatteryChargingFull
+import androidx.compose.material.icons.filled.BatteryFull
+import androidx.compose.material.icons.filled.BatterySaver
+import androidx.compose.material.icons.filled.BatteryStd
+import androidx.compose.material.icons.filled.BatteryUnknown
+import androidx.compose.material.icons.filled.Bloodtype
+import androidx.compose.material.icons.filled.Bluetooth
+import androidx.compose.material.icons.filled.BluetoothAudio
+import androidx.compose.material.icons.filled.BluetoothConnected
+import androidx.compose.material.icons.filled.BluetoothDisabled
+import androidx.compose.material.icons.filled.BluetoothDrive
+import androidx.compose.material.icons.filled.BluetoothSearching
+import androidx.compose.material.icons.filled.BrightnessAuto
+import androidx.compose.material.icons.filled.BrightnessHigh
+import androidx.compose.material.icons.filled.BrightnessLow
+import androidx.compose.material.icons.filled.BrightnessMedium
+import androidx.compose.material.icons.filled.Cable
+import androidx.compose.material.icons.filled.Cameraswitch
+import androidx.compose.material.icons.filled.CreditScore
+import androidx.compose.material.icons.filled.DarkMode
+import androidx.compose.material.icons.filled.DataSaverOff
+import androidx.compose.material.icons.filled.DataSaverOn
+import androidx.compose.material.icons.filled.DataUsage
+import androidx.compose.material.icons.filled.Dataset
+import androidx.compose.material.icons.filled.DatasetLinked
+import androidx.compose.material.icons.filled.DeveloperMode
+import androidx.compose.material.icons.filled.DeviceThermostat
+import androidx.compose.material.icons.filled.Devices
+import androidx.compose.material.icons.filled.DevicesFold
+import androidx.compose.material.icons.filled.DevicesOther
+import androidx.compose.material.icons.filled.Discount
+import androidx.compose.material.icons.filled.DoNotDisturbOnTotalSilence
+import androidx.compose.material.icons.filled.Dvr
+import androidx.compose.material.icons.filled.EMobiledata
+import androidx.compose.material.icons.filled.EdgesensorHigh
+import androidx.compose.material.icons.filled.EdgesensorLow
+import androidx.compose.material.icons.filled.FlashlightOff
+import androidx.compose.material.icons.filled.FlashlightOn
+import androidx.compose.material.icons.filled.Flourescent
+import androidx.compose.material.icons.filled.Fluorescent
+import androidx.compose.material.icons.filled.FmdBad
+import androidx.compose.material.icons.filled.FmdGood
+import androidx.compose.material.icons.filled.GMobiledata
+import androidx.compose.material.icons.filled.GppBad
+import androidx.compose.material.icons.filled.GppGood
+import androidx.compose.material.icons.filled.GppMaybe
+import androidx.compose.material.icons.filled.GpsFixed
+import androidx.compose.material.icons.filled.GpsNotFixed
+import androidx.compose.material.icons.filled.GpsOff
+import androidx.compose.material.icons.filled.GraphicEq
+import androidx.compose.material.icons.filled.Grid3x3
+import androidx.compose.material.icons.filled.Grid4x4
+import androidx.compose.material.icons.filled.GridGoldenratio
+import androidx.compose.material.icons.filled.HMobiledata
+import androidx.compose.material.icons.filled.HPlusMobiledata
+import androidx.compose.material.icons.filled.HdrAuto
+import androidx.compose.material.icons.filled.HdrAutoSelect
+import androidx.compose.material.icons.filled.HdrOffSelect
+import androidx.compose.material.icons.filled.HdrOnSelect
+import androidx.compose.material.icons.filled.Lan
+import androidx.compose.material.icons.filled.LensBlur
+import androidx.compose.material.icons.filled.LightMode
+import androidx.compose.material.icons.filled.LocationDisabled
+import androidx.compose.material.icons.filled.LocationSearching
+import androidx.compose.material.icons.filled.LteMobiledata
+import androidx.compose.material.icons.filled.LtePlusMobiledata
+import androidx.compose.material.icons.filled.MediaBluetoothOff
+import androidx.compose.material.icons.filled.MediaBluetoothOn
+import androidx.compose.material.icons.filled.Medication
+import androidx.compose.material.icons.filled.MobileFriendly
+import androidx.compose.material.icons.filled.MobileOff
+import androidx.compose.material.icons.filled.MobiledataOff
+import androidx.compose.material.icons.filled.ModeNight
+import androidx.compose.material.icons.filled.ModeStandby
+import androidx.compose.material.icons.filled.MonitorHeart
+import androidx.compose.material.icons.filled.MonitorWeight
+import androidx.compose.material.icons.filled.NearbyError
+import androidx.compose.material.icons.filled.NearbyOff
+import androidx.compose.material.icons.filled.NetworkCell
+import androidx.compose.material.icons.filled.NetworkWifi
+import androidx.compose.material.icons.filled.NetworkWifi1Bar
+import androidx.compose.material.icons.filled.NetworkWifi2Bar
+import androidx.compose.material.icons.filled.NetworkWifi3Bar
+import androidx.compose.material.icons.filled.Nfc
+import androidx.compose.material.icons.filled.Nightlight
+import androidx.compose.material.icons.filled.NoteAlt
+import androidx.compose.material.icons.filled.Password
+import androidx.compose.material.icons.filled.Pattern
+import androidx.compose.material.icons.filled.Phishing
+import androidx.compose.material.icons.filled.Pin
+import androidx.compose.material.icons.filled.PlayLesson
+import androidx.compose.material.icons.filled.PriceChange
+import androidx.compose.material.icons.filled.PriceCheck
+import androidx.compose.material.icons.filled.PunchClock
+import androidx.compose.material.icons.filled.Quiz
+import androidx.compose.material.icons.filled.RMobiledata
+import androidx.compose.material.icons.filled.Radar
+import androidx.compose.material.icons.filled.RememberMe
+import androidx.compose.material.icons.filled.ResetTv
+import androidx.compose.material.icons.filled.RestartAlt
+import androidx.compose.material.icons.filled.Reviews
+import androidx.compose.material.icons.filled.Rsvp
+import androidx.compose.material.icons.filled.ScreenLockLandscape
+import androidx.compose.material.icons.filled.ScreenLockPortrait
+import androidx.compose.material.icons.filled.ScreenLockRotation
+import androidx.compose.material.icons.filled.ScreenRotation
+import androidx.compose.material.icons.filled.ScreenSearchDesktop
+import androidx.compose.material.icons.filled.Screenshot
+import androidx.compose.material.icons.filled.ScreenshotMonitor
+import androidx.compose.material.icons.filled.SdStorage
+import androidx.compose.material.icons.filled.SecurityUpdate
+import androidx.compose.material.icons.filled.SecurityUpdateGood
+import androidx.compose.material.icons.filled.SecurityUpdateWarning
+import androidx.compose.material.icons.filled.Sell
+import androidx.compose.material.icons.filled.SendToMobile
+import androidx.compose.material.icons.filled.SettingsSuggest
+import androidx.compose.material.icons.filled.SettingsSystemDaydream
+import androidx.compose.material.icons.filled.ShareLocation
+import androidx.compose.material.icons.filled.Shortcut
+import androidx.compose.material.icons.filled.SignalCellular0Bar
+import androidx.compose.material.icons.filled.SignalCellular4Bar
+import androidx.compose.material.icons.filled.SignalCellularAlt
+import androidx.compose.material.icons.filled.SignalCellularAlt1Bar
+import androidx.compose.material.icons.filled.SignalCellularAlt2Bar
+import androidx.compose.material.icons.filled.SignalCellularConnectedNoInternet0Bar
+import androidx.compose.material.icons.filled.SignalCellularConnectedNoInternet4Bar
+import androidx.compose.material.icons.filled.SignalCellularNoSim
+import androidx.compose.material.icons.filled.SignalCellularNodata
+import androidx.compose.material.icons.filled.SignalCellularNull
+import androidx.compose.material.icons.filled.SignalCellularOff
+import androidx.compose.material.icons.filled.SignalWifi0Bar
+import androidx.compose.material.icons.filled.SignalWifi4Bar
+import androidx.compose.material.icons.filled.SignalWifi4BarLock
+import androidx.compose.material.icons.filled.SignalWifiBad
+import androidx.compose.material.icons.filled.SignalWifiConnectedNoInternet4
+import androidx.compose.material.icons.filled.SignalWifiOff
+import androidx.compose.material.icons.filled.SignalWifiStatusbar4Bar
+import androidx.compose.material.icons.filled.SignalWifiStatusbarConnectedNoInternet4
+import androidx.compose.material.icons.filled.SignalWifiStatusbarNull
+import androidx.compose.material.icons.filled.SimCard
+import androidx.compose.material.icons.filled.SimCardAlert
+import androidx.compose.material.icons.filled.SimCardDownload
+import androidx.compose.material.icons.filled.SmartDisplay
+import androidx.compose.material.icons.filled.SmartScreen
+import androidx.compose.material.icons.filled.SmartToy
+import androidx.compose.material.icons.filled.Splitscreen
+import androidx.compose.material.icons.filled.SportsScore
+import androidx.compose.material.icons.filled.SsidChart
+import androidx.compose.material.icons.filled.Storage
+import androidx.compose.material.icons.filled.Storm
+import androidx.compose.material.icons.filled.Summarize
+import androidx.compose.material.icons.filled.SystemSecurityUpdate
+import androidx.compose.material.icons.filled.SystemSecurityUpdateGood
+import androidx.compose.material.icons.filled.SystemSecurityUpdateWarning
+import androidx.compose.material.icons.filled.Task
+import androidx.compose.material.icons.filled.Thermostat
+import androidx.compose.material.icons.filled.ThermostatAuto
+import androidx.compose.material.icons.filled.Timer
+import androidx.compose.material.icons.filled.Timer10
+import androidx.compose.material.icons.filled.Timer10Select
+import androidx.compose.material.icons.filled.Timer3
+import androidx.compose.material.icons.filled.Timer3Select
+import androidx.compose.material.icons.filled.TimerOff
+import androidx.compose.material.icons.filled.Tungsten
+import androidx.compose.material.icons.filled.Usb
+import androidx.compose.material.icons.filled.UsbOff
+import androidx.compose.material.icons.filled.Wallpaper
+import androidx.compose.material.icons.filled.Water
+import androidx.compose.material.icons.filled.Widgets
+import androidx.compose.material.icons.filled.Wifi
+import androidx.compose.material.icons.filled.Wifi1Bar
+import androidx.compose.material.icons.filled.Wifi2Bar
+import androidx.compose.material.icons.filled.WifiCalling
+import androidx.compose.material.icons.filled.WifiCalling3
+import androidx.compose.material.icons.filled.WifiChannel
+import androidx.compose.material.icons.filled.WifiFind
+import androidx.compose.material.icons.filled.WifiLock
+import androidx.compose.material.icons.filled.WifiOff
+import androidx.compose.material.icons.filled.WifiPassword
+import androidx.compose.material.icons.filled.WifiProtectedSetup
+import androidx.compose.material.icons.filled.WifiTethering
+import androidx.compose.material.icons.filled.WifiTetheringError
+import androidx.compose.material.icons.filled.WifiTetheringOff
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * Device category icons - Device-specific icons and features
+ * Based on Google's Material Design Icons taxonomy
+ */
+object DeviceIcons {
+ val icons =
+ listOf(
+ ProfileIcon("access_time", Icons.Filled.AccessTime, "Access Time"),
+ ProfileIcon("access_time_filled", Icons.Filled.AccessTimeFilled, "Time Filled"),
+ ProfileIcon("ad_units", Icons.Filled.AdUnits, "Ad Units"),
+ ProfileIcon("add_alarm", Icons.Filled.AddAlarm, "Add Alarm"),
+ ProfileIcon("add_to_home_screen", Icons.Filled.AddToHomeScreen, "Add to Home"),
+ ProfileIcon("air", Icons.Filled.Air, "Air"),
+ ProfileIcon("airplane_ticket", Icons.Filled.AirplaneTicket, "Airplane Ticket"),
+ ProfileIcon("airplanemode_active", Icons.Filled.AirplanemodeActive, "Airplane Active"),
+ ProfileIcon(
+ "airplanemode_inactive",
+ Icons.Filled.AirplanemodeInactive,
+ "Airplane Inactive",
+ ),
+ ProfileIcon("aod", Icons.Filled.Aod, "Always On Display"),
+ ProfileIcon("battery_0_bar", Icons.Filled.Battery0Bar, "Battery 0"),
+ ProfileIcon("battery_1_bar", Icons.Filled.Battery1Bar, "Battery 1"),
+ ProfileIcon("battery_2_bar", Icons.Filled.Battery2Bar, "Battery 2"),
+ ProfileIcon("battery_3_bar", Icons.Filled.Battery3Bar, "Battery 3"),
+ ProfileIcon("battery_4_bar", Icons.Filled.Battery4Bar, "Battery 4"),
+ ProfileIcon("battery_5_bar", Icons.Filled.Battery5Bar, "Battery 5"),
+ ProfileIcon("battery_6_bar", Icons.Filled.Battery6Bar, "Battery 6"),
+ ProfileIcon("battery_alert", Icons.Filled.BatteryAlert, "Battery Alert"),
+ ProfileIcon("battery_charging_full", Icons.Filled.BatteryChargingFull, "Charging Full"),
+ ProfileIcon("battery_full", Icons.Filled.BatteryFull, "Battery Full"),
+ ProfileIcon("battery_saver", Icons.Filled.BatterySaver, "Battery Saver"),
+ ProfileIcon("battery_std", Icons.Filled.BatteryStd, "Battery Standard"),
+ ProfileIcon("battery_unknown", Icons.Filled.BatteryUnknown, "Battery Unknown"),
+ ProfileIcon("bloodtype", Icons.Filled.Bloodtype, "Blood Type"),
+ ProfileIcon("bluetooth", Icons.Filled.Bluetooth, "Bluetooth"),
+ ProfileIcon("bluetooth_audio", Icons.Filled.BluetoothAudio, "Bluetooth Audio"),
+ ProfileIcon("bluetooth_connected", Icons.Filled.BluetoothConnected, "Bluetooth Connected"),
+ ProfileIcon("bluetooth_disabled", Icons.Filled.BluetoothDisabled, "Bluetooth Disabled"),
+ ProfileIcon("bluetooth_drive", Icons.Filled.BluetoothDrive, "Bluetooth Drive"),
+ ProfileIcon("bluetooth_searching", Icons.Filled.BluetoothSearching, "Bluetooth Searching"),
+ ProfileIcon("brightness_auto", Icons.Filled.BrightnessAuto, "Brightness Auto"),
+ ProfileIcon("brightness_high", Icons.Filled.BrightnessHigh, "Brightness High"),
+ ProfileIcon("brightness_low", Icons.Filled.BrightnessLow, "Brightness Low"),
+ ProfileIcon("brightness_medium", Icons.Filled.BrightnessMedium, "Brightness Medium"),
+ ProfileIcon("cable", Icons.Filled.Cable, "Cable"),
+ ProfileIcon("cameraswitch", Icons.Filled.Cameraswitch, "Camera Switch"),
+ ProfileIcon("credit_score", Icons.Filled.CreditScore, "Credit Score"),
+ ProfileIcon("dark_mode", Icons.Filled.DarkMode, "Dark Mode"),
+ ProfileIcon("data_saver_off", Icons.Filled.DataSaverOff, "Data Saver Off"),
+ ProfileIcon("data_saver_on", Icons.Filled.DataSaverOn, "Data Saver On"),
+ ProfileIcon("data_usage", Icons.Filled.DataUsage, "Data Usage"),
+ ProfileIcon("dataset", Icons.Filled.Dataset, "Dataset"),
+ ProfileIcon("dataset_linked", Icons.Filled.DatasetLinked, "Dataset Linked"),
+ ProfileIcon("developer_mode", Icons.Filled.DeveloperMode, "Developer Mode"),
+ ProfileIcon("device_thermostat", Icons.Filled.DeviceThermostat, "Thermostat"),
+ ProfileIcon("devices", Icons.Filled.Devices, "Devices"),
+ ProfileIcon("devices_fold", Icons.Filled.DevicesFold, "Devices Fold"),
+ ProfileIcon("devices_other", Icons.Filled.DevicesOther, "Devices Other"),
+ ProfileIcon("discount", Icons.Filled.Discount, "Discount"),
+ ProfileIcon(
+ "do_not_disturb_on_total_silence",
+ Icons.Filled.DoNotDisturbOnTotalSilence,
+ "DND Total",
+ ),
+ ProfileIcon("dvr", Icons.Filled.Dvr, "DVR"),
+ ProfileIcon("e_mobiledata", Icons.Filled.EMobiledata, "E Mobile Data"),
+ ProfileIcon("edgesensor_high", Icons.Filled.EdgesensorHigh, "Edge Sensor High"),
+ ProfileIcon("edgesensor_low", Icons.Filled.EdgesensorLow, "Edge Sensor Low"),
+ ProfileIcon("flashlight_off", Icons.Filled.FlashlightOff, "Flashlight Off"),
+ ProfileIcon("flashlight_on", Icons.Filled.FlashlightOn, "Flashlight On"),
+ ProfileIcon("flourescent", Icons.Filled.Flourescent, "Flourescent"),
+ ProfileIcon("fluorescent", Icons.Filled.Fluorescent, "Fluorescent"),
+ ProfileIcon("fmd_bad", Icons.Filled.FmdBad, "Find My Device Bad"),
+ ProfileIcon("fmd_good", Icons.Filled.FmdGood, "Find My Device Good"),
+ ProfileIcon("g_mobiledata", Icons.Filled.GMobiledata, "G Mobile Data"),
+ ProfileIcon("gpp_bad", Icons.Filled.GppBad, "GPP Bad"),
+ ProfileIcon("gpp_good", Icons.Filled.GppGood, "GPP Good"),
+ ProfileIcon("gpp_maybe", Icons.Filled.GppMaybe, "GPP Maybe"),
+ ProfileIcon("gps_fixed", Icons.Filled.GpsFixed, "GPS Fixed"),
+ ProfileIcon("gps_not_fixed", Icons.Filled.GpsNotFixed, "GPS Not Fixed"),
+ ProfileIcon("gps_off", Icons.Filled.GpsOff, "GPS Off"),
+ ProfileIcon("graphic_eq", Icons.Filled.GraphicEq, "Graphic EQ"),
+ ProfileIcon("grid_3x3", Icons.Filled.Grid3x3, "Grid 3x3"),
+ ProfileIcon("grid_4x4", Icons.Filled.Grid4x4, "Grid 4x4"),
+ ProfileIcon("grid_goldenratio", Icons.Filled.GridGoldenratio, "Grid Golden Ratio"),
+ ProfileIcon("h_mobiledata", Icons.Filled.HMobiledata, "H Mobile Data"),
+ ProfileIcon("h_plus_mobiledata", Icons.Filled.HPlusMobiledata, "H+ Mobile Data"),
+ ProfileIcon("hdr_auto", Icons.Filled.HdrAuto, "HDR Auto"),
+ ProfileIcon("hdr_auto_select", Icons.Filled.HdrAutoSelect, "HDR Auto Select"),
+ ProfileIcon("hdr_off_select", Icons.Filled.HdrOffSelect, "HDR Off Select"),
+ ProfileIcon("hdr_on_select", Icons.Filled.HdrOnSelect, "HDR On Select"),
+ ProfileIcon("lan", Icons.Filled.Lan, "LAN"),
+ ProfileIcon("lens_blur", Icons.Filled.LensBlur, "Lens Blur"),
+ ProfileIcon("light_mode", Icons.Filled.LightMode, "Light Mode"),
+ ProfileIcon("location_disabled", Icons.Filled.LocationDisabled, "Location Disabled"),
+ ProfileIcon("location_searching", Icons.Filled.LocationSearching, "Location Searching"),
+ ProfileIcon("lte_mobiledata", Icons.Filled.LteMobiledata, "LTE"),
+ ProfileIcon("lte_plus_mobiledata", Icons.Filled.LtePlusMobiledata, "LTE+"),
+ ProfileIcon("media_bluetooth_off", Icons.Filled.MediaBluetoothOff, "Media Bluetooth Off"),
+ ProfileIcon("media_bluetooth_on", Icons.Filled.MediaBluetoothOn, "Media Bluetooth On"),
+ ProfileIcon("medication", Icons.Filled.Medication, "Medication"),
+ // ProfileIcon("medication_liquid", Icons.Filled.MedicationLiquid, "Medication Liquid"),
+ ProfileIcon("mobile_friendly", Icons.Filled.MobileFriendly, "Mobile Friendly"),
+ ProfileIcon("mobile_off", Icons.Filled.MobileOff, "Mobile Off"),
+ ProfileIcon("mobiledata_off", Icons.Filled.MobiledataOff, "Mobile Data Off"),
+ ProfileIcon("mode_night", Icons.Filled.ModeNight, "Night Mode"),
+ ProfileIcon("mode_standby", Icons.Filled.ModeStandby, "Standby Mode"),
+ ProfileIcon("monitor_heart", Icons.Filled.MonitorHeart, "Monitor Heart"),
+ ProfileIcon("monitor_weight", Icons.Filled.MonitorWeight, "Monitor Weight"),
+ ProfileIcon("nearby_error", Icons.Filled.NearbyError, "Nearby Error"),
+ ProfileIcon("nearby_off", Icons.Filled.NearbyOff, "Nearby Off"),
+ ProfileIcon("network_cell", Icons.Filled.NetworkCell, "Network Cell"),
+ ProfileIcon("network_wifi", Icons.Filled.NetworkWifi, "Network WiFi"),
+ ProfileIcon("network_wifi_1_bar", Icons.Filled.NetworkWifi1Bar, "WiFi 1 Bar"),
+ ProfileIcon("network_wifi_2_bar", Icons.Filled.NetworkWifi2Bar, "WiFi 2 Bar"),
+ ProfileIcon("network_wifi_3_bar", Icons.Filled.NetworkWifi3Bar, "WiFi 3 Bar"),
+ ProfileIcon("nfc", Icons.Filled.Nfc, "NFC"),
+ ProfileIcon("nightlight", Icons.Filled.Nightlight, "Nightlight"),
+ ProfileIcon("note_alt", Icons.Filled.NoteAlt, "Note Alt"),
+ ProfileIcon("password", Icons.Filled.Password, "Password"),
+ ProfileIcon("pattern", Icons.Filled.Pattern, "Pattern"),
+ ProfileIcon("phishing", Icons.Filled.Phishing, "Phishing"),
+ ProfileIcon("pin", Icons.Filled.Pin, "PIN"),
+ ProfileIcon("play_lesson", Icons.Filled.PlayLesson, "Play Lesson"),
+ ProfileIcon("price_change", Icons.Filled.PriceChange, "Price Change"),
+ ProfileIcon("price_check", Icons.Filled.PriceCheck, "Price Check"),
+ ProfileIcon("punch_clock", Icons.Filled.PunchClock, "Punch Clock"),
+ ProfileIcon("quiz", Icons.Filled.Quiz, "Quiz"),
+ ProfileIcon("r_mobiledata", Icons.Filled.RMobiledata, "R Mobile Data"),
+ ProfileIcon("radar", Icons.Filled.Radar, "Radar"),
+ ProfileIcon("remember_me", Icons.Filled.RememberMe, "Remember Me"),
+ ProfileIcon("reset_tv", Icons.Filled.ResetTv, "Reset TV"),
+ ProfileIcon("restart_alt", Icons.Filled.RestartAlt, "Restart"),
+ ProfileIcon("reviews", Icons.Filled.Reviews, "Reviews"),
+ ProfileIcon("rsvp", Icons.Filled.Rsvp, "RSVP"),
+ ProfileIcon("screen_lock_landscape", Icons.Filled.ScreenLockLandscape, "Lock Landscape"),
+ ProfileIcon("screen_lock_portrait", Icons.Filled.ScreenLockPortrait, "Lock Portrait"),
+ ProfileIcon("screen_lock_rotation", Icons.Filled.ScreenLockRotation, "Lock Rotation"),
+ ProfileIcon("screen_rotation", Icons.Filled.ScreenRotation, "Screen Rotation"),
+ ProfileIcon("screen_search_desktop", Icons.Filled.ScreenSearchDesktop, "Screen Search"),
+ ProfileIcon("screenshot", Icons.Filled.Screenshot, "Screenshot"),
+ ProfileIcon("screenshot_monitor", Icons.Filled.ScreenshotMonitor, "Screenshot Monitor"),
+ ProfileIcon("sd_storage", Icons.Filled.SdStorage, "SD Storage"),
+ ProfileIcon("security_update", Icons.Filled.SecurityUpdate, "Security Update"),
+ ProfileIcon("security_update_good", Icons.Filled.SecurityUpdateGood, "Security Good"),
+ ProfileIcon(
+ "security_update_warning",
+ Icons.Filled.SecurityUpdateWarning,
+ "Security Warning",
+ ),
+ ProfileIcon("sell", Icons.Filled.Sell, "Sell"),
+ ProfileIcon("send_to_mobile", Icons.Filled.SendToMobile, "Send to Mobile"),
+ ProfileIcon("settings_suggest", Icons.Filled.SettingsSuggest, "Settings Suggest"),
+ ProfileIcon("settings_system_daydream", Icons.Filled.SettingsSystemDaydream, "Daydream"),
+ ProfileIcon("share_location", Icons.Filled.ShareLocation, "Share Location"),
+ ProfileIcon("shortcut", Icons.Filled.Shortcut, "Shortcut"),
+ ProfileIcon("signal_cellular_0_bar", Icons.Filled.SignalCellular0Bar, "Signal 0"),
+ ProfileIcon("signal_cellular_4_bar", Icons.Filled.SignalCellular4Bar, "Signal 4"),
+ ProfileIcon("signal_cellular_alt", Icons.Filled.SignalCellularAlt, "Signal Alt"),
+ ProfileIcon(
+ "signal_cellular_alt_1_bar",
+ Icons.Filled.SignalCellularAlt1Bar,
+ "Signal Alt 1",
+ ),
+ ProfileIcon(
+ "signal_cellular_alt_2_bar",
+ Icons.Filled.SignalCellularAlt2Bar,
+ "Signal Alt 2",
+ ),
+ ProfileIcon(
+ "signal_cellular_connected_no_internet_0_bar",
+ Icons.Filled.SignalCellularConnectedNoInternet0Bar,
+ "No Internet",
+ ),
+ ProfileIcon(
+ "signal_cellular_connected_no_internet_4_bar",
+ Icons.Filled.SignalCellularConnectedNoInternet4Bar,
+ "No Internet 4",
+ ),
+ ProfileIcon("signal_cellular_no_sim", Icons.Filled.SignalCellularNoSim, "No SIM"),
+ ProfileIcon("signal_cellular_nodata", Icons.Filled.SignalCellularNodata, "No Data"),
+ ProfileIcon("signal_cellular_null", Icons.Filled.SignalCellularNull, "Signal Null"),
+ ProfileIcon("signal_cellular_off", Icons.Filled.SignalCellularOff, "Signal Off"),
+ ProfileIcon("signal_wifi_0_bar", Icons.Filled.SignalWifi0Bar, "WiFi 0"),
+ ProfileIcon("signal_wifi_4_bar", Icons.Filled.SignalWifi4Bar, "WiFi 4"),
+ ProfileIcon("signal_wifi_4_bar_lock", Icons.Filled.SignalWifi4BarLock, "WiFi Lock"),
+ ProfileIcon("signal_wifi_bad", Icons.Filled.SignalWifiBad, "WiFi Bad"),
+ ProfileIcon(
+ "signal_wifi_connected_no_internet_4",
+ Icons.Filled.SignalWifiConnectedNoInternet4,
+ "WiFi No Internet",
+ ),
+ ProfileIcon("signal_wifi_off", Icons.Filled.SignalWifiOff, "WiFi Off"),
+ ProfileIcon(
+ "signal_wifi_statusbar_4_bar",
+ Icons.Filled.SignalWifiStatusbar4Bar,
+ "WiFi Status 4",
+ ),
+ ProfileIcon(
+ "signal_wifi_statusbar_connected_no_internet_4",
+ Icons.Filled.SignalWifiStatusbarConnectedNoInternet4,
+ "WiFi Status No Internet",
+ ),
+ ProfileIcon(
+ "signal_wifi_statusbar_null",
+ Icons.Filled.SignalWifiStatusbarNull,
+ "WiFi Status Null",
+ ),
+ ProfileIcon("sim_card", Icons.Filled.SimCard, "SIM Card"),
+ ProfileIcon("sim_card_alert", Icons.Filled.SimCardAlert, "SIM Alert"),
+ ProfileIcon("sim_card_download", Icons.Filled.SimCardDownload, "SIM Download"),
+ ProfileIcon("smart_display", Icons.Filled.SmartDisplay, "Smart Display"),
+ ProfileIcon("smart_screen", Icons.Filled.SmartScreen, "Smart Screen"),
+ ProfileIcon("smart_toy", Icons.Filled.SmartToy, "Smart Toy"),
+ ProfileIcon("splitscreen", Icons.Filled.Splitscreen, "Split Screen"),
+ ProfileIcon("sports_score", Icons.Filled.SportsScore, "Sports Score"),
+ ProfileIcon("ssid_chart", Icons.Filled.SsidChart, "SSID Chart"),
+ ProfileIcon("storage", Icons.Filled.Storage, "Storage"),
+ ProfileIcon("storm", Icons.Filled.Storm, "Storm"),
+ ProfileIcon("summarize", Icons.Filled.Summarize, "Summarize"),
+ ProfileIcon("system_security_update", Icons.Filled.SystemSecurityUpdate, "System Security"),
+ ProfileIcon(
+ "system_security_update_good",
+ Icons.Filled.SystemSecurityUpdateGood,
+ "System Security Good",
+ ),
+ ProfileIcon(
+ "system_security_update_warning",
+ Icons.Filled.SystemSecurityUpdateWarning,
+ "System Warning",
+ ),
+ ProfileIcon("task", Icons.Filled.Task, "Task"),
+ ProfileIcon("thermostat", Icons.Filled.Thermostat, "Thermostat"),
+ ProfileIcon("thermostat_auto", Icons.Filled.ThermostatAuto, "Thermostat Auto"),
+ ProfileIcon("timer", Icons.Filled.Timer, "Timer"),
+ ProfileIcon("timer_10", Icons.Filled.Timer10, "Timer 10"),
+ ProfileIcon("timer_10_select", Icons.Filled.Timer10Select, "Timer 10 Select"),
+ ProfileIcon("timer_3", Icons.Filled.Timer3, "Timer 3"),
+ ProfileIcon("timer_3_select", Icons.Filled.Timer3Select, "Timer 3 Select"),
+ ProfileIcon("timer_off", Icons.Filled.TimerOff, "Timer Off"),
+ ProfileIcon("tungsten", Icons.Filled.Tungsten, "Tungsten"),
+ ProfileIcon("usb", Icons.Filled.Usb, "USB"),
+ ProfileIcon("usb_off", Icons.Filled.UsbOff, "USB Off"),
+ ProfileIcon("wallpaper", Icons.Filled.Wallpaper, "Wallpaper"),
+ ProfileIcon("water", Icons.Filled.Water, "Water"),
+ ProfileIcon("widgets", Icons.Filled.Widgets, "Widgets"),
+ ProfileIcon("wifi", Icons.Filled.Wifi, "WiFi"),
+ ProfileIcon("wifi_1_bar", Icons.Filled.Wifi1Bar, "WiFi 1 Bar"),
+ ProfileIcon("wifi_2_bar", Icons.Filled.Wifi2Bar, "WiFi 2 Bar"),
+ ProfileIcon("wifi_calling", Icons.Filled.WifiCalling, "WiFi Calling"),
+ ProfileIcon("wifi_calling_3", Icons.Filled.WifiCalling3, "WiFi Calling 3"),
+ ProfileIcon("wifi_channel", Icons.Filled.WifiChannel, "WiFi Channel"),
+ ProfileIcon("wifi_find", Icons.Filled.WifiFind, "WiFi Find"),
+ ProfileIcon("wifi_lock", Icons.Filled.WifiLock, "WiFi Lock"),
+ ProfileIcon("wifi_off", Icons.Filled.WifiOff, "WiFi Off"),
+ ProfileIcon("wifi_password", Icons.Filled.WifiPassword, "WiFi Password"),
+ ProfileIcon("wifi_protected_setup", Icons.Filled.WifiProtectedSetup, "WiFi Setup"),
+ ProfileIcon("wifi_tethering", Icons.Filled.WifiTethering, "WiFi Tethering"),
+ ProfileIcon("wifi_tethering_error", Icons.Filled.WifiTetheringError, "WiFi Error"),
+ ProfileIcon("wifi_tethering_off", Icons.Filled.WifiTetheringOff, "WiFi Tethering Off"),
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/EditorIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/EditorIcons.kt
new file mode 100644
index 0000000..0634d4d
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/EditorIcons.kt
@@ -0,0 +1,272 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
+import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
+import androidx.compose.material.icons.automirrored.filled.Notes
+import androidx.compose.material.icons.automirrored.filled.ShortText
+import androidx.compose.material.icons.automirrored.filled.ShowChart
+import androidx.compose.material.icons.automirrored.filled.WrapText
+import androidx.compose.material.icons.filled.AddChart
+import androidx.compose.material.icons.filled.AddComment
+import androidx.compose.material.icons.filled.AlignHorizontalCenter
+import androidx.compose.material.icons.filled.AlignHorizontalLeft
+import androidx.compose.material.icons.filled.AlignHorizontalRight
+import androidx.compose.material.icons.filled.AlignVerticalBottom
+import androidx.compose.material.icons.filled.AlignVerticalCenter
+import androidx.compose.material.icons.filled.AlignVerticalTop
+import androidx.compose.material.icons.filled.AreaChart
+import androidx.compose.material.icons.filled.AttachEmail
+import androidx.compose.material.icons.filled.AttachFile
+import androidx.compose.material.icons.filled.AttachMoney
+import androidx.compose.material.icons.filled.AutoGraph
+import androidx.compose.material.icons.filled.BarChart
+import androidx.compose.material.icons.filled.BorderAll
+import androidx.compose.material.icons.filled.BorderBottom
+import androidx.compose.material.icons.filled.BorderClear
+import androidx.compose.material.icons.filled.BorderColor
+import androidx.compose.material.icons.filled.BorderHorizontal
+import androidx.compose.material.icons.filled.BorderInner
+import androidx.compose.material.icons.filled.BorderLeft
+import androidx.compose.material.icons.filled.BorderOuter
+import androidx.compose.material.icons.filled.BorderRight
+import androidx.compose.material.icons.filled.BorderStyle
+import androidx.compose.material.icons.filled.BorderTop
+import androidx.compose.material.icons.filled.BorderVertical
+import androidx.compose.material.icons.filled.BubbleChart
+import androidx.compose.material.icons.filled.CandlestickChart
+import androidx.compose.material.icons.filled.Checklist
+import androidx.compose.material.icons.filled.ChecklistRtl
+import androidx.compose.material.icons.filled.DataArray
+import androidx.compose.material.icons.filled.DataObject
+import androidx.compose.material.icons.filled.DragHandle
+import androidx.compose.material.icons.filled.Draw
+import androidx.compose.material.icons.filled.EditNote
+import androidx.compose.material.icons.filled.FormatAlignCenter
+import androidx.compose.material.icons.filled.FormatAlignJustify
+import androidx.compose.material.icons.filled.FormatAlignLeft
+import androidx.compose.material.icons.filled.FormatAlignRight
+import androidx.compose.material.icons.filled.FormatBold
+import androidx.compose.material.icons.filled.FormatClear
+import androidx.compose.material.icons.filled.FormatColorFill
+import androidx.compose.material.icons.filled.FormatColorReset
+import androidx.compose.material.icons.filled.FormatColorText
+import androidx.compose.material.icons.filled.FormatIndentDecrease
+import androidx.compose.material.icons.filled.FormatIndentIncrease
+import androidx.compose.material.icons.filled.FormatItalic
+import androidx.compose.material.icons.filled.FormatLineSpacing
+import androidx.compose.material.icons.filled.FormatListNumbered
+import androidx.compose.material.icons.filled.FormatListNumberedRtl
+import androidx.compose.material.icons.filled.FormatPaint
+import androidx.compose.material.icons.filled.FormatQuote
+import androidx.compose.material.icons.filled.FormatShapes
+import androidx.compose.material.icons.filled.FormatSize
+import androidx.compose.material.icons.filled.FormatStrikethrough
+import androidx.compose.material.icons.filled.FormatTextdirectionLToR
+import androidx.compose.material.icons.filled.FormatTextdirectionRToL
+import androidx.compose.material.icons.filled.FormatUnderlined
+import androidx.compose.material.icons.filled.Functions
+import androidx.compose.material.icons.filled.Height
+import androidx.compose.material.icons.filled.Hexagon
+import androidx.compose.material.icons.filled.Highlight
+import androidx.compose.material.icons.filled.HorizontalDistribute
+import androidx.compose.material.icons.filled.HorizontalRule
+import androidx.compose.material.icons.filled.InsertChart
+import androidx.compose.material.icons.filled.InsertChartOutlined
+import androidx.compose.material.icons.filled.InsertComment
+import androidx.compose.material.icons.filled.InsertEmoticon
+import androidx.compose.material.icons.filled.InsertInvitation
+import androidx.compose.material.icons.filled.InsertLink
+import androidx.compose.material.icons.filled.InsertPageBreak
+import androidx.compose.material.icons.filled.InsertPhoto
+import androidx.compose.material.icons.filled.LineAxis
+import androidx.compose.material.icons.filled.LineWeight
+import androidx.compose.material.icons.filled.LinearScale
+import androidx.compose.material.icons.filled.Margin
+import androidx.compose.material.icons.filled.MergeType
+import androidx.compose.material.icons.filled.Mode
+import androidx.compose.material.icons.filled.ModeComment
+import androidx.compose.material.icons.filled.ModeEdit
+import androidx.compose.material.icons.filled.ModeEditOutline
+import androidx.compose.material.icons.filled.MonetizationOn
+import androidx.compose.material.icons.filled.MoneyOff
+import androidx.compose.material.icons.filled.MoneyOffCsred
+import androidx.compose.material.icons.filled.MoveDown
+import androidx.compose.material.icons.filled.MoveUp
+import androidx.compose.material.icons.filled.MultilineChart
+import androidx.compose.material.icons.filled.Numbers
+import androidx.compose.material.icons.filled.Padding
+import androidx.compose.material.icons.filled.Pentagon
+import androidx.compose.material.icons.filled.PieChart
+import androidx.compose.material.icons.filled.PieChartOutline
+import androidx.compose.material.icons.filled.Polyline
+import androidx.compose.material.icons.filled.PostAdd
+import androidx.compose.material.icons.filled.Publish
+import androidx.compose.material.icons.filled.QueryStats
+import androidx.compose.material.icons.filled.Rectangle
+import androidx.compose.material.icons.filled.ScatterPlot
+import androidx.compose.material.icons.filled.Schema
+import androidx.compose.material.icons.filled.Score
+import androidx.compose.material.icons.filled.SpaceBar
+import androidx.compose.material.icons.filled.Square
+import androidx.compose.material.icons.filled.StackedLineChart
+import androidx.compose.material.icons.filled.StrikethroughS
+import androidx.compose.material.icons.filled.Subscript
+import androidx.compose.material.icons.filled.Superscript
+import androidx.compose.material.icons.filled.TableChart
+import androidx.compose.material.icons.filled.TableRows
+import androidx.compose.material.icons.filled.TextDecrease
+import androidx.compose.material.icons.filled.TextFields
+import androidx.compose.material.icons.filled.TextIncrease
+import androidx.compose.material.icons.filled.Title
+import androidx.compose.material.icons.filled.VerticalAlignBottom
+import androidx.compose.material.icons.filled.VerticalAlignCenter
+import androidx.compose.material.icons.filled.VerticalAlignTop
+import androidx.compose.material.icons.filled.VerticalDistribute
+import androidx.compose.material.icons.filled.WaterfallChart
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * Editor category icons - Text and content editing
+ * Based on Google's Material Design Icons taxonomy
+ */
+object EditorIcons {
+ val icons =
+ listOf(
+ ProfileIcon("add_chart", Icons.Filled.AddChart, "Add Chart"),
+ ProfileIcon("add_comment", Icons.Filled.AddComment, "Add Comment"),
+ ProfileIcon("align_horizontal_center", Icons.Filled.AlignHorizontalCenter, "Align Center"),
+ ProfileIcon("align_horizontal_left", Icons.Filled.AlignHorizontalLeft, "Align Left"),
+ ProfileIcon("align_horizontal_right", Icons.Filled.AlignHorizontalRight, "Align Right"),
+ ProfileIcon("align_vertical_bottom", Icons.Filled.AlignVerticalBottom, "Align Bottom"),
+ ProfileIcon("align_vertical_center", Icons.Filled.AlignVerticalCenter, "Align Middle"),
+ ProfileIcon("align_vertical_top", Icons.Filled.AlignVerticalTop, "Align Top"),
+ ProfileIcon("area_chart", Icons.Filled.AreaChart, "Area Chart"),
+ ProfileIcon("attach_email", Icons.Filled.AttachEmail, "Attach Email"),
+ ProfileIcon("attach_file", Icons.Filled.AttachFile, "Attach File"),
+ ProfileIcon("attach_money", Icons.Filled.AttachMoney, "Attach Money"),
+ ProfileIcon("auto_graph", Icons.Filled.AutoGraph, "Auto Graph"),
+ ProfileIcon("bar_chart", Icons.Filled.BarChart, "Bar Chart"),
+ ProfileIcon("border_all", Icons.Filled.BorderAll, "Border All"),
+ ProfileIcon("border_bottom", Icons.Filled.BorderBottom, "Border Bottom"),
+ ProfileIcon("border_clear", Icons.Filled.BorderClear, "Border Clear"),
+ ProfileIcon("border_color", Icons.Filled.BorderColor, "Border Color"),
+ ProfileIcon("border_horizontal", Icons.Filled.BorderHorizontal, "Border Horizontal"),
+ ProfileIcon("border_inner", Icons.Filled.BorderInner, "Border Inner"),
+ ProfileIcon("border_left", Icons.Filled.BorderLeft, "Border Left"),
+ ProfileIcon("border_outer", Icons.Filled.BorderOuter, "Border Outer"),
+ ProfileIcon("border_right", Icons.Filled.BorderRight, "Border Right"),
+ ProfileIcon("border_style", Icons.Filled.BorderStyle, "Border Style"),
+ ProfileIcon("border_top", Icons.Filled.BorderTop, "Border Top"),
+ ProfileIcon("border_vertical", Icons.Filled.BorderVertical, "Border Vertical"),
+ ProfileIcon("bubble_chart", Icons.Filled.BubbleChart, "Bubble Chart"),
+ ProfileIcon("candlestick_chart", Icons.Filled.CandlestickChart, "Candlestick Chart"),
+ ProfileIcon("checklist", Icons.Filled.Checklist, "Checklist"),
+ ProfileIcon("checklist_rtl", Icons.Filled.ChecklistRtl, "Checklist RTL"),
+ ProfileIcon("data_array", Icons.Filled.DataArray, "Data Array"),
+ ProfileIcon("data_object", Icons.Filled.DataObject, "Data Object"),
+ ProfileIcon("drag_handle", Icons.Filled.DragHandle, "Drag Handle"),
+ ProfileIcon("draw", Icons.Filled.Draw, "Draw"),
+ ProfileIcon("edit_note", Icons.Filled.EditNote, "Edit Note"),
+ ProfileIcon("format_align_center", Icons.Filled.FormatAlignCenter, "Format Center"),
+ ProfileIcon("format_align_justify", Icons.Filled.FormatAlignJustify, "Format Justify"),
+ ProfileIcon("format_align_left", Icons.Filled.FormatAlignLeft, "Format Left"),
+ ProfileIcon("format_align_right", Icons.Filled.FormatAlignRight, "Format Right"),
+ ProfileIcon("format_bold", Icons.Filled.FormatBold, "Bold"),
+ ProfileIcon("format_clear", Icons.Filled.FormatClear, "Format Clear"),
+ ProfileIcon("format_color_fill", Icons.Filled.FormatColorFill, "Color Fill"),
+ ProfileIcon("format_color_reset", Icons.Filled.FormatColorReset, "Color Reset"),
+ ProfileIcon("format_color_text", Icons.Filled.FormatColorText, "Color Text"),
+ ProfileIcon("format_indent_decrease", Icons.Filled.FormatIndentDecrease, "Indent Less"),
+ ProfileIcon("format_indent_increase", Icons.Filled.FormatIndentIncrease, "Indent More"),
+ ProfileIcon("format_italic", Icons.Filled.FormatItalic, "Italic"),
+ ProfileIcon("format_line_spacing", Icons.Filled.FormatLineSpacing, "Line Spacing"),
+ ProfileIcon(
+ "format_list_bulleted",
+ Icons.AutoMirrored.Filled.FormatListBulleted,
+ "Bulleted List",
+ ),
+ ProfileIcon("format_list_numbered", Icons.Filled.FormatListNumbered, "Numbered List"),
+ ProfileIcon("format_list_numbered_rtl", Icons.Filled.FormatListNumberedRtl, "List RTL"),
+ ProfileIcon("format_paint", Icons.Filled.FormatPaint, "Format Paint"),
+ ProfileIcon("format_quote", Icons.Filled.FormatQuote, "Quote"),
+ ProfileIcon("format_shapes", Icons.Filled.FormatShapes, "Format Shapes"),
+ ProfileIcon("format_size", Icons.Filled.FormatSize, "Format Size"),
+ ProfileIcon("format_strikethrough", Icons.Filled.FormatStrikethrough, "Strikethrough"),
+ ProfileIcon("format_text_direction_l_to_r", Icons.Filled.FormatTextdirectionLToR, "LTR"),
+ ProfileIcon("format_text_direction_r_to_l", Icons.Filled.FormatTextdirectionRToL, "RTL"),
+ ProfileIcon("format_underlined", Icons.Filled.FormatUnderlined, "Underlined"),
+ ProfileIcon("functions", Icons.Filled.Functions, "Functions"),
+ ProfileIcon("height", Icons.Filled.Height, "Height"),
+ ProfileIcon("hexagon", Icons.Filled.Hexagon, "Hexagon"),
+ ProfileIcon("highlight", Icons.Filled.Highlight, "Highlight"),
+ ProfileIcon(
+ "horizontal_distribute",
+ Icons.Filled.HorizontalDistribute,
+ "Horizontal Distribute",
+ ),
+ ProfileIcon("horizontal_rule", Icons.Filled.HorizontalRule, "Horizontal Rule"),
+ ProfileIcon("insert_chart", Icons.Filled.InsertChart, "Insert Chart"),
+ ProfileIcon(
+ "insert_chart_outlined",
+ Icons.Filled.InsertChartOutlined,
+ "Insert Chart Outlined",
+ ),
+ ProfileIcon("insert_comment", Icons.Filled.InsertComment, "Insert Comment"),
+ ProfileIcon("insert_drive_file", Icons.AutoMirrored.Filled.InsertDriveFile, "Insert File"),
+ ProfileIcon("insert_emoticon", Icons.Filled.InsertEmoticon, "Insert Emoticon"),
+ ProfileIcon("insert_invitation", Icons.Filled.InsertInvitation, "Insert Invitation"),
+ ProfileIcon("insert_link", Icons.Filled.InsertLink, "Insert Link"),
+ ProfileIcon("insert_page_break", Icons.Filled.InsertPageBreak, "Page Break"),
+ ProfileIcon("insert_photo", Icons.Filled.InsertPhoto, "Insert Photo"),
+ ProfileIcon("line_axis", Icons.Filled.LineAxis, "Line Axis"),
+ ProfileIcon("line_weight", Icons.Filled.LineWeight, "Line Weight"),
+ ProfileIcon("linear_scale", Icons.Filled.LinearScale, "Linear Scale"),
+ ProfileIcon("margin", Icons.Filled.Margin, "Margin"),
+ ProfileIcon("merge_type", Icons.Filled.MergeType, "Merge Type"),
+ ProfileIcon("mode", Icons.Filled.Mode, "Mode"),
+ ProfileIcon("mode_comment", Icons.Filled.ModeComment, "Mode Comment"),
+ ProfileIcon("mode_edit", Icons.Filled.ModeEdit, "Mode Edit"),
+ ProfileIcon("mode_edit_outline", Icons.Filled.ModeEditOutline, "Mode Edit Outline"),
+ ProfileIcon("monetization_on", Icons.Filled.MonetizationOn, "Monetization On"),
+ ProfileIcon("money_off", Icons.Filled.MoneyOff, "Money Off"),
+ ProfileIcon("money_off_csred", Icons.Filled.MoneyOffCsred, "Money Off CS"),
+ ProfileIcon("move_down", Icons.Filled.MoveDown, "Move Down"),
+ ProfileIcon("move_up", Icons.Filled.MoveUp, "Move Up"),
+ ProfileIcon("multiline_chart", Icons.Filled.MultilineChart, "Multiline Chart"),
+ ProfileIcon("notes", Icons.AutoMirrored.Filled.Notes, "Notes"),
+ ProfileIcon("numbers", Icons.Filled.Numbers, "Numbers"),
+ ProfileIcon("padding", Icons.Filled.Padding, "Padding"),
+ ProfileIcon("pentagon", Icons.Filled.Pentagon, "Pentagon"),
+ ProfileIcon("pie_chart", Icons.Filled.PieChart, "Pie Chart"),
+ ProfileIcon("pie_chart_outline", Icons.Filled.PieChartOutline, "Pie Chart Outline"),
+ ProfileIcon("polyline", Icons.Filled.Polyline, "Polyline"),
+ ProfileIcon("post_add", Icons.Filled.PostAdd, "Post Add"),
+ ProfileIcon("publish", Icons.Filled.Publish, "Publish"),
+ ProfileIcon("query_stats", Icons.Filled.QueryStats, "Query Stats"),
+ ProfileIcon("rectangle", Icons.Filled.Rectangle, "Rectangle"),
+ ProfileIcon("scatter_plot", Icons.Filled.ScatterPlot, "Scatter Plot"),
+ ProfileIcon("schema", Icons.Filled.Schema, "Schema"),
+ ProfileIcon("score", Icons.Filled.Score, "Score"),
+ ProfileIcon("short_text", Icons.AutoMirrored.Filled.ShortText, "Short Text"),
+ ProfileIcon("show_chart", Icons.AutoMirrored.Filled.ShowChart, "Show Chart"),
+ ProfileIcon("space_bar", Icons.Filled.SpaceBar, "Space Bar"),
+ ProfileIcon("square", Icons.Filled.Square, "Square"),
+ ProfileIcon("stacked_line_chart", Icons.Filled.StackedLineChart, "Stacked Line Chart"),
+ ProfileIcon("strikethrough_s", Icons.Filled.StrikethroughS, "Strikethrough S"),
+ ProfileIcon("subscript", Icons.Filled.Subscript, "Subscript"),
+ ProfileIcon("superscript", Icons.Filled.Superscript, "Superscript"),
+ ProfileIcon("table_chart", Icons.Filled.TableChart, "Table Chart"),
+ ProfileIcon("table_rows", Icons.Filled.TableRows, "Table Rows"),
+ ProfileIcon("text_decrease", Icons.Filled.TextDecrease, "Text Decrease"),
+ ProfileIcon("text_fields", Icons.Filled.TextFields, "Text Fields"),
+ ProfileIcon("text_increase", Icons.Filled.TextIncrease, "Text Increase"),
+ ProfileIcon("title", Icons.Filled.Title, "Title"),
+ ProfileIcon("vertical_align_bottom", Icons.Filled.VerticalAlignBottom, "Vertical Bottom"),
+ ProfileIcon("vertical_align_center", Icons.Filled.VerticalAlignCenter, "Vertical Center"),
+ ProfileIcon("vertical_align_top", Icons.Filled.VerticalAlignTop, "Vertical Top"),
+ ProfileIcon("vertical_distribute", Icons.Filled.VerticalDistribute, "Vertical Distribute"),
+ ProfileIcon("waterfall_chart", Icons.Filled.WaterfallChart, "Waterfall Chart"),
+ ProfileIcon("wrap_text", Icons.AutoMirrored.Filled.WrapText, "Wrap Text"),
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/FileIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/FileIcons.kt
new file mode 100644
index 0000000..df842a5
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/FileIcons.kt
@@ -0,0 +1,112 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Approval
+import androidx.compose.material.icons.filled.AttachEmail
+import androidx.compose.material.icons.filled.Attachment
+import androidx.compose.material.icons.filled.Cloud
+import androidx.compose.material.icons.filled.CloudCircle
+import androidx.compose.material.icons.filled.CloudDone
+import androidx.compose.material.icons.filled.CloudDownload
+import androidx.compose.material.icons.filled.CloudOff
+import androidx.compose.material.icons.filled.CloudQueue
+import androidx.compose.material.icons.filled.CloudSync
+import androidx.compose.material.icons.filled.CloudUpload
+import androidx.compose.material.icons.filled.CreateNewFolder
+import androidx.compose.material.icons.filled.Difference
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.DownloadDone
+import androidx.compose.material.icons.filled.DownloadForOffline
+import androidx.compose.material.icons.filled.Downloading
+import androidx.compose.material.icons.filled.DriveFileMove
+import androidx.compose.material.icons.filled.DriveFileMoveRtl
+import androidx.compose.material.icons.filled.DriveFileRenameOutline
+import androidx.compose.material.icons.filled.DriveFolderUpload
+import androidx.compose.material.icons.filled.FileCopy
+import androidx.compose.material.icons.filled.FileDownload
+import androidx.compose.material.icons.filled.FileDownloadDone
+import androidx.compose.material.icons.filled.FileDownloadOff
+import androidx.compose.material.icons.filled.FileOpen
+import androidx.compose.material.icons.filled.FilePresent
+import androidx.compose.material.icons.filled.FileUpload
+import androidx.compose.material.icons.filled.Folder
+import androidx.compose.material.icons.filled.FolderCopy
+import androidx.compose.material.icons.filled.FolderDelete
+import androidx.compose.material.icons.filled.FolderOff
+import androidx.compose.material.icons.filled.FolderOpen
+import androidx.compose.material.icons.filled.FolderShared
+import androidx.compose.material.icons.filled.FolderSpecial
+import androidx.compose.material.icons.filled.FolderZip
+import androidx.compose.material.icons.filled.FormatOverline
+import androidx.compose.material.icons.filled.GridView
+import androidx.compose.material.icons.filled.Javascript
+import androidx.compose.material.icons.filled.Newspaper
+import androidx.compose.material.icons.filled.RequestQuote
+import androidx.compose.material.icons.filled.RuleFolder
+import androidx.compose.material.icons.filled.SnippetFolder
+import androidx.compose.material.icons.filled.Source
+import androidx.compose.material.icons.filled.TextSnippet
+import androidx.compose.material.icons.filled.Topic
+import androidx.compose.material.icons.filled.Upload
+import androidx.compose.material.icons.filled.UploadFile
+import androidx.compose.material.icons.filled.Workspaces
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * File category icons - File types and operations
+ * Based on Google's Material Design Icons taxonomy
+ */
+object FileIcons {
+ val icons =
+ listOf(
+ ProfileIcon("approval", Icons.Filled.Approval, "Approval"),
+ ProfileIcon("attach_email", Icons.Filled.AttachEmail, "Attach Email"),
+ ProfileIcon("attachment", Icons.Filled.Attachment, "Attachment"),
+ ProfileIcon("cloud", Icons.Filled.Cloud, "Cloud"),
+ ProfileIcon("cloud_circle", Icons.Filled.CloudCircle, "Cloud Circle"),
+ ProfileIcon("cloud_done", Icons.Filled.CloudDone, "Cloud Done"),
+ ProfileIcon("cloud_download", Icons.Filled.CloudDownload, "Cloud Download"),
+ ProfileIcon("cloud_off", Icons.Filled.CloudOff, "Cloud Off"),
+ ProfileIcon("cloud_queue", Icons.Filled.CloudQueue, "Cloud Queue"),
+ ProfileIcon("cloud_sync", Icons.Filled.CloudSync, "Cloud Sync"),
+ ProfileIcon("cloud_upload", Icons.Filled.CloudUpload, "Cloud Upload"),
+ ProfileIcon("create_new_folder", Icons.Filled.CreateNewFolder, "New Folder"),
+ ProfileIcon("difference", Icons.Filled.Difference, "Difference"),
+ ProfileIcon("download", Icons.Filled.Download, "Download"),
+ ProfileIcon("download_done", Icons.Filled.DownloadDone, "Download Done"),
+ ProfileIcon("download_for_offline", Icons.Filled.DownloadForOffline, "Download Offline"),
+ ProfileIcon("downloading", Icons.Filled.Downloading, "Downloading"),
+ ProfileIcon("drive_file_move", Icons.Filled.DriveFileMove, "File Move"),
+ ProfileIcon("drive_file_move_rtl", Icons.Filled.DriveFileMoveRtl, "File Move RTL"),
+ ProfileIcon("drive_file_rename_outline", Icons.Filled.DriveFileRenameOutline, "Rename"),
+ ProfileIcon("drive_folder_upload", Icons.Filled.DriveFolderUpload, "Folder Upload"),
+ ProfileIcon("file_copy", Icons.Filled.FileCopy, "File Copy"),
+ ProfileIcon("file_download", Icons.Filled.FileDownload, "File Download"),
+ ProfileIcon("file_download_done", Icons.Filled.FileDownloadDone, "Download Done"),
+ ProfileIcon("file_download_off", Icons.Filled.FileDownloadOff, "Download Off"),
+ ProfileIcon("file_open", Icons.Filled.FileOpen, "File Open"),
+ ProfileIcon("file_present", Icons.Filled.FilePresent, "File Present"),
+ ProfileIcon("file_upload", Icons.Filled.FileUpload, "File Upload"),
+ ProfileIcon("folder", Icons.Filled.Folder, "Folder"),
+ ProfileIcon("folder_copy", Icons.Filled.FolderCopy, "Folder Copy"),
+ ProfileIcon("folder_delete", Icons.Filled.FolderDelete, "Folder Delete"),
+ ProfileIcon("folder_off", Icons.Filled.FolderOff, "Folder Off"),
+ ProfileIcon("folder_open", Icons.Filled.FolderOpen, "Folder Open"),
+ ProfileIcon("folder_shared", Icons.Filled.FolderShared, "Folder Shared"),
+ ProfileIcon("folder_special", Icons.Filled.FolderSpecial, "Folder Special"),
+ ProfileIcon("folder_zip", Icons.Filled.FolderZip, "Folder Zip"),
+ ProfileIcon("format_overline", Icons.Filled.FormatOverline, "Format Overline"),
+ ProfileIcon("grid_view", Icons.Filled.GridView, "Grid View"),
+ ProfileIcon("javascript", Icons.Filled.Javascript, "JavaScript"),
+ ProfileIcon("newspaper", Icons.Filled.Newspaper, "Newspaper"),
+ ProfileIcon("request_quote", Icons.Filled.RequestQuote, "Request Quote"),
+ ProfileIcon("rule_folder", Icons.Filled.RuleFolder, "Rule Folder"),
+ ProfileIcon("snippet_folder", Icons.Filled.SnippetFolder, "Snippet Folder"),
+ ProfileIcon("source", Icons.Filled.Source, "Source"),
+ ProfileIcon("text_snippet", Icons.Filled.TextSnippet, "Text Snippet"),
+ ProfileIcon("topic", Icons.Filled.Topic, "Topic"),
+ ProfileIcon("upload", Icons.Filled.Upload, "Upload"),
+ ProfileIcon("upload_file", Icons.Filled.UploadFile, "Upload File"),
+ ProfileIcon("workspaces", Icons.Filled.Workspaces, "Workspaces"),
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/HardwareIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/HardwareIcons.kt
new file mode 100644
index 0000000..3486f61
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/HardwareIcons.kt
@@ -0,0 +1,186 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.BrowserNotSupported
+import androidx.compose.material.icons.filled.BrowserUpdated
+import androidx.compose.material.icons.filled.Cast
+import androidx.compose.material.icons.filled.CastConnected
+import androidx.compose.material.icons.filled.CastForEducation
+import androidx.compose.material.icons.filled.Computer
+import androidx.compose.material.icons.filled.ConnectedTv
+import androidx.compose.material.icons.filled.DesktopMac
+import androidx.compose.material.icons.filled.DesktopWindows
+import androidx.compose.material.icons.filled.DeveloperBoard
+import androidx.compose.material.icons.filled.DeveloperBoardOff
+import androidx.compose.material.icons.filled.DeviceHub
+import androidx.compose.material.icons.filled.DeviceUnknown
+import androidx.compose.material.icons.filled.DevicesOther
+import androidx.compose.material.icons.filled.DisplaySettings
+import androidx.compose.material.icons.filled.Dock
+import androidx.compose.material.icons.filled.Earbuds
+import androidx.compose.material.icons.filled.EarbudsBattery
+import androidx.compose.material.icons.filled.Gamepad
+import androidx.compose.material.icons.filled.Headphones
+import androidx.compose.material.icons.filled.HeadphonesBattery
+import androidx.compose.material.icons.filled.Headset
+import androidx.compose.material.icons.filled.HeadsetMic
+import androidx.compose.material.icons.filled.HeadsetOff
+import androidx.compose.material.icons.filled.HomeMax
+import androidx.compose.material.icons.filled.HomeMini
+import androidx.compose.material.icons.filled.Keyboard
+import androidx.compose.material.icons.filled.KeyboardAlt
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowLeft
+import androidx.compose.material.icons.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material.icons.filled.KeyboardBackspace
+import androidx.compose.material.icons.filled.KeyboardCapslock
+import androidx.compose.material.icons.filled.KeyboardCommandKey
+import androidx.compose.material.icons.filled.KeyboardControlKey
+import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown
+import androidx.compose.material.icons.filled.KeyboardDoubleArrowLeft
+import androidx.compose.material.icons.filled.KeyboardDoubleArrowRight
+import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp
+import androidx.compose.material.icons.filled.KeyboardHide
+import androidx.compose.material.icons.filled.KeyboardOptionKey
+import androidx.compose.material.icons.filled.KeyboardReturn
+import androidx.compose.material.icons.filled.KeyboardTab
+import androidx.compose.material.icons.filled.KeyboardVoice
+import androidx.compose.material.icons.filled.Laptop
+import androidx.compose.material.icons.filled.LaptopChromebook
+import androidx.compose.material.icons.filled.LaptopMac
+import androidx.compose.material.icons.filled.LaptopWindows
+import androidx.compose.material.icons.filled.Memory
+import androidx.compose.material.icons.filled.Monitor
+import androidx.compose.material.icons.filled.Mouse
+import androidx.compose.material.icons.filled.PhoneAndroid
+import androidx.compose.material.icons.filled.PhoneIphone
+import androidx.compose.material.icons.filled.Phonelink
+import androidx.compose.material.icons.filled.PhonelinkOff
+import androidx.compose.material.icons.filled.PivotTableChart
+import androidx.compose.material.icons.filled.PointOfSale
+import androidx.compose.material.icons.filled.PowerInput
+import androidx.compose.material.icons.filled.Print
+import androidx.compose.material.icons.filled.Router
+import androidx.compose.material.icons.filled.Scanner
+import androidx.compose.material.icons.filled.Security
+import androidx.compose.material.icons.filled.SimCard
+import androidx.compose.material.icons.filled.Smartphone
+import androidx.compose.material.icons.filled.Speaker
+import androidx.compose.material.icons.filled.SpeakerGroup
+import androidx.compose.material.icons.filled.Start
+import androidx.compose.material.icons.filled.Tablet
+import androidx.compose.material.icons.filled.TabletAndroid
+import androidx.compose.material.icons.filled.TabletMac
+import androidx.compose.material.icons.filled.Toys
+import androidx.compose.material.icons.filled.Tv
+import androidx.compose.material.icons.filled.TvOff
+import androidx.compose.material.icons.filled.VideogameAsset
+import androidx.compose.material.icons.filled.VideogameAssetOff
+import androidx.compose.material.icons.filled.Watch
+import androidx.compose.material.icons.filled.WatchOff
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * Hardware category icons - Physical hardware and peripherals
+ * Based on Google's Material Design Icons taxonomy
+ */
+object HardwareIcons {
+ val icons =
+ listOf(
+ ProfileIcon(
+ "browser_not_supported",
+ Icons.Filled.BrowserNotSupported,
+ "Browser Not Supported",
+ ),
+ ProfileIcon("browser_updated", Icons.Filled.BrowserUpdated, "Browser Updated"),
+ ProfileIcon("cast", Icons.Filled.Cast, "Cast"),
+ ProfileIcon("cast_connected", Icons.Filled.CastConnected, "Cast Connected"),
+ ProfileIcon("cast_for_education", Icons.Filled.CastForEducation, "Cast Education"),
+ ProfileIcon("computer", Icons.Filled.Computer, "Computer"),
+ ProfileIcon("connected_tv", Icons.Filled.ConnectedTv, "Connected TV"),
+ ProfileIcon("desktop_mac", Icons.Filled.DesktopMac, "Desktop Mac"),
+ ProfileIcon("desktop_windows", Icons.Filled.DesktopWindows, "Desktop Windows"),
+ ProfileIcon("developer_board", Icons.Filled.DeveloperBoard, "Developer Board"),
+ ProfileIcon("developer_board_off", Icons.Filled.DeveloperBoardOff, "Developer Board Off"),
+ ProfileIcon("device_hub", Icons.Filled.DeviceHub, "Device Hub"),
+ ProfileIcon("device_unknown", Icons.Filled.DeviceUnknown, "Device Unknown"),
+ ProfileIcon("devices_other", Icons.Filled.DevicesOther, "Devices Other"),
+ ProfileIcon("display_settings", Icons.Filled.DisplaySettings, "Display Settings"),
+ ProfileIcon("dock", Icons.Filled.Dock, "Dock"),
+ ProfileIcon("earbuds", Icons.Filled.Earbuds, "Earbuds"),
+ ProfileIcon("earbuds_battery", Icons.Filled.EarbudsBattery, "Earbuds Battery"),
+ ProfileIcon("gamepad", Icons.Filled.Gamepad, "Gamepad"),
+ ProfileIcon("headphones", Icons.Filled.Headphones, "Headphones"),
+ ProfileIcon("headphones_battery", Icons.Filled.HeadphonesBattery, "Headphones Battery"),
+ ProfileIcon("headset", Icons.Filled.Headset, "Headset"),
+ ProfileIcon("headset_mic", Icons.Filled.HeadsetMic, "Headset Mic"),
+ ProfileIcon("headset_off", Icons.Filled.HeadsetOff, "Headset Off"),
+ ProfileIcon("home_max", Icons.Filled.HomeMax, "Home Max"),
+ ProfileIcon("home_mini", Icons.Filled.HomeMini, "Home Mini"),
+ ProfileIcon("keyboard", Icons.Filled.Keyboard, "Keyboard"),
+ ProfileIcon("keyboard_alt", Icons.Filled.KeyboardAlt, "Keyboard Alt"),
+ ProfileIcon("keyboard_arrow_down", Icons.Filled.KeyboardArrowDown, "Arrow Down"),
+ ProfileIcon("keyboard_arrow_left", Icons.Filled.KeyboardArrowLeft, "Arrow Left"),
+ ProfileIcon("keyboard_arrow_right", Icons.Filled.KeyboardArrowRight, "Arrow Right"),
+ ProfileIcon("keyboard_arrow_up", Icons.Filled.KeyboardArrowUp, "Arrow Up"),
+ ProfileIcon("keyboard_backspace", Icons.Filled.KeyboardBackspace, "Backspace"),
+ ProfileIcon("keyboard_capslock", Icons.Filled.KeyboardCapslock, "Caps Lock"),
+ ProfileIcon("keyboard_command_key", Icons.Filled.KeyboardCommandKey, "Command Key"),
+ ProfileIcon("keyboard_control_key", Icons.Filled.KeyboardControlKey, "Control Key"),
+ ProfileIcon(
+ "keyboard_double_arrow_down",
+ Icons.Filled.KeyboardDoubleArrowDown,
+ "Double Down",
+ ),
+ ProfileIcon(
+ "keyboard_double_arrow_left",
+ Icons.Filled.KeyboardDoubleArrowLeft,
+ "Double Left",
+ ),
+ ProfileIcon(
+ "keyboard_double_arrow_right",
+ Icons.Filled.KeyboardDoubleArrowRight,
+ "Double Right",
+ ),
+ ProfileIcon("keyboard_double_arrow_up", Icons.Filled.KeyboardDoubleArrowUp, "Double Up"),
+ ProfileIcon("keyboard_hide", Icons.Filled.KeyboardHide, "Keyboard Hide"),
+ ProfileIcon("keyboard_option_key", Icons.Filled.KeyboardOptionKey, "Option Key"),
+ ProfileIcon("keyboard_return", Icons.Filled.KeyboardReturn, "Return"),
+ ProfileIcon("keyboard_tab", Icons.Filled.KeyboardTab, "Tab"),
+ ProfileIcon("keyboard_voice", Icons.Filled.KeyboardVoice, "Voice"),
+ ProfileIcon("laptop", Icons.Filled.Laptop, "Laptop"),
+ ProfileIcon("laptop_chromebook", Icons.Filled.LaptopChromebook, "Chromebook"),
+ ProfileIcon("laptop_mac", Icons.Filled.LaptopMac, "Laptop Mac"),
+ ProfileIcon("laptop_windows", Icons.Filled.LaptopWindows, "Laptop Windows"),
+ ProfileIcon("memory", Icons.Filled.Memory, "Memory"),
+ ProfileIcon("monitor", Icons.Filled.Monitor, "Monitor"),
+ ProfileIcon("mouse", Icons.Filled.Mouse, "Mouse"),
+ ProfileIcon("phone_android", Icons.Filled.PhoneAndroid, "Phone Android"),
+ ProfileIcon("phone_iphone", Icons.Filled.PhoneIphone, "Phone iPhone"),
+ ProfileIcon("phonelink", Icons.Filled.Phonelink, "Phonelink"),
+ ProfileIcon("phonelink_off", Icons.Filled.PhonelinkOff, "Phonelink Off"),
+ ProfileIcon("pivot_table_chart", Icons.Filled.PivotTableChart, "Pivot Table"),
+ ProfileIcon("point_of_sale", Icons.Filled.PointOfSale, "Point of Sale"),
+ ProfileIcon("power_input", Icons.Filled.PowerInput, "Power Input"),
+ ProfileIcon("printer", Icons.Filled.Print, "Printer"),
+ ProfileIcon("router", Icons.Filled.Router, "Router"),
+ ProfileIcon("scanner", Icons.Filled.Scanner, "Scanner"),
+ ProfileIcon("security", Icons.Filled.Security, "Security"),
+ ProfileIcon("sim_card", Icons.Filled.SimCard, "SIM Card"),
+ ProfileIcon("smartphone", Icons.Filled.Smartphone, "Smartphone"),
+ ProfileIcon("speaker", Icons.Filled.Speaker, "Speaker"),
+ ProfileIcon("speaker_group", Icons.Filled.SpeakerGroup, "Speaker Group"),
+ ProfileIcon("start", Icons.Filled.Start, "Start"),
+ ProfileIcon("tablet", Icons.Filled.Tablet, "Tablet"),
+ ProfileIcon("tablet_android", Icons.Filled.TabletAndroid, "Tablet Android"),
+ ProfileIcon("tablet_mac", Icons.Filled.TabletMac, "Tablet Mac"),
+ ProfileIcon("toys", Icons.Filled.Toys, "Toys"),
+ ProfileIcon("tv", Icons.Filled.Tv, "TV"),
+ ProfileIcon("tv_off", Icons.Filled.TvOff, "TV Off"),
+ ProfileIcon("videogame_asset", Icons.Filled.VideogameAsset, "Videogame"),
+ ProfileIcon("videogame_asset_off", Icons.Filled.VideogameAssetOff, "Videogame Off"),
+ ProfileIcon("watch", Icons.Filled.Watch, "Watch"),
+ ProfileIcon("watch_off", Icons.Filled.WatchOff, "Watch Off"),
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/IconCategory.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/IconCategory.kt
new file mode 100644
index 0000000..a271eac
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/IconCategory.kt
@@ -0,0 +1,13 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * Represents a category of Material Icons following Google's official taxonomy
+ */
+data class IconCategory(
+ val name: String,
+ val icons: List,
+) {
+ val size: Int get() = icons.size
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ImageIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ImageIcons.kt
new file mode 100644
index 0000000..67ade2e
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ImageIcons.kt
@@ -0,0 +1,509 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ReceiptLong
+import androidx.compose.material.icons.automirrored.filled.RotateLeft
+import androidx.compose.material.icons.automirrored.filled.RotateRight
+import androidx.compose.material.icons.filled.AddAPhoto
+import androidx.compose.material.icons.filled.AddPhotoAlternate
+import androidx.compose.material.icons.filled.AddToPhotos
+import androidx.compose.material.icons.filled.Adjust
+import androidx.compose.material.icons.filled.Animation
+import androidx.compose.material.icons.filled.Assistant
+import androidx.compose.material.icons.filled.AssistantPhoto
+import androidx.compose.material.icons.filled.Audiotrack
+import androidx.compose.material.icons.filled.AutoAwesome
+import androidx.compose.material.icons.filled.AutoAwesomeMosaic
+import androidx.compose.material.icons.filled.AutoAwesomeMotion
+import androidx.compose.material.icons.filled.AutoFixHigh
+import androidx.compose.material.icons.filled.AutoFixNormal
+import androidx.compose.material.icons.filled.AutoFixOff
+import androidx.compose.material.icons.filled.AutoMode
+import androidx.compose.material.icons.filled.AutoStories
+import androidx.compose.material.icons.filled.AutofpsSelect
+import androidx.compose.material.icons.filled.Bedtime
+import androidx.compose.material.icons.filled.BedtimeOff
+import androidx.compose.material.icons.filled.BlurCircular
+import androidx.compose.material.icons.filled.BlurLinear
+import androidx.compose.material.icons.filled.BlurOff
+import androidx.compose.material.icons.filled.BlurOn
+import androidx.compose.material.icons.filled.Brightness1
+import androidx.compose.material.icons.filled.Brightness2
+import androidx.compose.material.icons.filled.Brightness3
+import androidx.compose.material.icons.filled.Brightness4
+import androidx.compose.material.icons.filled.Brightness5
+import androidx.compose.material.icons.filled.Brightness6
+import androidx.compose.material.icons.filled.Brightness7
+import androidx.compose.material.icons.filled.BrokenImage
+import androidx.compose.material.icons.filled.Brush
+import androidx.compose.material.icons.filled.BurstMode
+import androidx.compose.material.icons.filled.Camera
+import androidx.compose.material.icons.filled.CameraAlt
+import androidx.compose.material.icons.filled.CameraFront
+import androidx.compose.material.icons.filled.CameraOutdoor
+import androidx.compose.material.icons.filled.CameraRear
+import androidx.compose.material.icons.filled.CameraRoll
+import androidx.compose.material.icons.filled.Cases
+import androidx.compose.material.icons.filled.CenterFocusStrong
+import androidx.compose.material.icons.filled.CenterFocusWeak
+import androidx.compose.material.icons.filled.Circle
+import androidx.compose.material.icons.filled.Collections
+import androidx.compose.material.icons.filled.CollectionsBookmark
+import androidx.compose.material.icons.filled.ColorLens
+import androidx.compose.material.icons.filled.Colorize
+import androidx.compose.material.icons.filled.Compare
+import androidx.compose.material.icons.filled.Contrast
+import androidx.compose.material.icons.filled.ControlPoint
+import androidx.compose.material.icons.filled.ControlPointDuplicate
+import androidx.compose.material.icons.filled.Crop
+import androidx.compose.material.icons.filled.Crop169
+import androidx.compose.material.icons.filled.Crop32
+import androidx.compose.material.icons.filled.Crop54
+import androidx.compose.material.icons.filled.Crop75
+import androidx.compose.material.icons.filled.CropDin
+import androidx.compose.material.icons.filled.CropFree
+import androidx.compose.material.icons.filled.CropLandscape
+import androidx.compose.material.icons.filled.CropOriginal
+import androidx.compose.material.icons.filled.CropPortrait
+import androidx.compose.material.icons.filled.CropRotate
+import androidx.compose.material.icons.filled.CropSquare
+import androidx.compose.material.icons.filled.CurrencyFranc
+import androidx.compose.material.icons.filled.CurrencyLira
+import androidx.compose.material.icons.filled.CurrencyPound
+import androidx.compose.material.icons.filled.CurrencyRuble
+import androidx.compose.material.icons.filled.CurrencyRupee
+import androidx.compose.material.icons.filled.CurrencyYen
+import androidx.compose.material.icons.filled.CurrencyYuan
+import androidx.compose.material.icons.filled.Deblur
+import androidx.compose.material.icons.filled.Dehaze
+import androidx.compose.material.icons.filled.Details
+import androidx.compose.material.icons.filled.DirtyLens
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.Euro
+import androidx.compose.material.icons.filled.Exposure
+import androidx.compose.material.icons.filled.ExposureNeg1
+import androidx.compose.material.icons.filled.ExposureNeg2
+import androidx.compose.material.icons.filled.ExposurePlus1
+import androidx.compose.material.icons.filled.ExposurePlus2
+import androidx.compose.material.icons.filled.ExposureZero
+import androidx.compose.material.icons.filled.FaceRetouchingNatural
+import androidx.compose.material.icons.filled.FaceRetouchingOff
+import androidx.compose.material.icons.filled.Filter
+import androidx.compose.material.icons.filled.Filter1
+import androidx.compose.material.icons.filled.Filter2
+import androidx.compose.material.icons.filled.Filter3
+import androidx.compose.material.icons.filled.Filter4
+import androidx.compose.material.icons.filled.Filter5
+import androidx.compose.material.icons.filled.Filter6
+import androidx.compose.material.icons.filled.Filter7
+import androidx.compose.material.icons.filled.Filter8
+import androidx.compose.material.icons.filled.Filter9
+import androidx.compose.material.icons.filled.Filter9Plus
+import androidx.compose.material.icons.filled.FilterBAndW
+import androidx.compose.material.icons.filled.FilterCenterFocus
+import androidx.compose.material.icons.filled.FilterDrama
+import androidx.compose.material.icons.filled.FilterFrames
+import androidx.compose.material.icons.filled.FilterHdr
+import androidx.compose.material.icons.filled.FilterNone
+import androidx.compose.material.icons.filled.FilterTiltShift
+import androidx.compose.material.icons.filled.FilterVintage
+import androidx.compose.material.icons.filled.Flare
+import androidx.compose.material.icons.filled.FlashAuto
+import androidx.compose.material.icons.filled.FlashOff
+import androidx.compose.material.icons.filled.FlashOn
+import androidx.compose.material.icons.filled.Flip
+import androidx.compose.material.icons.filled.FlipCameraAndroid
+import androidx.compose.material.icons.filled.FlipCameraIos
+import androidx.compose.material.icons.filled.Gradient
+import androidx.compose.material.icons.filled.Grain
+import androidx.compose.material.icons.filled.GridOff
+import androidx.compose.material.icons.filled.GridOn
+import androidx.compose.material.icons.filled.HdrEnhancedSelect
+import androidx.compose.material.icons.filled.HdrOff
+import androidx.compose.material.icons.filled.HdrOn
+import androidx.compose.material.icons.filled.HdrPlus
+import androidx.compose.material.icons.filled.HdrStrong
+import androidx.compose.material.icons.filled.HdrWeak
+import androidx.compose.material.icons.filled.Healing
+import androidx.compose.material.icons.filled.Hevc
+import androidx.compose.material.icons.filled.HideImage
+import androidx.compose.material.icons.filled.Image
+import androidx.compose.material.icons.filled.ImageAspectRatio
+import androidx.compose.material.icons.filled.ImageNotSupported
+import androidx.compose.material.icons.filled.ImageSearch
+import androidx.compose.material.icons.filled.IncompleteCircle
+import androidx.compose.material.icons.filled.Iso
+import androidx.compose.material.icons.filled.Landscape
+import androidx.compose.material.icons.filled.LeakAdd
+import androidx.compose.material.icons.filled.LeakRemove
+import androidx.compose.material.icons.filled.Lens
+import androidx.compose.material.icons.filled.LinkedCamera
+import androidx.compose.material.icons.filled.LogoDev
+import androidx.compose.material.icons.filled.Looks
+import androidx.compose.material.icons.filled.Looks3
+import androidx.compose.material.icons.filled.Looks4
+import androidx.compose.material.icons.filled.Looks5
+import androidx.compose.material.icons.filled.Looks6
+import androidx.compose.material.icons.filled.LooksOne
+import androidx.compose.material.icons.filled.LooksTwo
+import androidx.compose.material.icons.filled.Loupe
+import androidx.compose.material.icons.filled.MicExternalOff
+import androidx.compose.material.icons.filled.MicExternalOn
+import androidx.compose.material.icons.filled.MonochromePhotos
+import androidx.compose.material.icons.filled.MotionPhotosAuto
+import androidx.compose.material.icons.filled.MotionPhotosOff
+import androidx.compose.material.icons.filled.MotionPhotosOn
+import androidx.compose.material.icons.filled.MotionPhotosPause
+import androidx.compose.material.icons.filled.MotionPhotosPaused
+import androidx.compose.material.icons.filled.MovieCreation
+import androidx.compose.material.icons.filled.MovieFilter
+import androidx.compose.material.icons.filled.Mp
+import androidx.compose.material.icons.filled.MusicNote
+import androidx.compose.material.icons.filled.MusicOff
+import androidx.compose.material.icons.filled.Nature
+import androidx.compose.material.icons.filled.NaturePeople
+import androidx.compose.material.icons.filled.NavigateBefore
+import androidx.compose.material.icons.filled.NavigateNext
+import androidx.compose.material.icons.filled.Palette
+import androidx.compose.material.icons.filled.Panorama
+import androidx.compose.material.icons.filled.PanoramaFishEye
+import androidx.compose.material.icons.filled.PanoramaHorizontal
+import androidx.compose.material.icons.filled.PanoramaHorizontalSelect
+import androidx.compose.material.icons.filled.PanoramaPhotosphere
+import androidx.compose.material.icons.filled.PanoramaPhotosphereSelect
+import androidx.compose.material.icons.filled.PanoramaVertical
+import androidx.compose.material.icons.filled.PanoramaVerticalSelect
+import androidx.compose.material.icons.filled.PanoramaWideAngle
+import androidx.compose.material.icons.filled.PanoramaWideAngleSelect
+import androidx.compose.material.icons.filled.Photo
+import androidx.compose.material.icons.filled.PhotoAlbum
+import androidx.compose.material.icons.filled.PhotoCamera
+import androidx.compose.material.icons.filled.PhotoCameraBack
+import androidx.compose.material.icons.filled.PhotoCameraFront
+import androidx.compose.material.icons.filled.PhotoFilter
+import androidx.compose.material.icons.filled.PhotoLibrary
+import androidx.compose.material.icons.filled.PhotoSizeSelectActual
+import androidx.compose.material.icons.filled.PhotoSizeSelectLarge
+import androidx.compose.material.icons.filled.PhotoSizeSelectSmall
+import androidx.compose.material.icons.filled.PictureAsPdf
+import androidx.compose.material.icons.filled.Portrait
+import androidx.compose.material.icons.filled.RawOff
+import androidx.compose.material.icons.filled.RawOn
+import androidx.compose.material.icons.filled.RemoveRedEye
+import androidx.compose.material.icons.filled.Rotate90DegreesCcw
+import androidx.compose.material.icons.filled.Rotate90DegreesCw
+import androidx.compose.material.icons.filled.ShutterSpeed
+import androidx.compose.material.icons.filled.Slideshow
+import androidx.compose.material.icons.filled.Straighten
+import androidx.compose.material.icons.filled.Style
+import androidx.compose.material.icons.filled.SwitchCamera
+import androidx.compose.material.icons.filled.SwitchVideo
+import androidx.compose.material.icons.filled.TagFaces
+import androidx.compose.material.icons.filled.Texture
+import androidx.compose.material.icons.filled.ThermostatAuto
+import androidx.compose.material.icons.filled.Timelapse
+import androidx.compose.material.icons.filled.Timer
+import androidx.compose.material.icons.filled.Timer10
+import androidx.compose.material.icons.filled.Timer3
+import androidx.compose.material.icons.filled.TimerOff
+import androidx.compose.material.icons.filled.Tonality
+import androidx.compose.material.icons.filled.Transform
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material.icons.filled.VideoCameraBack
+import androidx.compose.material.icons.filled.VideoCameraFront
+import androidx.compose.material.icons.filled.VideoStable
+import androidx.compose.material.icons.filled.ViewComfy
+import androidx.compose.material.icons.filled.ViewCompact
+import androidx.compose.material.icons.filled.Vignette
+import androidx.compose.material.icons.filled.Vrpano
+import androidx.compose.material.icons.filled.WbAuto
+import androidx.compose.material.icons.filled.WbCloudy
+import androidx.compose.material.icons.filled.WbIncandescent
+import androidx.compose.material.icons.filled.WbIridescent
+import androidx.compose.material.icons.filled.WbShade
+import androidx.compose.material.icons.filled.WbSunny
+import androidx.compose.material.icons.filled.WbTwilight
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * Image category icons - Image editing and gallery
+ * Based on Google's Material Design Icons taxonomy
+ */
+object ImageIcons {
+ val icons =
+ listOf(
+ // ProfileIcon("10mp", Icons.Filled.TenMp, "10MP"),
+ // ProfileIcon("11mp", Icons.Filled.ElevenMp, "11MP"),
+ // ProfileIcon("12mp", Icons.Filled.TwelveMp, "12MP"),
+ // ProfileIcon("13mp", Icons.Filled.ThirteenMp, "13MP"),
+ // ProfileIcon("14mp", Icons.Filled.FourteenMp, "14MP"),
+ // ProfileIcon("15mp", Icons.Filled.FifteenMp, "15MP"),
+ // ProfileIcon("16mp", Icons.Filled.SixteenMp, "16MP"),
+ // ProfileIcon("17mp", Icons.Filled.SeventeenMp, "17MP"),
+ // ProfileIcon("18mp", Icons.Filled.EighteenMp, "18MP"),
+ // ProfileIcon("19mp", Icons.Filled.NineteenMp, "19MP"),
+ // ProfileIcon("20mp", Icons.Filled.TwentyMp, "20MP"),
+ // ProfileIcon("21mp", Icons.Filled.TwentyOneMp, "21MP"),
+ // ProfileIcon("22mp", Icons.Filled.TwentyTwoMp, "22MP"),
+ // ProfileIcon("23mp", Icons.Filled.TwentyThreeMp, "23MP"),
+ // ProfileIcon("24mp", Icons.Filled.TwentyFourMp, "24MP"),
+ // ProfileIcon("2mp", Icons.Filled.TwoMp, "2MP"),
+ // ProfileIcon("30fps", Icons.Filled.ThirtyFps, "30 FPS"), // Not available
+ // ProfileIcon("30fps_select", Icons.Filled.ThirtyFpsSelect, "30 FPS Select"),
+ // ProfileIcon("3mp", Icons.Filled.ThreeMp, "3MP"),
+ // ProfileIcon("4mp", Icons.Filled.FourMp, "4MP"),
+ // ProfileIcon("5mp", Icons.Filled.FiveMp, "5MP"),
+ // ProfileIcon("60fps", Icons.Filled.SixtyFps, "60 FPS"),
+ // ProfileIcon("60fps_select", Icons.Filled.SixtyFpsSelect, "60 FPS Select"),
+ // ProfileIcon("6mp", Icons.Filled.SixMp, "6MP"),
+ // ProfileIcon("7mp", Icons.Filled.SevenMp, "7MP"),
+ // ProfileIcon("8mp", Icons.Filled.EightMp, "8MP"),
+ // ProfileIcon("9mp", Icons.Filled.NineMp, "9MP"),
+ ProfileIcon("add_a_photo", Icons.Filled.AddAPhoto, "Add Photo"),
+ ProfileIcon("add_photo_alternate", Icons.Filled.AddPhotoAlternate, "Add Photo Alt"),
+ ProfileIcon("add_to_photos", Icons.Filled.AddToPhotos, "Add to Photos"),
+ ProfileIcon("adjust", Icons.Filled.Adjust, "Adjust"),
+ ProfileIcon("animation", Icons.Filled.Animation, "Animation"),
+ ProfileIcon("assistant", Icons.Filled.Assistant, "Assistant"),
+ ProfileIcon("assistant_photo", Icons.Filled.AssistantPhoto, "Assistant Photo"),
+ ProfileIcon("audiotrack", Icons.Filled.Audiotrack, "Audio Track"),
+ ProfileIcon("auto_awesome", Icons.Filled.AutoAwesome, "Auto Awesome"),
+ ProfileIcon("auto_awesome_mosaic", Icons.Filled.AutoAwesomeMosaic, "Auto Mosaic"),
+ ProfileIcon("auto_awesome_motion", Icons.Filled.AutoAwesomeMotion, "Auto Motion"),
+ ProfileIcon("auto_fix_high", Icons.Filled.AutoFixHigh, "Auto Fix High"),
+ ProfileIcon("auto_fix_normal", Icons.Filled.AutoFixNormal, "Auto Fix Normal"),
+ ProfileIcon("auto_fix_off", Icons.Filled.AutoFixOff, "Auto Fix Off"),
+ ProfileIcon("auto_mode", Icons.Filled.AutoMode, "Auto Mode"),
+ ProfileIcon("auto_stories", Icons.Filled.AutoStories, "Auto Stories"),
+ ProfileIcon("autofps_select", Icons.Filled.AutofpsSelect, "Auto FPS Select"),
+ ProfileIcon("bedtime", Icons.Filled.Bedtime, "Bedtime"),
+ ProfileIcon("bedtime_off", Icons.Filled.BedtimeOff, "Bedtime Off"),
+ ProfileIcon("blur_circular", Icons.Filled.BlurCircular, "Blur Circular"),
+ ProfileIcon("blur_linear", Icons.Filled.BlurLinear, "Blur Linear"),
+ ProfileIcon("blur_off", Icons.Filled.BlurOff, "Blur Off"),
+ ProfileIcon("blur_on", Icons.Filled.BlurOn, "Blur On"),
+ ProfileIcon("brightness_1", Icons.Filled.Brightness1, "Brightness 1"),
+ ProfileIcon("brightness_2", Icons.Filled.Brightness2, "Brightness 2"),
+ ProfileIcon("brightness_3", Icons.Filled.Brightness3, "Brightness 3"),
+ ProfileIcon("brightness_4", Icons.Filled.Brightness4, "Brightness 4"),
+ ProfileIcon("brightness_5", Icons.Filled.Brightness5, "Brightness 5"),
+ ProfileIcon("brightness_6", Icons.Filled.Brightness6, "Brightness 6"),
+ ProfileIcon("brightness_7", Icons.Filled.Brightness7, "Brightness 7"),
+ ProfileIcon("broken_image", Icons.Filled.BrokenImage, "Broken Image"),
+ ProfileIcon("brush", Icons.Filled.Brush, "Brush"),
+ ProfileIcon("burst_mode", Icons.Filled.BurstMode, "Burst Mode"),
+ ProfileIcon("camera", Icons.Filled.Camera, "Camera"),
+ ProfileIcon("camera_alt", Icons.Filled.CameraAlt, "Camera Alt"),
+ ProfileIcon("camera_front", Icons.Filled.CameraFront, "Camera Front"),
+ ProfileIcon("camera_outdoor", Icons.Filled.CameraOutdoor, "Camera Outdoor"),
+ ProfileIcon("camera_rear", Icons.Filled.CameraRear, "Camera Rear"),
+ ProfileIcon("camera_roll", Icons.Filled.CameraRoll, "Camera Roll"),
+ ProfileIcon("cases", Icons.Filled.Cases, "Cases"),
+ ProfileIcon("center_focus_strong", Icons.Filled.CenterFocusStrong, "Center Focus Strong"),
+ ProfileIcon("center_focus_weak", Icons.Filled.CenterFocusWeak, "Center Focus Weak"),
+ ProfileIcon("circle", Icons.Filled.Circle, "Circle"),
+ ProfileIcon("collections", Icons.Filled.Collections, "Collections"),
+ ProfileIcon(
+ "collections_bookmark",
+ Icons.Filled.CollectionsBookmark,
+ "Collections Bookmark",
+ ),
+ ProfileIcon("color_lens", Icons.Filled.ColorLens, "Color Lens"),
+ ProfileIcon("colorize", Icons.Filled.Colorize, "Colorize"),
+ ProfileIcon("compare", Icons.Filled.Compare, "Compare"),
+ ProfileIcon("contrast", Icons.Filled.Contrast, "Contrast"),
+ ProfileIcon("control_point", Icons.Filled.ControlPoint, "Control Point"),
+ ProfileIcon(
+ "control_point_duplicate",
+ Icons.Filled.ControlPointDuplicate,
+ "Control Duplicate",
+ ),
+ ProfileIcon("crop", Icons.Filled.Crop, "Crop"),
+ ProfileIcon("crop_16_9", Icons.Filled.Crop169, "Crop 16:9"),
+ ProfileIcon("crop_3_2", Icons.Filled.Crop32, "Crop 3:2"),
+ ProfileIcon("crop_5_4", Icons.Filled.Crop54, "Crop 5:4"),
+ ProfileIcon("crop_7_5", Icons.Filled.Crop75, "Crop 7:5"),
+ ProfileIcon("crop_din", Icons.Filled.CropDin, "Crop Din"),
+ ProfileIcon("crop_free", Icons.Filled.CropFree, "Crop Free"),
+ ProfileIcon("crop_landscape", Icons.Filled.CropLandscape, "Crop Landscape"),
+ ProfileIcon("crop_original", Icons.Filled.CropOriginal, "Crop Original"),
+ ProfileIcon("crop_portrait", Icons.Filled.CropPortrait, "Crop Portrait"),
+ ProfileIcon("crop_rotate", Icons.Filled.CropRotate, "Crop Rotate"),
+ ProfileIcon("crop_square", Icons.Filled.CropSquare, "Crop Square"),
+ ProfileIcon("currency_franc", Icons.Filled.CurrencyFranc, "Currency Franc"),
+ ProfileIcon("currency_lira", Icons.Filled.CurrencyLira, "Currency Lira"),
+ ProfileIcon("currency_pound", Icons.Filled.CurrencyPound, "Currency Pound"),
+ ProfileIcon("currency_ruble", Icons.Filled.CurrencyRuble, "Currency Ruble"),
+ ProfileIcon("currency_rupee", Icons.Filled.CurrencyRupee, "Currency Rupee"),
+ ProfileIcon("currency_yen", Icons.Filled.CurrencyYen, "Currency Yen"),
+ ProfileIcon("currency_yuan", Icons.Filled.CurrencyYuan, "Currency Yuan"),
+ ProfileIcon("deblur", Icons.Filled.Deblur, "Deblur"),
+ ProfileIcon("dehaze", Icons.Filled.Dehaze, "Dehaze"),
+ ProfileIcon("details", Icons.Filled.Details, "Details"),
+ ProfileIcon("dirty_lens", Icons.Filled.DirtyLens, "Dirty Lens"),
+ ProfileIcon("edit", Icons.Filled.Edit, "Edit"),
+ ProfileIcon("euro", Icons.Filled.Euro, "Euro"),
+ ProfileIcon("exposure", Icons.Filled.Exposure, "Exposure"),
+ ProfileIcon("exposure_neg_1", Icons.Filled.ExposureNeg1, "Exposure -1"),
+ ProfileIcon("exposure_neg_2", Icons.Filled.ExposureNeg2, "Exposure -2"),
+ ProfileIcon("exposure_plus_1", Icons.Filled.ExposurePlus1, "Exposure +1"),
+ ProfileIcon("exposure_plus_2", Icons.Filled.ExposurePlus2, "Exposure +2"),
+ ProfileIcon("exposure_zero", Icons.Filled.ExposureZero, "Exposure 0"),
+ ProfileIcon("face_retouching_natural", Icons.Filled.FaceRetouchingNatural, "Face Natural"),
+ ProfileIcon("face_retouching_off", Icons.Filled.FaceRetouchingOff, "Face Off"),
+ ProfileIcon("filter", Icons.Filled.Filter, "Filter"),
+ ProfileIcon("filter_1", Icons.Filled.Filter1, "Filter 1"),
+ ProfileIcon("filter_2", Icons.Filled.Filter2, "Filter 2"),
+ ProfileIcon("filter_3", Icons.Filled.Filter3, "Filter 3"),
+ ProfileIcon("filter_4", Icons.Filled.Filter4, "Filter 4"),
+ ProfileIcon("filter_5", Icons.Filled.Filter5, "Filter 5"),
+ ProfileIcon("filter_6", Icons.Filled.Filter6, "Filter 6"),
+ ProfileIcon("filter_7", Icons.Filled.Filter7, "Filter 7"),
+ ProfileIcon("filter_8", Icons.Filled.Filter8, "Filter 8"),
+ ProfileIcon("filter_9", Icons.Filled.Filter9, "Filter 9"),
+ ProfileIcon("filter_9_plus", Icons.Filled.Filter9Plus, "Filter 9+"),
+ ProfileIcon("filter_b_and_w", Icons.Filled.FilterBAndW, "Filter B&W"),
+ ProfileIcon("filter_center_focus", Icons.Filled.FilterCenterFocus, "Filter Focus"),
+ ProfileIcon("filter_drama", Icons.Filled.FilterDrama, "Filter Drama"),
+ ProfileIcon("filter_frames", Icons.Filled.FilterFrames, "Filter Frames"),
+ ProfileIcon("filter_hdr", Icons.Filled.FilterHdr, "Filter HDR"),
+ ProfileIcon("filter_none", Icons.Filled.FilterNone, "Filter None"),
+ ProfileIcon("filter_tilt_shift", Icons.Filled.FilterTiltShift, "Filter Tilt"),
+ ProfileIcon("filter_vintage", Icons.Filled.FilterVintage, "Filter Vintage"),
+ ProfileIcon("flare", Icons.Filled.Flare, "Flare"),
+ ProfileIcon("flash_auto", Icons.Filled.FlashAuto, "Flash Auto"),
+ ProfileIcon("flash_off", Icons.Filled.FlashOff, "Flash Off"),
+ ProfileIcon("flash_on", Icons.Filled.FlashOn, "Flash On"),
+ ProfileIcon("flip", Icons.Filled.Flip, "Flip"),
+ ProfileIcon("flip_camera_android", Icons.Filled.FlipCameraAndroid, "Flip Camera"),
+ ProfileIcon("flip_camera_ios", Icons.Filled.FlipCameraIos, "Flip Camera iOS"),
+ ProfileIcon("gradient", Icons.Filled.Gradient, "Gradient"),
+ ProfileIcon("grain", Icons.Filled.Grain, "Grain"),
+ ProfileIcon("grid_off", Icons.Filled.GridOff, "Grid Off"),
+ ProfileIcon("grid_on", Icons.Filled.GridOn, "Grid On"),
+ ProfileIcon("hdr_enhanced_select", Icons.Filled.HdrEnhancedSelect, "HDR Enhanced"),
+ ProfileIcon("hdr_off", Icons.Filled.HdrOff, "HDR Off"),
+ ProfileIcon("hdr_on", Icons.Filled.HdrOn, "HDR On"),
+ ProfileIcon("hdr_plus", Icons.Filled.HdrPlus, "HDR Plus"),
+ ProfileIcon("hdr_strong", Icons.Filled.HdrStrong, "HDR Strong"),
+ ProfileIcon("hdr_weak", Icons.Filled.HdrWeak, "HDR Weak"),
+ ProfileIcon("healing", Icons.Filled.Healing, "Healing"),
+ ProfileIcon("hevc", Icons.Filled.Hevc, "HEVC"),
+ ProfileIcon("hide_image", Icons.Filled.HideImage, "Hide Image"),
+ ProfileIcon("image", Icons.Filled.Image, "Image"),
+ ProfileIcon("image_aspect_ratio", Icons.Filled.ImageAspectRatio, "Image Aspect"),
+ ProfileIcon("image_not_supported", Icons.Filled.ImageNotSupported, "Image Not Supported"),
+ ProfileIcon("image_search", Icons.Filled.ImageSearch, "Image Search"),
+ ProfileIcon("incomplete_circle", Icons.Filled.IncompleteCircle, "Incomplete Circle"),
+ ProfileIcon("iso", Icons.Filled.Iso, "ISO"),
+ ProfileIcon("landscape", Icons.Filled.Landscape, "Landscape"),
+ ProfileIcon("leak_add", Icons.Filled.LeakAdd, "Leak Add"),
+ ProfileIcon("leak_remove", Icons.Filled.LeakRemove, "Leak Remove"),
+ ProfileIcon("lens", Icons.Filled.Lens, "Lens"),
+ ProfileIcon("linked_camera", Icons.Filled.LinkedCamera, "Linked Camera"),
+ ProfileIcon("logo_dev", Icons.Filled.LogoDev, "Logo Dev"),
+ ProfileIcon("looks", Icons.Filled.Looks, "Looks"),
+ ProfileIcon("looks_3", Icons.Filled.Looks3, "Looks 3"),
+ ProfileIcon("looks_4", Icons.Filled.Looks4, "Looks 4"),
+ ProfileIcon("looks_5", Icons.Filled.Looks5, "Looks 5"),
+ ProfileIcon("looks_6", Icons.Filled.Looks6, "Looks 6"),
+ ProfileIcon("looks_one", Icons.Filled.LooksOne, "Looks One"),
+ ProfileIcon("looks_two", Icons.Filled.LooksTwo, "Looks Two"),
+ ProfileIcon("loupe", Icons.Filled.Loupe, "Loupe"),
+ ProfileIcon("mic_external_off", Icons.Filled.MicExternalOff, "Mic External Off"),
+ ProfileIcon("mic_external_on", Icons.Filled.MicExternalOn, "Mic External On"),
+ ProfileIcon("monochrome_photos", Icons.Filled.MonochromePhotos, "Monochrome"),
+ ProfileIcon("motion_photos_auto", Icons.Filled.MotionPhotosAuto, "Motion Auto"),
+ ProfileIcon("motion_photos_off", Icons.Filled.MotionPhotosOff, "Motion Off"),
+ ProfileIcon("motion_photos_on", Icons.Filled.MotionPhotosOn, "Motion On"),
+ ProfileIcon("motion_photos_pause", Icons.Filled.MotionPhotosPause, "Motion Pause"),
+ ProfileIcon("motion_photos_paused", Icons.Filled.MotionPhotosPaused, "Motion Paused"),
+ ProfileIcon("movie_creation", Icons.Filled.MovieCreation, "Movie Creation"),
+ ProfileIcon("movie_filter", Icons.Filled.MovieFilter, "Movie Filter"),
+ ProfileIcon("mp", Icons.Filled.Mp, "MP"),
+ ProfileIcon("music_note", Icons.Filled.MusicNote, "Music Note"),
+ ProfileIcon("music_off", Icons.Filled.MusicOff, "Music Off"),
+ ProfileIcon("nature", Icons.Filled.Nature, "Nature"),
+ ProfileIcon("nature_people", Icons.Filled.NaturePeople, "Nature People"),
+ ProfileIcon("navigate_before", Icons.Filled.NavigateBefore, "Navigate Before"),
+ ProfileIcon("navigate_next", Icons.Filled.NavigateNext, "Navigate Next"),
+ ProfileIcon("palette", Icons.Filled.Palette, "Palette"),
+ ProfileIcon("panorama", Icons.Filled.Panorama, "Panorama"),
+ ProfileIcon("panorama_fish_eye", Icons.Filled.PanoramaFishEye, "Fish Eye"),
+ ProfileIcon("panorama_horizontal", Icons.Filled.PanoramaHorizontal, "Panorama Horizontal"),
+ ProfileIcon(
+ "panorama_horizontal_select",
+ Icons.Filled.PanoramaHorizontalSelect,
+ "Horizontal Select",
+ ),
+ ProfileIcon("panorama_photosphere", Icons.Filled.PanoramaPhotosphere, "Photosphere"),
+ ProfileIcon(
+ "panorama_photosphere_select",
+ Icons.Filled.PanoramaPhotosphereSelect,
+ "Photosphere Select",
+ ),
+ ProfileIcon("panorama_vertical", Icons.Filled.PanoramaVertical, "Panorama Vertical"),
+ ProfileIcon(
+ "panorama_vertical_select",
+ Icons.Filled.PanoramaVerticalSelect,
+ "Vertical Select",
+ ),
+ ProfileIcon("panorama_wide_angle", Icons.Filled.PanoramaWideAngle, "Wide Angle"),
+ ProfileIcon(
+ "panorama_wide_angle_select",
+ Icons.Filled.PanoramaWideAngleSelect,
+ "Wide Select",
+ ),
+ ProfileIcon("photo", Icons.Filled.Photo, "Photo"),
+ ProfileIcon("photo_album", Icons.Filled.PhotoAlbum, "Photo Album"),
+ ProfileIcon("photo_camera", Icons.Filled.PhotoCamera, "Photo Camera"),
+ ProfileIcon("photo_camera_back", Icons.Filled.PhotoCameraBack, "Camera Back"),
+ ProfileIcon("photo_camera_front", Icons.Filled.PhotoCameraFront, "Camera Front"),
+ ProfileIcon("photo_filter", Icons.Filled.PhotoFilter, "Photo Filter"),
+ ProfileIcon("photo_library", Icons.Filled.PhotoLibrary, "Photo Library"),
+ ProfileIcon("photo_size_select_actual", Icons.Filled.PhotoSizeSelectActual, "Actual Size"),
+ ProfileIcon("photo_size_select_large", Icons.Filled.PhotoSizeSelectLarge, "Large Size"),
+ ProfileIcon("photo_size_select_small", Icons.Filled.PhotoSizeSelectSmall, "Small Size"),
+ ProfileIcon("picture_as_pdf", Icons.Filled.PictureAsPdf, "Picture as PDF"),
+ ProfileIcon("portrait", Icons.Filled.Portrait, "Portrait"),
+ ProfileIcon("raw_off", Icons.Filled.RawOff, "RAW Off"),
+ ProfileIcon("raw_on", Icons.Filled.RawOn, "RAW On"),
+ ProfileIcon("receipt_long", Icons.AutoMirrored.Filled.ReceiptLong, "Receipt Long"),
+ ProfileIcon("remove_red_eye", Icons.Filled.RemoveRedEye, "Remove Red Eye"),
+ ProfileIcon("rotate_90_degrees_ccw", Icons.Filled.Rotate90DegreesCcw, "Rotate CCW"),
+ ProfileIcon("rotate_90_degrees_cw", Icons.Filled.Rotate90DegreesCw, "Rotate CW"),
+ ProfileIcon("rotate_left", Icons.AutoMirrored.Filled.RotateLeft, "Rotate Left"),
+ ProfileIcon("rotate_right", Icons.AutoMirrored.Filled.RotateRight, "Rotate Right"),
+ ProfileIcon("shutter_speed", Icons.Filled.ShutterSpeed, "Shutter Speed"),
+ ProfileIcon("slideshow", Icons.Filled.Slideshow, "Slideshow"),
+ ProfileIcon("straighten", Icons.Filled.Straighten, "Straighten"),
+ ProfileIcon("style", Icons.Filled.Style, "Style"),
+ ProfileIcon("switch_camera", Icons.Filled.SwitchCamera, "Switch Camera"),
+ ProfileIcon("switch_video", Icons.Filled.SwitchVideo, "Switch Video"),
+ ProfileIcon("tag_faces", Icons.Filled.TagFaces, "Tag Faces"),
+ ProfileIcon("texture", Icons.Filled.Texture, "Texture"),
+ ProfileIcon("thermostat_auto", Icons.Filled.ThermostatAuto, "Thermostat Auto"),
+ ProfileIcon("timelapse", Icons.Filled.Timelapse, "Timelapse"),
+ ProfileIcon("timer", Icons.Filled.Timer, "Timer"),
+ ProfileIcon("timer_10", Icons.Filled.Timer10, "Timer 10"),
+ ProfileIcon("timer_3", Icons.Filled.Timer3, "Timer 3"),
+ ProfileIcon("timer_off", Icons.Filled.TimerOff, "Timer Off"),
+ ProfileIcon("tonality", Icons.Filled.Tonality, "Tonality"),
+ ProfileIcon("transform", Icons.Filled.Transform, "Transform"),
+ ProfileIcon("tune", Icons.Filled.Tune, "Tune"),
+ ProfileIcon("video_camera_back", Icons.Filled.VideoCameraBack, "Video Back"),
+ ProfileIcon("video_camera_front", Icons.Filled.VideoCameraFront, "Video Front"),
+ ProfileIcon("video_stable", Icons.Filled.VideoStable, "Video Stable"),
+ ProfileIcon("view_comfy", Icons.Filled.ViewComfy, "View Comfy"),
+ ProfileIcon("view_compact", Icons.Filled.ViewCompact, "View Compact"),
+ ProfileIcon("vignette", Icons.Filled.Vignette, "Vignette"),
+ ProfileIcon("vrpano", Icons.Filled.Vrpano, "VR Pano"),
+ ProfileIcon("wb_auto", Icons.Filled.WbAuto, "WB Auto"),
+ ProfileIcon("wb_cloudy", Icons.Filled.WbCloudy, "WB Cloudy"),
+ ProfileIcon("wb_incandescent", Icons.Filled.WbIncandescent, "WB Incandescent"),
+ ProfileIcon("wb_iridescent", Icons.Filled.WbIridescent, "WB Iridescent"),
+ ProfileIcon("wb_shade", Icons.Filled.WbShade, "WB Shade"),
+ ProfileIcon("wb_sunny", Icons.Filled.WbSunny, "WB Sunny"),
+ ProfileIcon("wb_twilight", Icons.Filled.WbTwilight, "WB Twilight"),
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MapsIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MapsIcons.kt
new file mode 100644
index 0000000..6b24609
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MapsIcons.kt
@@ -0,0 +1,465 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AddLocation
+import androidx.compose.material.icons.filled.AddLocationAlt
+import androidx.compose.material.icons.filled.AddRoad
+import androidx.compose.material.icons.filled.Agriculture
+import androidx.compose.material.icons.filled.AirlineStops
+import androidx.compose.material.icons.filled.Airlines
+import androidx.compose.material.icons.filled.AltRoute
+import androidx.compose.material.icons.filled.Atm
+import androidx.compose.material.icons.filled.Attractions
+import androidx.compose.material.icons.filled.Badge
+import androidx.compose.material.icons.filled.BakeryDining
+import androidx.compose.material.icons.filled.Beenhere
+import androidx.compose.material.icons.filled.BikeScooter
+import androidx.compose.material.icons.filled.BreakfastDining
+import androidx.compose.material.icons.filled.BrunchDining
+import androidx.compose.material.icons.filled.BusAlert
+import androidx.compose.material.icons.filled.CarCrash
+import androidx.compose.material.icons.filled.CarRental
+import androidx.compose.material.icons.filled.CarRepair
+import androidx.compose.material.icons.filled.Castle
+import androidx.compose.material.icons.filled.Category
+import androidx.compose.material.icons.filled.Celebration
+import androidx.compose.material.icons.filled.Church
+import androidx.compose.material.icons.filled.CleaningServices
+import androidx.compose.material.icons.filled.CompassCalibration
+import androidx.compose.material.icons.filled.ConnectingAirports
+import androidx.compose.material.icons.filled.CrisisAlert
+import androidx.compose.material.icons.filled.DeliveryDining
+import androidx.compose.material.icons.filled.DepartureBoard
+import androidx.compose.material.icons.filled.DesignServices
+import androidx.compose.material.icons.filled.Diamond
+import androidx.compose.material.icons.filled.DinnerDining
+import androidx.compose.material.icons.filled.Directions
+import androidx.compose.material.icons.filled.DirectionsBike
+import androidx.compose.material.icons.filled.DirectionsBoat
+import androidx.compose.material.icons.filled.DirectionsBoatFilled
+import androidx.compose.material.icons.filled.DirectionsBus
+import androidx.compose.material.icons.filled.DirectionsBusFilled
+import androidx.compose.material.icons.filled.DirectionsCar
+import androidx.compose.material.icons.filled.DirectionsCarFilled
+import androidx.compose.material.icons.filled.DirectionsRailway
+import androidx.compose.material.icons.filled.DirectionsRailwayFilled
+import androidx.compose.material.icons.filled.DirectionsRun
+import androidx.compose.material.icons.filled.DirectionsSubway
+import androidx.compose.material.icons.filled.DirectionsSubwayFilled
+import androidx.compose.material.icons.filled.DirectionsTransit
+import androidx.compose.material.icons.filled.DirectionsTransitFilled
+import androidx.compose.material.icons.filled.DirectionsWalk
+import androidx.compose.material.icons.filled.DryCleaning
+import androidx.compose.material.icons.filled.EditAttributes
+import androidx.compose.material.icons.filled.EditLocation
+import androidx.compose.material.icons.filled.EditLocationAlt
+import androidx.compose.material.icons.filled.EditRoad
+import androidx.compose.material.icons.filled.Egg
+import androidx.compose.material.icons.filled.EggAlt
+import androidx.compose.material.icons.filled.ElectricBike
+import androidx.compose.material.icons.filled.ElectricCar
+import androidx.compose.material.icons.filled.ElectricMoped
+import androidx.compose.material.icons.filled.ElectricRickshaw
+import androidx.compose.material.icons.filled.ElectricScooter
+import androidx.compose.material.icons.filled.ElectricalServices
+import androidx.compose.material.icons.filled.Emergency
+import androidx.compose.material.icons.filled.EmergencyRecording
+import androidx.compose.material.icons.filled.EmergencyShare
+import androidx.compose.material.icons.filled.EvStation
+import androidx.compose.material.icons.filled.Factory
+import androidx.compose.material.icons.filled.Fastfood
+import androidx.compose.material.icons.filled.Festival
+import androidx.compose.material.icons.filled.FireExtinguisher
+import androidx.compose.material.icons.filled.FireHydrantAlt
+import androidx.compose.material.icons.filled.FireTruck
+import androidx.compose.material.icons.filled.Flight
+import androidx.compose.material.icons.filled.FlightClass
+import androidx.compose.material.icons.filled.FlightLand
+import androidx.compose.material.icons.filled.FlightTakeoff
+import androidx.compose.material.icons.filled.FoodBank
+import androidx.compose.material.icons.filled.Forest
+import androidx.compose.material.icons.filled.ForkLeft
+import androidx.compose.material.icons.filled.ForkRight
+import androidx.compose.material.icons.filled.Fort
+import androidx.compose.material.icons.filled.Hail
+import androidx.compose.material.icons.filled.Handyman
+import androidx.compose.material.icons.filled.Hardware
+import androidx.compose.material.icons.filled.HomeRepairService
+import androidx.compose.material.icons.filled.Hotel
+import androidx.compose.material.icons.filled.Hvac
+import androidx.compose.material.icons.filled.Icecream
+import androidx.compose.material.icons.filled.KebabDining
+import androidx.compose.material.icons.filled.Layers
+import androidx.compose.material.icons.filled.LayersClear
+import androidx.compose.material.icons.filled.Liquor
+import androidx.compose.material.icons.filled.LocalActivity
+import androidx.compose.material.icons.filled.LocalAirport
+import androidx.compose.material.icons.filled.LocalAtm
+import androidx.compose.material.icons.filled.LocalBar
+import androidx.compose.material.icons.filled.LocalCafe
+import androidx.compose.material.icons.filled.LocalCarWash
+import androidx.compose.material.icons.filled.LocalConvenienceStore
+import androidx.compose.material.icons.filled.LocalDining
+import androidx.compose.material.icons.filled.LocalDrink
+import androidx.compose.material.icons.filled.LocalFireDepartment
+import androidx.compose.material.icons.filled.LocalFlorist
+import androidx.compose.material.icons.filled.LocalGasStation
+import androidx.compose.material.icons.filled.LocalGroceryStore
+import androidx.compose.material.icons.filled.LocalHospital
+import androidx.compose.material.icons.filled.LocalHotel
+import androidx.compose.material.icons.filled.LocalLaundryService
+import androidx.compose.material.icons.filled.LocalLibrary
+import androidx.compose.material.icons.filled.LocalMall
+import androidx.compose.material.icons.filled.LocalMovies
+import androidx.compose.material.icons.filled.LocalOffer
+import androidx.compose.material.icons.filled.LocalParking
+import androidx.compose.material.icons.filled.LocalPharmacy
+import androidx.compose.material.icons.filled.LocalPhone
+import androidx.compose.material.icons.filled.LocalPizza
+import androidx.compose.material.icons.filled.LocalPlay
+import androidx.compose.material.icons.filled.LocalPolice
+import androidx.compose.material.icons.filled.LocalPostOffice
+import androidx.compose.material.icons.filled.LocalPrintshop
+import androidx.compose.material.icons.filled.LocalSee
+import androidx.compose.material.icons.filled.LocalShipping
+import androidx.compose.material.icons.filled.LocalTaxi
+import androidx.compose.material.icons.filled.LocationCity
+import androidx.compose.material.icons.filled.LocationDisabled
+import androidx.compose.material.icons.filled.LocationOff
+import androidx.compose.material.icons.filled.LocationOn
+import androidx.compose.material.icons.filled.LocationSearching
+import androidx.compose.material.icons.filled.LunchDining
+import androidx.compose.material.icons.filled.Map
+import androidx.compose.material.icons.filled.MapsHomeWork
+import androidx.compose.material.icons.filled.MapsUgc
+import androidx.compose.material.icons.filled.MedicalInformation
+import androidx.compose.material.icons.filled.MedicalServices
+import androidx.compose.material.icons.filled.Merge
+import androidx.compose.material.icons.filled.MinorCrash
+import androidx.compose.material.icons.filled.MiscellaneousServices
+import androidx.compose.material.icons.filled.ModeOfTravel
+import androidx.compose.material.icons.filled.Money
+import androidx.compose.material.icons.filled.Mosque
+import androidx.compose.material.icons.filled.Moving
+import androidx.compose.material.icons.filled.MultipleStop
+import androidx.compose.material.icons.filled.Museum
+import androidx.compose.material.icons.filled.MyLocation
+import androidx.compose.material.icons.filled.Navigation
+import androidx.compose.material.icons.filled.NearMe
+import androidx.compose.material.icons.filled.NearMeDisabled
+import androidx.compose.material.icons.filled.Nightlife
+import androidx.compose.material.icons.filled.NoCrash
+import androidx.compose.material.icons.filled.NoMeals
+import androidx.compose.material.icons.filled.NoTransfer
+import androidx.compose.material.icons.filled.NotListedLocation
+import androidx.compose.material.icons.filled.Park
+import androidx.compose.material.icons.filled.PedalBike
+import androidx.compose.material.icons.filled.PersonPin
+import androidx.compose.material.icons.filled.PersonPinCircle
+import androidx.compose.material.icons.filled.PestControl
+import androidx.compose.material.icons.filled.PestControlRodent
+import androidx.compose.material.icons.filled.PinDrop
+import androidx.compose.material.icons.filled.Place
+import androidx.compose.material.icons.filled.Plumbing
+import androidx.compose.material.icons.filled.RailwayAlert
+import androidx.compose.material.icons.filled.RamenDining
+import androidx.compose.material.icons.filled.RampLeft
+import androidx.compose.material.icons.filled.RampRight
+import androidx.compose.material.icons.filled.RateReview
+import androidx.compose.material.icons.filled.RemoveRoad
+import androidx.compose.material.icons.filled.Restaurant
+import androidx.compose.material.icons.filled.RestaurantMenu
+import androidx.compose.material.icons.filled.RoundaboutLeft
+import androidx.compose.material.icons.filled.RoundaboutRight
+import androidx.compose.material.icons.filled.Route
+import androidx.compose.material.icons.filled.RunCircle
+import androidx.compose.material.icons.filled.SafetyCheck
+import androidx.compose.material.icons.filled.Sailing
+import androidx.compose.material.icons.filled.Satellite
+import androidx.compose.material.icons.filled.ScreenRotationAlt
+import androidx.compose.material.icons.filled.SetMeal
+import androidx.compose.material.icons.filled.Signpost
+import androidx.compose.material.icons.filled.Snowmobile
+import androidx.compose.material.icons.filled.Sos
+import androidx.compose.material.icons.filled.SoupKitchen
+import androidx.compose.material.icons.filled.Stadium
+import androidx.compose.material.icons.filled.StoreMallDirectory
+import androidx.compose.material.icons.filled.Straight
+import androidx.compose.material.icons.filled.Streetview
+import androidx.compose.material.icons.filled.Subway
+import androidx.compose.material.icons.filled.Synagogue
+import androidx.compose.material.icons.filled.TakeoutDining
+import androidx.compose.material.icons.filled.TaxiAlert
+import androidx.compose.material.icons.filled.TempleBuddhist
+import androidx.compose.material.icons.filled.TempleHindu
+import androidx.compose.material.icons.filled.Terrain
+import androidx.compose.material.icons.filled.TheaterComedy
+import androidx.compose.material.icons.filled.TireRepair
+import androidx.compose.material.icons.filled.Traffic
+import androidx.compose.material.icons.filled.Train
+import androidx.compose.material.icons.filled.Tram
+import androidx.compose.material.icons.filled.TransferWithinAStation
+import androidx.compose.material.icons.filled.TransitEnterexit
+import androidx.compose.material.icons.filled.TripOrigin
+import androidx.compose.material.icons.filled.TurnLeft
+import androidx.compose.material.icons.filled.TurnRight
+import androidx.compose.material.icons.filled.TurnSharpLeft
+import androidx.compose.material.icons.filled.TurnSharpRight
+import androidx.compose.material.icons.filled.TurnSlightLeft
+import androidx.compose.material.icons.filled.TurnSlightRight
+import androidx.compose.material.icons.filled.TwoWheeler
+import androidx.compose.material.icons.filled.UTurnLeft
+import androidx.compose.material.icons.filled.UTurnRight
+import androidx.compose.material.icons.filled.VolunteerActivism
+import androidx.compose.material.icons.filled.Warehouse
+import androidx.compose.material.icons.filled.WineBar
+import androidx.compose.material.icons.filled.WrongLocation
+import androidx.compose.material.icons.filled.ZoomInMap
+import androidx.compose.material.icons.filled.ZoomOutMap
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * Maps category icons - Location and navigation
+ * Based on Google's Material Design Icons taxonomy
+ */
+object MapsIcons {
+ val icons =
+ listOf(
+ // ProfileIcon("360", Icons.Filled.ThreeSixty, "360"),
+ ProfileIcon("add_location", Icons.Filled.AddLocation, "Add Location"),
+ ProfileIcon("add_location_alt", Icons.Filled.AddLocationAlt, "Add Location Alt"),
+ ProfileIcon("add_road", Icons.Filled.AddRoad, "Add Road"),
+ ProfileIcon("agriculture", Icons.Filled.Agriculture, "Agriculture"),
+ ProfileIcon("airline_stops", Icons.Filled.AirlineStops, "Airline Stops"),
+ ProfileIcon("airlines", Icons.Filled.Airlines, "Airlines"),
+ ProfileIcon("alt_route", Icons.Filled.AltRoute, "Alt Route"),
+ ProfileIcon("atm", Icons.Filled.Atm, "ATM"),
+ ProfileIcon("attractions", Icons.Filled.Attractions, "Attractions"),
+ ProfileIcon("badge", Icons.Filled.Badge, "Badge"),
+ ProfileIcon("bakery_dining", Icons.Filled.BakeryDining, "Bakery Dining"),
+ ProfileIcon("beenhere", Icons.Filled.Beenhere, "Been Here"),
+ ProfileIcon("bike_scooter", Icons.Filled.BikeScooter, "Bike Scooter"),
+ ProfileIcon("breakfast_dining", Icons.Filled.BreakfastDining, "Breakfast Dining"),
+ ProfileIcon("brunch_dining", Icons.Filled.BrunchDining, "Brunch Dining"),
+ ProfileIcon("bus_alert", Icons.Filled.BusAlert, "Bus Alert"),
+ ProfileIcon("car_crash", Icons.Filled.CarCrash, "Car Crash"),
+ ProfileIcon("car_rental", Icons.Filled.CarRental, "Car Rental"),
+ ProfileIcon("car_repair", Icons.Filled.CarRepair, "Car Repair"),
+ ProfileIcon("castle", Icons.Filled.Castle, "Castle"),
+ ProfileIcon("category", Icons.Filled.Category, "Category"),
+ ProfileIcon("celebration", Icons.Filled.Celebration, "Celebration"),
+ ProfileIcon("church", Icons.Filled.Church, "Church"),
+ ProfileIcon("cleaning_services", Icons.Filled.CleaningServices, "Cleaning Services"),
+ ProfileIcon("compass_calibration", Icons.Filled.CompassCalibration, "Compass Calibration"),
+ ProfileIcon("connecting_airports", Icons.Filled.ConnectingAirports, "Connecting Airports"),
+ ProfileIcon("crisis_alert", Icons.Filled.CrisisAlert, "Crisis Alert"),
+ ProfileIcon("delivery_dining", Icons.Filled.DeliveryDining, "Delivery Dining"),
+ ProfileIcon("departure_board", Icons.Filled.DepartureBoard, "Departure Board"),
+ ProfileIcon("design_services", Icons.Filled.DesignServices, "Design Services"),
+ ProfileIcon("diamond", Icons.Filled.Diamond, "Diamond"),
+ ProfileIcon("dinner_dining", Icons.Filled.DinnerDining, "Dinner Dining"),
+ ProfileIcon("directions", Icons.Filled.Directions, "Directions"),
+ ProfileIcon("directions_bike", Icons.Filled.DirectionsBike, "Directions Bike"),
+ ProfileIcon("directions_boat", Icons.Filled.DirectionsBoat, "Directions Boat"),
+ ProfileIcon("directions_boat_filled", Icons.Filled.DirectionsBoatFilled, "Boat Filled"),
+ ProfileIcon("directions_bus", Icons.Filled.DirectionsBus, "Directions Bus"),
+ ProfileIcon("directions_bus_filled", Icons.Filled.DirectionsBusFilled, "Bus Filled"),
+ ProfileIcon("directions_car", Icons.Filled.DirectionsCar, "Directions Car"),
+ ProfileIcon("directions_car_filled", Icons.Filled.DirectionsCarFilled, "Car Filled"),
+ ProfileIcon("directions_railway", Icons.Filled.DirectionsRailway, "Railway"),
+ ProfileIcon(
+ "directions_railway_filled",
+ Icons.Filled.DirectionsRailwayFilled,
+ "Railway Filled",
+ ),
+ ProfileIcon("directions_run", Icons.Filled.DirectionsRun, "Directions Run"),
+ ProfileIcon("directions_subway", Icons.Filled.DirectionsSubway, "Subway"),
+ ProfileIcon(
+ "directions_subway_filled",
+ Icons.Filled.DirectionsSubwayFilled,
+ "Subway Filled",
+ ),
+ ProfileIcon("directions_transit", Icons.Filled.DirectionsTransit, "Transit"),
+ ProfileIcon(
+ "directions_transit_filled",
+ Icons.Filled.DirectionsTransitFilled,
+ "Transit Filled",
+ ),
+ ProfileIcon("directions_walk", Icons.Filled.DirectionsWalk, "Directions Walk"),
+ ProfileIcon("dry_cleaning", Icons.Filled.DryCleaning, "Dry Cleaning"),
+ ProfileIcon("edit_attributes", Icons.Filled.EditAttributes, "Edit Attributes"),
+ ProfileIcon("edit_location", Icons.Filled.EditLocation, "Edit Location"),
+ ProfileIcon("edit_location_alt", Icons.Filled.EditLocationAlt, "Edit Location Alt"),
+ ProfileIcon("edit_road", Icons.Filled.EditRoad, "Edit Road"),
+ ProfileIcon("egg", Icons.Filled.Egg, "Egg"),
+ ProfileIcon("egg_alt", Icons.Filled.EggAlt, "Egg Alt"),
+ ProfileIcon("electric_bike", Icons.Filled.ElectricBike, "Electric Bike"),
+ ProfileIcon("electric_car", Icons.Filled.ElectricCar, "Electric Car"),
+ ProfileIcon("electric_moped", Icons.Filled.ElectricMoped, "Electric Moped"),
+ ProfileIcon("electric_rickshaw", Icons.Filled.ElectricRickshaw, "Electric Rickshaw"),
+ ProfileIcon("electric_scooter", Icons.Filled.ElectricScooter, "Electric Scooter"),
+ ProfileIcon("electrical_services", Icons.Filled.ElectricalServices, "Electrical Services"),
+ ProfileIcon("emergency", Icons.Filled.Emergency, "Emergency"),
+ ProfileIcon("emergency_recording", Icons.Filled.EmergencyRecording, "Emergency Recording"),
+ ProfileIcon("emergency_share", Icons.Filled.EmergencyShare, "Emergency Share"),
+ ProfileIcon("ev_station", Icons.Filled.EvStation, "EV Station"),
+ ProfileIcon("factory", Icons.Filled.Factory, "Factory"),
+ ProfileIcon("fastfood", Icons.Filled.Fastfood, "Fast Food"),
+ ProfileIcon("festival", Icons.Filled.Festival, "Festival"),
+ ProfileIcon("fire_extinguisher", Icons.Filled.FireExtinguisher, "Fire Extinguisher"),
+ ProfileIcon("fire_hydrant_alt", Icons.Filled.FireHydrantAlt, "Fire Hydrant"),
+ ProfileIcon("fire_truck", Icons.Filled.FireTruck, "Fire Truck"),
+ ProfileIcon("flight", Icons.Filled.Flight, "Flight"),
+ ProfileIcon("flight_class", Icons.Filled.FlightClass, "Flight Class"),
+ ProfileIcon("flight_land", Icons.Filled.FlightLand, "Flight Land"),
+ ProfileIcon("flight_takeoff", Icons.Filled.FlightTakeoff, "Flight Takeoff"),
+ ProfileIcon("food_bank", Icons.Filled.FoodBank, "Food Bank"),
+ ProfileIcon("forest", Icons.Filled.Forest, "Forest"),
+ ProfileIcon("fork_left", Icons.Filled.ForkLeft, "Fork Left"),
+ ProfileIcon("fork_right", Icons.Filled.ForkRight, "Fork Right"),
+ ProfileIcon("fort", Icons.Filled.Fort, "Fort"),
+ ProfileIcon("hail", Icons.Filled.Hail, "Hail"),
+ ProfileIcon("handyman", Icons.Filled.Handyman, "Handyman"),
+ ProfileIcon("hardware", Icons.Filled.Hardware, "Hardware"),
+ ProfileIcon("home_repair_service", Icons.Filled.HomeRepairService, "Home Repair"),
+ ProfileIcon("hotel", Icons.Filled.Hotel, "Hotel"),
+ ProfileIcon("hvac", Icons.Filled.Hvac, "HVAC"),
+ ProfileIcon("icecream", Icons.Filled.Icecream, "Ice Cream"),
+ ProfileIcon("kebab_dining", Icons.Filled.KebabDining, "Kebab Dining"),
+ ProfileIcon("layers", Icons.Filled.Layers, "Layers"),
+ ProfileIcon("layers_clear", Icons.Filled.LayersClear, "Layers Clear"),
+ ProfileIcon("liquor", Icons.Filled.Liquor, "Liquor"),
+ ProfileIcon("local_activity", Icons.Filled.LocalActivity, "Local Activity"),
+ ProfileIcon("local_airport", Icons.Filled.LocalAirport, "Airport"),
+ ProfileIcon("local_atm", Icons.Filled.LocalAtm, "ATM"),
+ ProfileIcon("local_bar", Icons.Filled.LocalBar, "Bar"),
+ ProfileIcon("local_cafe", Icons.Filled.LocalCafe, "Cafe"),
+ ProfileIcon("local_car_wash", Icons.Filled.LocalCarWash, "Car Wash"),
+ ProfileIcon(
+ "local_convenience_store",
+ Icons.Filled.LocalConvenienceStore,
+ "Convenience Store",
+ ),
+ ProfileIcon("local_dining", Icons.Filled.LocalDining, "Dining"),
+ ProfileIcon("local_drink", Icons.Filled.LocalDrink, "Drink"),
+ ProfileIcon("local_fire_department", Icons.Filled.LocalFireDepartment, "Fire Department"),
+ ProfileIcon("local_florist", Icons.Filled.LocalFlorist, "Florist"),
+ ProfileIcon("local_gas_station", Icons.Filled.LocalGasStation, "Gas Station"),
+ ProfileIcon("local_grocery_store", Icons.Filled.LocalGroceryStore, "Grocery Store"),
+ ProfileIcon("local_hospital", Icons.Filled.LocalHospital, "Hospital"),
+ ProfileIcon("local_hotel", Icons.Filled.LocalHotel, "Hotel"),
+ ProfileIcon("local_laundry_service", Icons.Filled.LocalLaundryService, "Laundry"),
+ ProfileIcon("local_library", Icons.Filled.LocalLibrary, "Library"),
+ ProfileIcon("local_mall", Icons.Filled.LocalMall, "Mall"),
+ ProfileIcon("local_movies", Icons.Filled.LocalMovies, "Movies"),
+ ProfileIcon("local_offer", Icons.Filled.LocalOffer, "Offer"),
+ ProfileIcon("local_parking", Icons.Filled.LocalParking, "Parking"),
+ ProfileIcon("local_pharmacy", Icons.Filled.LocalPharmacy, "Pharmacy"),
+ ProfileIcon("local_phone", Icons.Filled.LocalPhone, "Phone"),
+ ProfileIcon("local_pizza", Icons.Filled.LocalPizza, "Pizza"),
+ ProfileIcon("local_play", Icons.Filled.LocalPlay, "Play"),
+ ProfileIcon("local_police", Icons.Filled.LocalPolice, "Police"),
+ ProfileIcon("local_post_office", Icons.Filled.LocalPostOffice, "Post Office"),
+ ProfileIcon("local_printshop", Icons.Filled.LocalPrintshop, "Print Shop"),
+ ProfileIcon("local_see", Icons.Filled.LocalSee, "See"),
+ ProfileIcon("local_shipping", Icons.Filled.LocalShipping, "Shipping"),
+ ProfileIcon("local_taxi", Icons.Filled.LocalTaxi, "Taxi"),
+ ProfileIcon("location_city", Icons.Filled.LocationCity, "City"),
+ ProfileIcon("location_disabled", Icons.Filled.LocationDisabled, "Location Disabled"),
+ ProfileIcon("location_off", Icons.Filled.LocationOff, "Location Off"),
+ ProfileIcon("location_on", Icons.Filled.LocationOn, "Location On"),
+ ProfileIcon("location_searching", Icons.Filled.LocationSearching, "Location Searching"),
+ ProfileIcon("lunch_dining", Icons.Filled.LunchDining, "Lunch Dining"),
+ ProfileIcon("map", Icons.Filled.Map, "Map"),
+ ProfileIcon("maps_home_work", Icons.Filled.MapsHomeWork, "Home Work"),
+ ProfileIcon("maps_ugc", Icons.Filled.MapsUgc, "Maps UGC"),
+ ProfileIcon("medical_information", Icons.Filled.MedicalInformation, "Medical Info"),
+ ProfileIcon("medical_services", Icons.Filled.MedicalServices, "Medical Services"),
+ ProfileIcon("merge", Icons.Filled.Merge, "Merge"),
+ ProfileIcon("minor_crash", Icons.Filled.MinorCrash, "Minor Crash"),
+ ProfileIcon("miscellaneous_services", Icons.Filled.MiscellaneousServices, "Misc Services"),
+ ProfileIcon("mode_of_travel", Icons.Filled.ModeOfTravel, "Mode of Travel"),
+ ProfileIcon("money", Icons.Filled.Money, "Money"),
+ ProfileIcon("mosque", Icons.Filled.Mosque, "Mosque"),
+ ProfileIcon("moving", Icons.Filled.Moving, "Moving"),
+ ProfileIcon("multiple_stop", Icons.Filled.MultipleStop, "Multiple Stop"),
+ ProfileIcon("museum", Icons.Filled.Museum, "Museum"),
+ ProfileIcon("my_location", Icons.Filled.MyLocation, "My Location"),
+ ProfileIcon("navigation", Icons.Filled.Navigation, "Navigation"),
+ ProfileIcon("near_me", Icons.Filled.NearMe, "Near Me"),
+ ProfileIcon("near_me_disabled", Icons.Filled.NearMeDisabled, "Near Me Disabled"),
+ ProfileIcon("nightlife", Icons.Filled.Nightlife, "Nightlife"),
+ ProfileIcon("no_crash", Icons.Filled.NoCrash, "No Crash"),
+ ProfileIcon("no_meals", Icons.Filled.NoMeals, "No Meals"),
+ ProfileIcon("no_transfer", Icons.Filled.NoTransfer, "No Transfer"),
+ ProfileIcon("not_listed_location", Icons.Filled.NotListedLocation, "Not Listed"),
+ ProfileIcon("park", Icons.Filled.Park, "Park"),
+ ProfileIcon("pedal_bike", Icons.Filled.PedalBike, "Pedal Bike"),
+ ProfileIcon("person_pin", Icons.Filled.PersonPin, "Person Pin"),
+ ProfileIcon("person_pin_circle", Icons.Filled.PersonPinCircle, "Person Pin Circle"),
+ ProfileIcon("pest_control", Icons.Filled.PestControl, "Pest Control"),
+ ProfileIcon("pest_control_rodent", Icons.Filled.PestControlRodent, "Pest Rodent"),
+ ProfileIcon("pin_drop", Icons.Filled.PinDrop, "Pin Drop"),
+ ProfileIcon("place", Icons.Filled.Place, "Place"),
+ ProfileIcon("plumbing", Icons.Filled.Plumbing, "Plumbing"),
+ ProfileIcon("railway_alert", Icons.Filled.RailwayAlert, "Railway Alert"),
+ ProfileIcon("ramen_dining", Icons.Filled.RamenDining, "Ramen Dining"),
+ ProfileIcon("ramp_left", Icons.Filled.RampLeft, "Ramp Left"),
+ ProfileIcon("ramp_right", Icons.Filled.RampRight, "Ramp Right"),
+ ProfileIcon("rate_review", Icons.Filled.RateReview, "Rate Review"),
+ ProfileIcon("remove_road", Icons.Filled.RemoveRoad, "Remove Road"),
+ ProfileIcon("restaurant", Icons.Filled.Restaurant, "Restaurant"),
+ ProfileIcon("restaurant_menu", Icons.Filled.RestaurantMenu, "Restaurant Menu"),
+ ProfileIcon("route", Icons.Filled.Route, "Route"),
+ ProfileIcon("roundabout_left", Icons.Filled.RoundaboutLeft, "Roundabout Left"),
+ ProfileIcon("roundabout_right", Icons.Filled.RoundaboutRight, "Roundabout Right"),
+ ProfileIcon("run_circle", Icons.Filled.RunCircle, "Run Circle"),
+ ProfileIcon("safety_check", Icons.Filled.SafetyCheck, "Safety Check"),
+ ProfileIcon("sailing", Icons.Filled.Sailing, "Sailing"),
+ ProfileIcon("satellite", Icons.Filled.Satellite, "Satellite"),
+ ProfileIcon("screen_rotation_alt", Icons.Filled.ScreenRotationAlt, "Screen Rotation Alt"),
+ ProfileIcon("set_meal", Icons.Filled.SetMeal, "Set Meal"),
+ ProfileIcon("signpost", Icons.Filled.Signpost, "Signpost"),
+ ProfileIcon("snowmobile", Icons.Filled.Snowmobile, "Snowmobile"),
+ ProfileIcon("sos", Icons.Filled.Sos, "SOS"),
+ ProfileIcon("soup_kitchen", Icons.Filled.SoupKitchen, "Soup Kitchen"),
+ ProfileIcon("stadium", Icons.Filled.Stadium, "Stadium"),
+ ProfileIcon("store_mall_directory", Icons.Filled.StoreMallDirectory, "Mall Directory"),
+ ProfileIcon("straight", Icons.Filled.Straight, "Straight"),
+ ProfileIcon("streetview", Icons.Filled.Streetview, "Street View"),
+ ProfileIcon("subway", Icons.Filled.Subway, "Subway"),
+ ProfileIcon("synagogue", Icons.Filled.Synagogue, "Synagogue"),
+ ProfileIcon("takeout_dining", Icons.Filled.TakeoutDining, "Takeout Dining"),
+ ProfileIcon("taxi_alert", Icons.Filled.TaxiAlert, "Taxi Alert"),
+ ProfileIcon("temple_buddhist", Icons.Filled.TempleBuddhist, "Buddhist Temple"),
+ ProfileIcon("temple_hindu", Icons.Filled.TempleHindu, "Hindu Temple"),
+ ProfileIcon("terrain", Icons.Filled.Terrain, "Terrain"),
+ ProfileIcon("theater_comedy", Icons.Filled.TheaterComedy, "Theater Comedy"),
+ ProfileIcon("tire_repair", Icons.Filled.TireRepair, "Tire Repair"),
+ ProfileIcon("traffic", Icons.Filled.Traffic, "Traffic"),
+ ProfileIcon("train", Icons.Filled.Train, "Train"),
+ ProfileIcon("tram", Icons.Filled.Tram, "Tram"),
+ ProfileIcon(
+ "transfer_within_a_station",
+ Icons.Filled.TransferWithinAStation,
+ "Transfer Station",
+ ),
+ ProfileIcon("transit_enterexit", Icons.Filled.TransitEnterexit, "Transit Enter/Exit"),
+ ProfileIcon("trip_origin", Icons.Filled.TripOrigin, "Trip Origin"),
+ ProfileIcon("turn_left", Icons.Filled.TurnLeft, "Turn Left"),
+ ProfileIcon("turn_right", Icons.Filled.TurnRight, "Turn Right"),
+ ProfileIcon("turn_sharp_left", Icons.Filled.TurnSharpLeft, "Turn Sharp Left"),
+ ProfileIcon("turn_sharp_right", Icons.Filled.TurnSharpRight, "Turn Sharp Right"),
+ ProfileIcon("turn_slight_left", Icons.Filled.TurnSlightLeft, "Turn Slight Left"),
+ ProfileIcon("turn_slight_right", Icons.Filled.TurnSlightRight, "Turn Slight Right"),
+ ProfileIcon("two_wheeler", Icons.Filled.TwoWheeler, "Two Wheeler"),
+ ProfileIcon("u_turn_left", Icons.Filled.UTurnLeft, "U-Turn Left"),
+ ProfileIcon("u_turn_right", Icons.Filled.UTurnRight, "U-Turn Right"),
+ ProfileIcon("volunteer_activism", Icons.Filled.VolunteerActivism, "Volunteer"),
+ ProfileIcon("warehouse", Icons.Filled.Warehouse, "Warehouse"),
+ ProfileIcon("wine_bar", Icons.Filled.WineBar, "Wine Bar"),
+ ProfileIcon("wrong_location", Icons.Filled.WrongLocation, "Wrong Location"),
+ ProfileIcon("zoom_in_map", Icons.Filled.ZoomInMap, "Zoom In Map"),
+ ProfileIcon("zoom_out_map", Icons.Filled.ZoomOutMap, "Zoom Out Map"),
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MaterialIconsLibrary.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MaterialIconsLibrary.kt
new file mode 100644
index 0000000..3921dfe
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MaterialIconsLibrary.kt
@@ -0,0 +1,112 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.ui.graphics.vector.ImageVector
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * Complete Material Icons Library following Google's official taxonomy
+ * Icons are organized into categories as defined by Material Design guidelines
+ *
+ * Categories based on https://fonts.google.com/icons taxonomy:
+ * - Action: User actions and common UI operations
+ * - Alert: Warnings, errors, and notifications
+ * - AV (Audio/Video): Media controls and playback
+ * - Communication: Messaging, calls, emails
+ * - Content: Content creation and management
+ * - Device: Device-specific icons and features
+ * - Editor: Text and content editing
+ * - File: File types and operations
+ * - Hardware: Physical hardware and peripherals
+ * - Image: Image editing and gallery
+ * - Maps: Location and navigation
+ * - Navigation: App navigation and menus
+ * - Notification: Alerts and status updates
+ * - Places: Locations and venues
+ * - Social: Social media and sharing
+ * - Toggle: Switches and toggles
+ */
+object MaterialIconsLibrary {
+ /**
+ * All icon categories following Google's Material Design taxonomy
+ */
+ val categories: List =
+ listOf(
+ IconCategory("Action", ActionIcons.icons),
+ IconCategory("Alert", AlertIcons.icons),
+ IconCategory("Audio & Video", AVIcons.icons),
+ IconCategory("Communication", CommunicationIcons.icons),
+ IconCategory("Content", ContentIcons.icons),
+ IconCategory("Device", DeviceIcons.icons),
+ IconCategory("Editor", EditorIcons.icons),
+ IconCategory("File", FileIcons.icons),
+ IconCategory("Hardware", HardwareIcons.icons),
+ IconCategory("Image", ImageIcons.icons),
+ IconCategory("Maps", MapsIcons.icons),
+ IconCategory("Navigation", NavigationIcons.icons),
+ IconCategory("Notification", NotificationIcons.icons),
+ IconCategory("Places", PlacesIcons.icons),
+ IconCategory("Social", SocialIcons.icons),
+ IconCategory("Toggle", ToggleIcons.icons),
+ )
+
+ /**
+ * Get all icons from all categories
+ */
+ fun getAllIcons(): List {
+ return categories.flatMap { it.icons }
+ }
+
+ /**
+ * Get an icon by its ID
+ */
+ fun getIconById(id: String): ImageVector? {
+ return getAllIcons().find { it.id == id }?.icon
+ }
+
+ /**
+ * Get the category name for a given icon ID
+ */
+ fun getCategoryForIcon(iconId: String): String? {
+ categories.forEach { category ->
+ if (category.icons.any { it.id == iconId }) {
+ return category.name
+ }
+ }
+ return null
+ }
+
+ /**
+ * Search icons by query (searches in both ID and label)
+ */
+ fun searchIcons(query: String): List {
+ if (query.isBlank()) return getAllIcons()
+
+ val lowercaseQuery = query.lowercase()
+ return getAllIcons().filter {
+ it.id.contains(lowercaseQuery) ||
+ it.label.lowercase().contains(lowercaseQuery)
+ }
+ }
+
+ /**
+ * Get icons by category name
+ */
+ fun getIconsByCategory(categoryName: String): List {
+ return categories.find { it.name.equals(categoryName, ignoreCase = true) }?.icons
+ ?: emptyList()
+ }
+
+ /**
+ * Get total number of icons in the library
+ */
+ fun getTotalIconCount(): Int {
+ return categories.sumOf { it.icons.size }
+ }
+
+ /**
+ * Get category names
+ */
+ fun getCategoryNames(): List {
+ return categories.map { it.name }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NavigationIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NavigationIcons.kt
new file mode 100644
index 0000000..8d97d3a
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NavigationIcons.kt
@@ -0,0 +1,137 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.ArrowBackIos
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
+import androidx.compose.material.icons.automirrored.filled.ArrowRightAlt
+import androidx.compose.material.icons.automirrored.filled.MenuBook
+import androidx.compose.material.icons.automirrored.filled.MenuOpen
+import androidx.compose.material.icons.filled.AppSettingsAlt
+import androidx.compose.material.icons.filled.Apps
+import androidx.compose.material.icons.filled.ArrowBackIosNew
+import androidx.compose.material.icons.filled.ArrowDownward
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material.icons.filled.ArrowDropDownCircle
+import androidx.compose.material.icons.filled.ArrowDropUp
+import androidx.compose.material.icons.filled.ArrowLeft
+import androidx.compose.material.icons.filled.ArrowRight
+import androidx.compose.material.icons.filled.ArrowUpward
+import androidx.compose.material.icons.filled.AssistantDirection
+import androidx.compose.material.icons.filled.Campaign
+import androidx.compose.material.icons.filled.Cancel
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.ChevronLeft
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.DoubleArrow
+import androidx.compose.material.icons.filled.East
+import androidx.compose.material.icons.filled.ExpandCircleDown
+import androidx.compose.material.icons.filled.ExpandLess
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.FirstPage
+import androidx.compose.material.icons.filled.Fullscreen
+import androidx.compose.material.icons.filled.FullscreenExit
+import androidx.compose.material.icons.filled.HomeWork
+import androidx.compose.material.icons.filled.LastPage
+import androidx.compose.material.icons.filled.LegendToggle
+import androidx.compose.material.icons.filled.LiveTv
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.MoreHoriz
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.North
+import androidx.compose.material.icons.filled.NorthEast
+import androidx.compose.material.icons.filled.NorthWest
+import androidx.compose.material.icons.filled.OfflineShare
+import androidx.compose.material.icons.filled.Payments
+import androidx.compose.material.icons.filled.PivotTableChart
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.South
+import androidx.compose.material.icons.filled.SouthEast
+import androidx.compose.material.icons.filled.SouthWest
+import androidx.compose.material.icons.filled.SubdirectoryArrowLeft
+import androidx.compose.material.icons.filled.SubdirectoryArrowRight
+import androidx.compose.material.icons.filled.SwitchLeft
+import androidx.compose.material.icons.filled.SwitchRight
+import androidx.compose.material.icons.filled.UnfoldLess
+import androidx.compose.material.icons.filled.UnfoldMore
+import androidx.compose.material.icons.filled.WaterfallChart
+import androidx.compose.material.icons.filled.West
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * Navigation category icons - App navigation and menus
+ * Based on Google's Material Design Icons taxonomy
+ */
+object NavigationIcons {
+ val icons =
+ listOf(
+ ProfileIcon("app_settings_alt", Icons.Filled.AppSettingsAlt, "App Settings"),
+ ProfileIcon("apps", Icons.Filled.Apps, "Apps"),
+ ProfileIcon("arrow_back", Icons.AutoMirrored.Filled.ArrowBack, "Arrow Back"),
+ ProfileIcon("arrow_back_ios", Icons.AutoMirrored.Filled.ArrowBackIos, "Back iOS"),
+ ProfileIcon("arrow_back_ios_new", Icons.Filled.ArrowBackIosNew, "Back iOS New"),
+ ProfileIcon("arrow_downward", Icons.Filled.ArrowDownward, "Arrow Down"),
+ ProfileIcon("arrow_drop_down", Icons.Filled.ArrowDropDown, "Drop Down"),
+ ProfileIcon("arrow_drop_down_circle", Icons.Filled.ArrowDropDownCircle, "Drop Down Circle"),
+ ProfileIcon("arrow_drop_up", Icons.Filled.ArrowDropUp, "Drop Up"),
+ ProfileIcon("arrow_forward", Icons.AutoMirrored.Filled.ArrowForward, "Arrow Forward"),
+ ProfileIcon("arrow_forward_ios", Icons.AutoMirrored.Filled.ArrowForwardIos, "Forward iOS"),
+ ProfileIcon("arrow_left", Icons.Filled.ArrowLeft, "Arrow Left"),
+ ProfileIcon("arrow_right", Icons.Filled.ArrowRight, "Arrow Right"),
+ ProfileIcon("arrow_right_alt", Icons.AutoMirrored.Filled.ArrowRightAlt, "Arrow Right Alt"),
+ ProfileIcon("arrow_upward", Icons.Filled.ArrowUpward, "Arrow Up"),
+ ProfileIcon("assistant_direction", Icons.Filled.AssistantDirection, "Assistant Direction"),
+ // ProfileIcon("assistant_navigation", Icons.Filled.AssistantNavigation, "Assistant Navigation"),
+ ProfileIcon("campaign", Icons.Filled.Campaign, "Campaign"),
+ ProfileIcon("cancel", Icons.Filled.Cancel, "Cancel"),
+ ProfileIcon("check", Icons.Filled.Check, "Check"),
+ ProfileIcon("chevron_left", Icons.Filled.ChevronLeft, "Chevron Left"),
+ ProfileIcon("chevron_right", Icons.Filled.ChevronRight, "Chevron Right"),
+ ProfileIcon("close", Icons.Filled.Close, "Close"),
+ ProfileIcon("double_arrow", Icons.Filled.DoubleArrow, "Double Arrow"),
+ ProfileIcon("east", Icons.Filled.East, "East"),
+ ProfileIcon("expand_circle_down", Icons.Filled.ExpandCircleDown, "Expand Circle Down"),
+ ProfileIcon("expand_less", Icons.Filled.ExpandLess, "Expand Less"),
+ ProfileIcon("expand_more", Icons.Filled.ExpandMore, "Expand More"),
+ ProfileIcon("first_page", Icons.Filled.FirstPage, "First Page"),
+ ProfileIcon("fullscreen", Icons.Filled.Fullscreen, "Fullscreen"),
+ ProfileIcon("fullscreen_exit", Icons.Filled.FullscreenExit, "Fullscreen Exit"),
+ ProfileIcon("home_work", Icons.Filled.HomeWork, "Home Work"),
+ ProfileIcon("last_page", Icons.Filled.LastPage, "Last Page"),
+ ProfileIcon("legend_toggle", Icons.Filled.LegendToggle, "Legend Toggle"),
+ ProfileIcon("live_tv", Icons.Filled.LiveTv, "Live TV"),
+ ProfileIcon("menu", Icons.Filled.Menu, "Menu"),
+ ProfileIcon("menu_book", Icons.AutoMirrored.Filled.MenuBook, "Menu Book"),
+ ProfileIcon("menu_open", Icons.AutoMirrored.Filled.MenuOpen, "Menu Open"),
+ ProfileIcon("more_horiz", Icons.Filled.MoreHoriz, "More Horizontal"),
+ ProfileIcon("more_vert", Icons.Filled.MoreVert, "More Vertical"),
+ ProfileIcon("north", Icons.Filled.North, "North"),
+ ProfileIcon("north_east", Icons.Filled.NorthEast, "North East"),
+ ProfileIcon("north_west", Icons.Filled.NorthWest, "North West"),
+ ProfileIcon("offline_share", Icons.Filled.OfflineShare, "Offline Share"),
+ ProfileIcon("payments", Icons.Filled.Payments, "Payments"),
+ ProfileIcon("pivot_table_chart", Icons.Filled.PivotTableChart, "Pivot Table"),
+ ProfileIcon("refresh", Icons.Filled.Refresh, "Refresh"),
+ ProfileIcon("south", Icons.Filled.South, "South"),
+ ProfileIcon("south_east", Icons.Filled.SouthEast, "South East"),
+ ProfileIcon("south_west", Icons.Filled.SouthWest, "South West"),
+ ProfileIcon(
+ "subdirectory_arrow_left",
+ Icons.Filled.SubdirectoryArrowLeft,
+ "Subdirectory Left",
+ ),
+ ProfileIcon(
+ "subdirectory_arrow_right",
+ Icons.Filled.SubdirectoryArrowRight,
+ "Subdirectory Right",
+ ),
+ ProfileIcon("switch_left", Icons.Filled.SwitchLeft, "Switch Left"),
+ ProfileIcon("switch_right", Icons.Filled.SwitchRight, "Switch Right"),
+ ProfileIcon("unfold_less", Icons.Filled.UnfoldLess, "Unfold Less"),
+ ProfileIcon("unfold_more", Icons.Filled.UnfoldMore, "Unfold More"),
+ ProfileIcon("waterfall_chart", Icons.Filled.WaterfallChart, "Waterfall Chart"),
+ ProfileIcon("west", Icons.Filled.West, "West"),
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NotificationIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NotificationIcons.kt
new file mode 100644
index 0000000..864f6c4
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NotificationIcons.kt
@@ -0,0 +1,186 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccountTree
+import androidx.compose.material.icons.filled.Adb
+import androidx.compose.material.icons.filled.AirlineSeatFlat
+import androidx.compose.material.icons.filled.AirlineSeatFlatAngled
+import androidx.compose.material.icons.filled.AirlineSeatIndividualSuite
+import androidx.compose.material.icons.filled.AirlineSeatLegroomExtra
+import androidx.compose.material.icons.filled.AirlineSeatLegroomNormal
+import androidx.compose.material.icons.filled.AirlineSeatLegroomReduced
+import androidx.compose.material.icons.filled.AirlineSeatReclineExtra
+import androidx.compose.material.icons.filled.AirlineSeatReclineNormal
+import androidx.compose.material.icons.filled.BluetoothAudio
+import androidx.compose.material.icons.filled.ConfirmationNumber
+import androidx.compose.material.icons.filled.DirectionsOff
+import androidx.compose.material.icons.filled.DiscFull
+import androidx.compose.material.icons.filled.DoDisturb
+import androidx.compose.material.icons.filled.DoDisturbAlt
+import androidx.compose.material.icons.filled.DoDisturbOff
+import androidx.compose.material.icons.filled.DoDisturbOn
+import androidx.compose.material.icons.filled.DoNotDisturb
+import androidx.compose.material.icons.filled.DoNotDisturbAlt
+import androidx.compose.material.icons.filled.DoNotDisturbOff
+import androidx.compose.material.icons.filled.DoNotDisturbOn
+import androidx.compose.material.icons.filled.DriveEta
+import androidx.compose.material.icons.filled.EnhancedEncryption
+import androidx.compose.material.icons.filled.EventAvailable
+import androidx.compose.material.icons.filled.EventBusy
+import androidx.compose.material.icons.filled.EventNote
+import androidx.compose.material.icons.filled.FolderSpecial
+import androidx.compose.material.icons.filled.ImagesearchRoller
+import androidx.compose.material.icons.filled.LiveTv
+import androidx.compose.material.icons.filled.Mms
+import androidx.compose.material.icons.filled.More
+import androidx.compose.material.icons.filled.NetworkCheck
+import androidx.compose.material.icons.filled.NetworkLocked
+import androidx.compose.material.icons.filled.NoEncryption
+import androidx.compose.material.icons.filled.NoEncryptionGmailerrorred
+import androidx.compose.material.icons.filled.OndemandVideo
+import androidx.compose.material.icons.filled.PersonalVideo
+import androidx.compose.material.icons.filled.PhoneBluetoothSpeaker
+import androidx.compose.material.icons.filled.PhoneCallback
+import androidx.compose.material.icons.filled.PhoneForwarded
+import androidx.compose.material.icons.filled.PhoneInTalk
+import androidx.compose.material.icons.filled.PhoneLocked
+import androidx.compose.material.icons.filled.PhoneMissed
+import androidx.compose.material.icons.filled.PhonePaused
+import androidx.compose.material.icons.filled.Power
+import androidx.compose.material.icons.filled.PowerOff
+import androidx.compose.material.icons.filled.PriorityHigh
+import androidx.compose.material.icons.filled.RunningWithErrors
+import androidx.compose.material.icons.filled.SdCardAlert
+import androidx.compose.material.icons.filled.SimCardAlert
+import androidx.compose.material.icons.filled.Sms
+import androidx.compose.material.icons.filled.SmsFailed
+import androidx.compose.material.icons.filled.SupportAgent
+import androidx.compose.material.icons.filled.Sync
+import androidx.compose.material.icons.filled.SyncDisabled
+import androidx.compose.material.icons.filled.SyncLock
+import androidx.compose.material.icons.filled.SyncProblem
+import androidx.compose.material.icons.filled.SystemUpdate
+import androidx.compose.material.icons.filled.TapAndPlay
+import androidx.compose.material.icons.filled.TimeToLeave
+import androidx.compose.material.icons.filled.TvOff
+import androidx.compose.material.icons.filled.Vibration
+import androidx.compose.material.icons.filled.VideoChat
+import androidx.compose.material.icons.filled.VoiceChat
+import androidx.compose.material.icons.filled.VpnLock
+import androidx.compose.material.icons.filled.Wc
+import androidx.compose.material.icons.filled.Wifi
+import androidx.compose.material.icons.filled.WifiCalling
+import androidx.compose.material.icons.filled.WifiOff
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * Notification category icons - Alerts and status updates
+ * Based on Google's Material Design Icons taxonomy
+ */
+object NotificationIcons {
+ val icons =
+ listOf(
+ ProfileIcon("account_tree", Icons.Filled.AccountTree, "Account Tree"),
+ ProfileIcon("adb", Icons.Filled.Adb, "ADB"),
+ ProfileIcon("airline_seat_flat", Icons.Filled.AirlineSeatFlat, "Seat Flat"),
+ ProfileIcon("airline_seat_flat_angled", Icons.Filled.AirlineSeatFlatAngled, "Seat Angled"),
+ ProfileIcon(
+ "airline_seat_individual_suite",
+ Icons.Filled.AirlineSeatIndividualSuite,
+ "Seat Suite",
+ ),
+ ProfileIcon(
+ "airline_seat_legroom_extra",
+ Icons.Filled.AirlineSeatLegroomExtra,
+ "Legroom Extra",
+ ),
+ ProfileIcon(
+ "airline_seat_legroom_normal",
+ Icons.Filled.AirlineSeatLegroomNormal,
+ "Legroom Normal",
+ ),
+ ProfileIcon(
+ "airline_seat_legroom_reduced",
+ Icons.Filled.AirlineSeatLegroomReduced,
+ "Legroom Reduced",
+ ),
+ ProfileIcon(
+ "airline_seat_recline_extra",
+ Icons.Filled.AirlineSeatReclineExtra,
+ "Recline Extra",
+ ),
+ ProfileIcon(
+ "airline_seat_recline_normal",
+ Icons.Filled.AirlineSeatReclineNormal,
+ "Recline Normal",
+ ),
+ ProfileIcon("bluetooth_audio", Icons.Filled.BluetoothAudio, "Bluetooth Audio"),
+ ProfileIcon("confirmation_number", Icons.Filled.ConfirmationNumber, "Confirmation Number"),
+ ProfileIcon("directions_off", Icons.Filled.DirectionsOff, "Directions Off"),
+ ProfileIcon("disc_full", Icons.Filled.DiscFull, "Disc Full"),
+ ProfileIcon("do_disturb", Icons.Filled.DoDisturb, "Do Disturb"),
+ ProfileIcon("do_disturb_alt", Icons.Filled.DoDisturbAlt, "Do Disturb Alt"),
+ ProfileIcon("do_disturb_off", Icons.Filled.DoDisturbOff, "Do Disturb Off"),
+ ProfileIcon("do_disturb_on", Icons.Filled.DoDisturbOn, "Do Disturb On"),
+ ProfileIcon("do_not_disturb", Icons.Filled.DoNotDisturb, "Do Not Disturb"),
+ ProfileIcon("do_not_disturb_alt", Icons.Filled.DoNotDisturbAlt, "DND Alt"),
+ ProfileIcon("do_not_disturb_off", Icons.Filled.DoNotDisturbOff, "DND Off"),
+ ProfileIcon("do_not_disturb_on", Icons.Filled.DoNotDisturbOn, "DND On"),
+ ProfileIcon("drive_eta", Icons.Filled.DriveEta, "Drive ETA"),
+ ProfileIcon("enhanced_encryption", Icons.Filled.EnhancedEncryption, "Enhanced Encryption"),
+ ProfileIcon("event_available", Icons.Filled.EventAvailable, "Event Available"),
+ ProfileIcon("event_busy", Icons.Filled.EventBusy, "Event Busy"),
+ ProfileIcon("event_note", Icons.Filled.EventNote, "Event Note"),
+ ProfileIcon("folder_special", Icons.Filled.FolderSpecial, "Folder Special"),
+ ProfileIcon("imagesearch_roller", Icons.Filled.ImagesearchRoller, "Image Search Roller"),
+ ProfileIcon("live_tv", Icons.Filled.LiveTv, "Live TV"),
+ ProfileIcon("mms", Icons.Filled.Mms, "MMS"),
+ ProfileIcon("more", Icons.Filled.More, "More"),
+ ProfileIcon("network_check", Icons.Filled.NetworkCheck, "Network Check"),
+ ProfileIcon("network_locked", Icons.Filled.NetworkLocked, "Network Locked"),
+ ProfileIcon("no_encryption", Icons.Filled.NoEncryption, "No Encryption"),
+ ProfileIcon(
+ "no_encryption_gmailerrorred",
+ Icons.Filled.NoEncryptionGmailerrorred,
+ "No Encryption Error",
+ ),
+ ProfileIcon("ondemand_video", Icons.Filled.OndemandVideo, "On Demand Video"),
+ ProfileIcon("personal_video", Icons.Filled.PersonalVideo, "Personal Video"),
+ ProfileIcon(
+ "phone_bluetooth_speaker",
+ Icons.Filled.PhoneBluetoothSpeaker,
+ "Phone Bluetooth",
+ ),
+ ProfileIcon("phone_callback", Icons.Filled.PhoneCallback, "Phone Callback"),
+ ProfileIcon("phone_forwarded", Icons.Filled.PhoneForwarded, "Phone Forwarded"),
+ ProfileIcon("phone_in_talk", Icons.Filled.PhoneInTalk, "Phone In Talk"),
+ ProfileIcon("phone_locked", Icons.Filled.PhoneLocked, "Phone Locked"),
+ ProfileIcon("phone_missed", Icons.Filled.PhoneMissed, "Phone Missed"),
+ ProfileIcon("phone_paused", Icons.Filled.PhonePaused, "Phone Paused"),
+ ProfileIcon("power", Icons.Filled.Power, "Power"),
+ ProfileIcon("power_off", Icons.Filled.PowerOff, "Power Off"),
+ ProfileIcon("priority_high", Icons.Filled.PriorityHigh, "Priority High"),
+ ProfileIcon("running_with_errors", Icons.Filled.RunningWithErrors, "Running With Errors"),
+ ProfileIcon("sd_card_alert", Icons.Filled.SdCardAlert, "SD Card Alert"),
+ ProfileIcon("sim_card_alert", Icons.Filled.SimCardAlert, "SIM Card Alert"),
+ ProfileIcon("sms", Icons.Filled.Sms, "SMS"),
+ ProfileIcon("sms_failed", Icons.Filled.SmsFailed, "SMS Failed"),
+ ProfileIcon("support_agent", Icons.Filled.SupportAgent, "Support Agent"),
+ ProfileIcon("sync", Icons.Filled.Sync, "Sync"),
+ ProfileIcon("sync_disabled", Icons.Filled.SyncDisabled, "Sync Disabled"),
+ ProfileIcon("sync_lock", Icons.Filled.SyncLock, "Sync Lock"),
+ ProfileIcon("sync_problem", Icons.Filled.SyncProblem, "Sync Problem"),
+ ProfileIcon("system_update", Icons.Filled.SystemUpdate, "System Update"),
+ ProfileIcon("tap_and_play", Icons.Filled.TapAndPlay, "Tap and Play"),
+ ProfileIcon("time_to_leave", Icons.Filled.TimeToLeave, "Time to Leave"),
+ ProfileIcon("tv_off", Icons.Filled.TvOff, "TV Off"),
+ ProfileIcon("vibration", Icons.Filled.Vibration, "Vibration"),
+ ProfileIcon("video_chat", Icons.Filled.VideoChat, "Video Chat"),
+ ProfileIcon("voice_chat", Icons.Filled.VoiceChat, "Voice Chat"),
+ ProfileIcon("vpn_lock", Icons.Filled.VpnLock, "VPN Lock"),
+ ProfileIcon("wc", Icons.Filled.Wc, "WC"),
+ ProfileIcon("wifi", Icons.Filled.Wifi, "WiFi"),
+ ProfileIcon("wifi_calling", Icons.Filled.WifiCalling, "WiFi Calling"),
+ ProfileIcon("wifi_off", Icons.Filled.WifiOff, "WiFi Off"),
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/PlacesIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/PlacesIcons.kt
new file mode 100644
index 0000000..46502bb
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/PlacesIcons.kt
@@ -0,0 +1,179 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AcUnit
+import androidx.compose.material.icons.filled.AirportShuttle
+import androidx.compose.material.icons.filled.AllInclusive
+import androidx.compose.material.icons.filled.Apartment
+import androidx.compose.material.icons.filled.BabyChangingStation
+import androidx.compose.material.icons.filled.Backpack
+import androidx.compose.material.icons.filled.Balcony
+import androidx.compose.material.icons.filled.Bathtub
+import androidx.compose.material.icons.filled.BeachAccess
+import androidx.compose.material.icons.filled.Bento
+import androidx.compose.material.icons.filled.Bungalow
+import androidx.compose.material.icons.filled.BusinessCenter
+import androidx.compose.material.icons.filled.Cabin
+import androidx.compose.material.icons.filled.Cake
+import androidx.compose.material.icons.filled.Casino
+import androidx.compose.material.icons.filled.Chalet
+import androidx.compose.material.icons.filled.ChargingStation
+import androidx.compose.material.icons.filled.Checkroom
+import androidx.compose.material.icons.filled.ChildCare
+import androidx.compose.material.icons.filled.ChildFriendly
+import androidx.compose.material.icons.filled.CorporateFare
+import androidx.compose.material.icons.filled.Cottage
+import androidx.compose.material.icons.filled.Countertops
+import androidx.compose.material.icons.filled.Crib
+import androidx.compose.material.icons.filled.Desk
+import androidx.compose.material.icons.filled.DoNotStep
+import androidx.compose.material.icons.filled.DoNotTouch
+import androidx.compose.material.icons.filled.Dry
+import androidx.compose.material.icons.filled.Elevator
+import androidx.compose.material.icons.filled.Escalator
+import androidx.compose.material.icons.filled.EscalatorWarning
+import androidx.compose.material.icons.filled.FamilyRestroom
+import androidx.compose.material.icons.filled.Fence
+import androidx.compose.material.icons.filled.FitnessCenter
+import androidx.compose.material.icons.filled.FoodBank
+import androidx.compose.material.icons.filled.Foundation
+import androidx.compose.material.icons.filled.FreeBreakfast
+import androidx.compose.material.icons.filled.Gite
+import androidx.compose.material.icons.filled.GolfCourse
+import androidx.compose.material.icons.filled.Grass
+import androidx.compose.material.icons.filled.HolidayVillage
+import androidx.compose.material.icons.filled.HotTub
+import androidx.compose.material.icons.filled.House
+import androidx.compose.material.icons.filled.HouseSiding
+import androidx.compose.material.icons.filled.Houseboat
+import androidx.compose.material.icons.filled.Iron
+import androidx.compose.material.icons.filled.Kitchen
+import androidx.compose.material.icons.filled.MeetingRoom
+import androidx.compose.material.icons.filled.Microwave
+import androidx.compose.material.icons.filled.NightShelter
+import androidx.compose.material.icons.filled.NoBackpack
+import androidx.compose.material.icons.filled.NoCell
+import androidx.compose.material.icons.filled.NoDrinks
+import androidx.compose.material.icons.filled.NoFlash
+import androidx.compose.material.icons.filled.NoFood
+import androidx.compose.material.icons.filled.NoMeetingRoom
+import androidx.compose.material.icons.filled.NoPhotography
+import androidx.compose.material.icons.filled.NoStroller
+import androidx.compose.material.icons.filled.OtherHouses
+import androidx.compose.material.icons.filled.Pool
+import androidx.compose.material.icons.filled.RiceBowl
+import androidx.compose.material.icons.filled.Roofing
+import androidx.compose.material.icons.filled.RoomPreferences
+import androidx.compose.material.icons.filled.RoomService
+import androidx.compose.material.icons.filled.RvHookup
+import androidx.compose.material.icons.filled.Shower
+import androidx.compose.material.icons.filled.SmokeFree
+import androidx.compose.material.icons.filled.SmokingRooms
+import androidx.compose.material.icons.filled.Soap
+import androidx.compose.material.icons.filled.Spa
+import androidx.compose.material.icons.filled.SportsBar
+import androidx.compose.material.icons.filled.Stairs
+import androidx.compose.material.icons.filled.Storefront
+import androidx.compose.material.icons.filled.Stroller
+import androidx.compose.material.icons.filled.Tapas
+import androidx.compose.material.icons.filled.Tty
+import androidx.compose.material.icons.filled.Umbrella
+import androidx.compose.material.icons.filled.VapingRooms
+import androidx.compose.material.icons.filled.Villa
+import androidx.compose.material.icons.filled.Wash
+import androidx.compose.material.icons.filled.WaterDamage
+import androidx.compose.material.icons.filled.WheelchairPickup
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * Places category icons - Locations and venues
+ * Based on Google's Material Design Icons taxonomy
+ */
+object PlacesIcons {
+ val icons =
+ listOf(
+ ProfileIcon("ac_unit", Icons.Filled.AcUnit, "AC Unit"),
+ ProfileIcon("airport_shuttle", Icons.Filled.AirportShuttle, "Airport Shuttle"),
+ ProfileIcon("all_inclusive", Icons.Filled.AllInclusive, "All Inclusive"),
+ ProfileIcon("apartment", Icons.Filled.Apartment, "Apartment"),
+ ProfileIcon("baby_changing_station", Icons.Filled.BabyChangingStation, "Baby Station"),
+ ProfileIcon("backpack", Icons.Filled.Backpack, "Backpack"),
+ ProfileIcon("balcony", Icons.Filled.Balcony, "Balcony"),
+ ProfileIcon("bathtub", Icons.Filled.Bathtub, "Bathtub"),
+ ProfileIcon("beach_access", Icons.Filled.BeachAccess, "Beach Access"),
+ ProfileIcon("bento", Icons.Filled.Bento, "Bento"),
+ ProfileIcon("bungalow", Icons.Filled.Bungalow, "Bungalow"),
+ ProfileIcon("business_center", Icons.Filled.BusinessCenter, "Business Center"),
+ ProfileIcon("cabin", Icons.Filled.Cabin, "Cabin"),
+ ProfileIcon("cake", Icons.Filled.Cake, "Cake"),
+ ProfileIcon("casino", Icons.Filled.Casino, "Casino"),
+ ProfileIcon("chalet", Icons.Filled.Chalet, "Chalet"),
+ ProfileIcon("charging_station", Icons.Filled.ChargingStation, "Charging Station"),
+ ProfileIcon("checkroom", Icons.Filled.Checkroom, "Checkroom"),
+ ProfileIcon("child_care", Icons.Filled.ChildCare, "Child Care"),
+ ProfileIcon("child_friendly", Icons.Filled.ChildFriendly, "Child Friendly"),
+ ProfileIcon("corporate_fare", Icons.Filled.CorporateFare, "Corporate Fare"),
+ ProfileIcon("cottage", Icons.Filled.Cottage, "Cottage"),
+ ProfileIcon("countertops", Icons.Filled.Countertops, "Countertops"),
+ ProfileIcon("crib", Icons.Filled.Crib, "Crib"),
+ ProfileIcon("desk", Icons.Filled.Desk, "Desk"),
+ ProfileIcon("do_not_step", Icons.Filled.DoNotStep, "Do Not Step"),
+ ProfileIcon("do_not_touch", Icons.Filled.DoNotTouch, "Do Not Touch"),
+ ProfileIcon("dry", Icons.Filled.Dry, "Dry"),
+ ProfileIcon("elevator", Icons.Filled.Elevator, "Elevator"),
+ ProfileIcon("escalator", Icons.Filled.Escalator, "Escalator"),
+ ProfileIcon("escalator_warning", Icons.Filled.EscalatorWarning, "Escalator Warning"),
+ ProfileIcon("family_restroom", Icons.Filled.FamilyRestroom, "Family Restroom"),
+ ProfileIcon("fence", Icons.Filled.Fence, "Fence"),
+ // ProfileIcon("fire_hydrant", Icons.Filled.FireHydrant, "Fire Hydrant"),
+ ProfileIcon("fitness_center", Icons.Filled.FitnessCenter, "Fitness Center"),
+ ProfileIcon("food_bank", Icons.Filled.FoodBank, "Food Bank"),
+ ProfileIcon("foundation", Icons.Filled.Foundation, "Foundation"),
+ ProfileIcon("free_breakfast", Icons.Filled.FreeBreakfast, "Free Breakfast"),
+ ProfileIcon("gite", Icons.Filled.Gite, "Gite"),
+ ProfileIcon("golf_course", Icons.Filled.GolfCourse, "Golf Course"),
+ ProfileIcon("grass", Icons.Filled.Grass, "Grass"),
+ ProfileIcon("holiday_village", Icons.Filled.HolidayVillage, "Holiday Village"),
+ ProfileIcon("hot_tub", Icons.Filled.HotTub, "Hot Tub"),
+ ProfileIcon("house", Icons.Filled.House, "House"),
+ ProfileIcon("house_siding", Icons.Filled.HouseSiding, "House Siding"),
+ ProfileIcon("houseboat", Icons.Filled.Houseboat, "Houseboat"),
+ ProfileIcon("iron", Icons.Filled.Iron, "Iron"),
+ ProfileIcon("kitchen", Icons.Filled.Kitchen, "Kitchen"),
+ ProfileIcon("meeting_room", Icons.Filled.MeetingRoom, "Meeting Room"),
+ ProfileIcon("microwave", Icons.Filled.Microwave, "Microwave"),
+ ProfileIcon("night_shelter", Icons.Filled.NightShelter, "Night Shelter"),
+ ProfileIcon("no_backpack", Icons.Filled.NoBackpack, "No Backpack"),
+ ProfileIcon("no_cell", Icons.Filled.NoCell, "No Cell"),
+ ProfileIcon("no_drinks", Icons.Filled.NoDrinks, "No Drinks"),
+ ProfileIcon("no_flash", Icons.Filled.NoFlash, "No Flash"),
+ ProfileIcon("no_food", Icons.Filled.NoFood, "No Food"),
+ ProfileIcon("no_meeting_room", Icons.Filled.NoMeetingRoom, "No Meeting Room"),
+ ProfileIcon("no_photography", Icons.Filled.NoPhotography, "No Photography"),
+ ProfileIcon("no_stroller", Icons.Filled.NoStroller, "No Stroller"),
+ ProfileIcon("other_houses", Icons.Filled.OtherHouses, "Other Houses"),
+ ProfileIcon("pool", Icons.Filled.Pool, "Pool"),
+ ProfileIcon("rice_bowl", Icons.Filled.RiceBowl, "Rice Bowl"),
+ ProfileIcon("roofing", Icons.Filled.Roofing, "Roofing"),
+ ProfileIcon("room_preferences", Icons.Filled.RoomPreferences, "Room Preferences"),
+ ProfileIcon("room_service", Icons.Filled.RoomService, "Room Service"),
+ ProfileIcon("rv_hookup", Icons.Filled.RvHookup, "RV Hookup"),
+ ProfileIcon("shower", Icons.Filled.Shower, "Shower"),
+ ProfileIcon("smoke_free", Icons.Filled.SmokeFree, "Smoke Free"),
+ ProfileIcon("smoking_rooms", Icons.Filled.SmokingRooms, "Smoking Rooms"),
+ ProfileIcon("soap", Icons.Filled.Soap, "Soap"),
+ ProfileIcon("spa", Icons.Filled.Spa, "Spa"),
+ ProfileIcon("sports_bar", Icons.Filled.SportsBar, "Sports Bar"),
+ ProfileIcon("stairs", Icons.Filled.Stairs, "Stairs"),
+ ProfileIcon("storefront", Icons.Filled.Storefront, "Storefront"),
+ ProfileIcon("stroller", Icons.Filled.Stroller, "Stroller"),
+ ProfileIcon("tapas", Icons.Filled.Tapas, "Tapas"),
+ ProfileIcon("tty", Icons.Filled.Tty, "TTY"),
+ ProfileIcon("umbrella", Icons.Filled.Umbrella, "Umbrella"),
+ ProfileIcon("vaping_rooms", Icons.Filled.VapingRooms, "Vaping Rooms"),
+ ProfileIcon("villa", Icons.Filled.Villa, "Villa"),
+ ProfileIcon("wash", Icons.Filled.Wash, "Wash"),
+ ProfileIcon("water_damage", Icons.Filled.WaterDamage, "Water Damage"),
+ ProfileIcon("wheelchair_pickup", Icons.Filled.WheelchairPickup, "Wheelchair Pickup"),
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/SocialIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/SocialIcons.kt
new file mode 100644
index 0000000..160dca9
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/SocialIcons.kt
@@ -0,0 +1,422 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AddModerator
+import androidx.compose.material.icons.filled.AddReaction
+import androidx.compose.material.icons.filled.Architecture
+import androidx.compose.material.icons.filled.AssistWalker
+import androidx.compose.material.icons.filled.BackHand
+import androidx.compose.material.icons.filled.Blind
+import androidx.compose.material.icons.filled.Boy
+import androidx.compose.material.icons.filled.Cake
+import androidx.compose.material.icons.filled.CatchingPokemon
+import androidx.compose.material.icons.filled.CleanHands
+import androidx.compose.material.icons.filled.Co2
+import androidx.compose.material.icons.filled.Compost
+import androidx.compose.material.icons.filled.ConnectWithoutContact
+import androidx.compose.material.icons.filled.Construction
+import androidx.compose.material.icons.filled.Cookie
+import androidx.compose.material.icons.filled.Coronavirus
+import androidx.compose.material.icons.filled.CrueltyFree
+import androidx.compose.material.icons.filled.Cyclone
+import androidx.compose.material.icons.filled.Deck
+import androidx.compose.material.icons.filled.Diversity1
+import androidx.compose.material.icons.filled.Diversity2
+import androidx.compose.material.icons.filled.Diversity3
+import androidx.compose.material.icons.filled.Domain
+import androidx.compose.material.icons.filled.DomainAdd
+import androidx.compose.material.icons.filled.DownhillSkiing
+import androidx.compose.material.icons.filled.EditNotifications
+import androidx.compose.material.icons.filled.Elderly
+import androidx.compose.material.icons.filled.ElderlyWoman
+import androidx.compose.material.icons.filled.EmojiEmotions
+import androidx.compose.material.icons.filled.EmojiEvents
+import androidx.compose.material.icons.filled.EmojiFlags
+import androidx.compose.material.icons.filled.EmojiFoodBeverage
+import androidx.compose.material.icons.filled.EmojiNature
+import androidx.compose.material.icons.filled.EmojiObjects
+import androidx.compose.material.icons.filled.EmojiPeople
+import androidx.compose.material.icons.filled.EmojiSymbols
+import androidx.compose.material.icons.filled.EmojiTransportation
+import androidx.compose.material.icons.filled.Engineering
+import androidx.compose.material.icons.filled.Face
+import androidx.compose.material.icons.filled.Face2
+import androidx.compose.material.icons.filled.Face3
+import androidx.compose.material.icons.filled.Face4
+import androidx.compose.material.icons.filled.Face5
+import androidx.compose.material.icons.filled.Face6
+import androidx.compose.material.icons.filled.Facebook
+import androidx.compose.material.icons.filled.Female
+import androidx.compose.material.icons.filled.Fireplace
+import androidx.compose.material.icons.filled.Fitbit
+import androidx.compose.material.icons.filled.Flood
+import androidx.compose.material.icons.filled.FollowTheSigns
+import androidx.compose.material.icons.filled.FrontHand
+import androidx.compose.material.icons.filled.Girl
+import androidx.compose.material.icons.filled.Group
+import androidx.compose.material.icons.filled.GroupAdd
+import androidx.compose.material.icons.filled.GroupOff
+import androidx.compose.material.icons.filled.GroupRemove
+import androidx.compose.material.icons.filled.Groups
+import androidx.compose.material.icons.filled.Groups2
+import androidx.compose.material.icons.filled.Groups3
+import androidx.compose.material.icons.filled.Handshake
+import androidx.compose.material.icons.filled.HealthAndSafety
+import androidx.compose.material.icons.filled.HeartBroken
+import androidx.compose.material.icons.filled.Hiking
+import androidx.compose.material.icons.filled.HistoryEdu
+import androidx.compose.material.icons.filled.Hive
+import androidx.compose.material.icons.filled.IceSkating
+import androidx.compose.material.icons.filled.Interests
+import androidx.compose.material.icons.filled.IosShare
+import androidx.compose.material.icons.filled.Kayaking
+import androidx.compose.material.icons.filled.KingBed
+import androidx.compose.material.icons.filled.Kitesurfing
+import androidx.compose.material.icons.filled.Landslide
+import androidx.compose.material.icons.filled.LocationCity
+import androidx.compose.material.icons.filled.Luggage
+import androidx.compose.material.icons.filled.Male
+import androidx.compose.material.icons.filled.Man
+import androidx.compose.material.icons.filled.Man2
+import androidx.compose.material.icons.filled.Man3
+import androidx.compose.material.icons.filled.Man4
+import androidx.compose.material.icons.filled.Masks
+import androidx.compose.material.icons.filled.MilitaryTech
+import androidx.compose.material.icons.filled.Mood
+import androidx.compose.material.icons.filled.MoodBad
+import androidx.compose.material.icons.filled.NightsStay
+import androidx.compose.material.icons.filled.NoAdultContent
+import androidx.compose.material.icons.filled.NoLuggage
+import androidx.compose.material.icons.filled.NordicWalking
+import androidx.compose.material.icons.filled.Notifications
+import androidx.compose.material.icons.filled.NotificationsActive
+import androidx.compose.material.icons.filled.NotificationsNone
+import androidx.compose.material.icons.filled.NotificationsOff
+import androidx.compose.material.icons.filled.NotificationsPaused
+import androidx.compose.material.icons.filled.OutdoorGrill
+import androidx.compose.material.icons.filled.Pages
+import androidx.compose.material.icons.filled.Paragliding
+import androidx.compose.material.icons.filled.PartyMode
+import androidx.compose.material.icons.filled.People
+import androidx.compose.material.icons.filled.PeopleAlt
+import androidx.compose.material.icons.filled.PeopleOutline
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Person2
+import androidx.compose.material.icons.filled.Person3
+import androidx.compose.material.icons.filled.Person4
+import androidx.compose.material.icons.filled.PersonAdd
+import androidx.compose.material.icons.filled.PersonAddAlt
+import androidx.compose.material.icons.filled.PersonAddAlt1
+import androidx.compose.material.icons.filled.PersonOff
+import androidx.compose.material.icons.filled.PersonOutline
+import androidx.compose.material.icons.filled.PersonRemove
+import androidx.compose.material.icons.filled.PersonRemoveAlt1
+import androidx.compose.material.icons.filled.PersonalInjury
+import androidx.compose.material.icons.filled.Piano
+import androidx.compose.material.icons.filled.PianoOff
+import androidx.compose.material.icons.filled.Pix
+import androidx.compose.material.icons.filled.PlusOne
+import androidx.compose.material.icons.filled.Poll
+import androidx.compose.material.icons.filled.PrecisionManufacturing
+import androidx.compose.material.icons.filled.Psychology
+import androidx.compose.material.icons.filled.PsychologyAlt
+import androidx.compose.material.icons.filled.Public
+import androidx.compose.material.icons.filled.PublicOff
+import androidx.compose.material.icons.filled.RealEstateAgent
+import androidx.compose.material.icons.filled.Recommend
+import androidx.compose.material.icons.filled.Recycling
+import androidx.compose.material.icons.filled.ReduceCapacity
+import androidx.compose.material.icons.filled.RemoveModerator
+import androidx.compose.material.icons.filled.RollerSkating
+import androidx.compose.material.icons.filled.SafetyDivider
+import androidx.compose.material.icons.filled.Sanitizer
+import androidx.compose.material.icons.filled.Scale
+import androidx.compose.material.icons.filled.School
+import androidx.compose.material.icons.filled.Science
+import androidx.compose.material.icons.filled.Scoreboard
+import androidx.compose.material.icons.filled.ScubaDiving
+import androidx.compose.material.icons.filled.SelfImprovement
+import androidx.compose.material.icons.filled.SentimentDissatisfied
+import androidx.compose.material.icons.filled.SentimentNeutral
+import androidx.compose.material.icons.filled.SentimentSatisfied
+import androidx.compose.material.icons.filled.SentimentSatisfiedAlt
+import androidx.compose.material.icons.filled.SentimentVeryDissatisfied
+import androidx.compose.material.icons.filled.SentimentVerySatisfied
+import androidx.compose.material.icons.filled.SevereCold
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material.icons.filled.Sick
+import androidx.compose.material.icons.filled.SignLanguage
+import androidx.compose.material.icons.filled.SingleBed
+import androidx.compose.material.icons.filled.Skateboarding
+import androidx.compose.material.icons.filled.Sledding
+import androidx.compose.material.icons.filled.Snowboarding
+import androidx.compose.material.icons.filled.Snowshoeing
+import androidx.compose.material.icons.filled.SocialDistance
+import androidx.compose.material.icons.filled.SouthAmerica
+import androidx.compose.material.icons.filled.Sports
+import androidx.compose.material.icons.filled.SportsBaseball
+import androidx.compose.material.icons.filled.SportsBasketball
+import androidx.compose.material.icons.filled.SportsCricket
+import androidx.compose.material.icons.filled.SportsEsports
+import androidx.compose.material.icons.filled.SportsFootball
+import androidx.compose.material.icons.filled.SportsGolf
+import androidx.compose.material.icons.filled.SportsGymnastics
+import androidx.compose.material.icons.filled.SportsHandball
+import androidx.compose.material.icons.filled.SportsHockey
+import androidx.compose.material.icons.filled.SportsKabaddi
+import androidx.compose.material.icons.filled.SportsMartialArts
+import androidx.compose.material.icons.filled.SportsMma
+import androidx.compose.material.icons.filled.SportsMotorsports
+import androidx.compose.material.icons.filled.SportsRugby
+import androidx.compose.material.icons.filled.SportsSoccer
+import androidx.compose.material.icons.filled.SportsTennis
+import androidx.compose.material.icons.filled.SportsVolleyball
+import androidx.compose.material.icons.filled.Surfing
+import androidx.compose.material.icons.filled.SwitchAccount
+import androidx.compose.material.icons.filled.ThumbDownAlt
+import androidx.compose.material.icons.filled.ThumbUpAlt
+import androidx.compose.material.icons.filled.Thunderstorm
+import androidx.compose.material.icons.filled.Tornado
+import androidx.compose.material.icons.filled.Transgender
+import androidx.compose.material.icons.filled.TravelExplore
+import androidx.compose.material.icons.filled.Tsunami
+import androidx.compose.material.icons.filled.Vaccines
+import androidx.compose.material.icons.filled.Volcano
+import androidx.compose.material.icons.filled.Wallet
+import androidx.compose.material.icons.filled.WaterDrop
+import androidx.compose.material.icons.filled.WavingHand
+import androidx.compose.material.icons.filled.Whatshot
+import androidx.compose.material.icons.filled.Woman
+import androidx.compose.material.icons.filled.Woman2
+import androidx.compose.material.icons.filled.WorkspacePremium
+import androidx.compose.material.icons.filled.Workspaces
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * Social category icons - Social media and sharing
+ * Based on Google's Material Design Icons taxonomy
+ */
+object SocialIcons {
+ val icons =
+ listOf(
+ // ProfileIcon("6_ft_apart", Icons.Filled.SixFtApart, "6 Ft Apart"),
+ ProfileIcon("add_moderator", Icons.Filled.AddModerator, "Add Moderator"),
+ ProfileIcon("add_reaction", Icons.Filled.AddReaction, "Add Reaction"),
+ ProfileIcon("architecture", Icons.Filled.Architecture, "Architecture"),
+ ProfileIcon("assist_walker", Icons.Filled.AssistWalker, "Assist Walker"),
+ ProfileIcon("back_hand", Icons.Filled.BackHand, "Back Hand"),
+ ProfileIcon("blind", Icons.Filled.Blind, "Blind"),
+ ProfileIcon("boy", Icons.Filled.Boy, "Boy"),
+ ProfileIcon("cake", Icons.Filled.Cake, "Cake"),
+ ProfileIcon("catching_pokemon", Icons.Filled.CatchingPokemon, "Catching Pokemon"),
+ ProfileIcon("clean_hands", Icons.Filled.CleanHands, "Clean Hands"),
+ ProfileIcon("co2", Icons.Filled.Co2, "CO2"),
+ ProfileIcon("compost", Icons.Filled.Compost, "Compost"),
+ ProfileIcon(
+ "connect_without_contact",
+ Icons.Filled.ConnectWithoutContact,
+ "Connect Without Contact",
+ ),
+ ProfileIcon("construction", Icons.Filled.Construction, "Construction"),
+ ProfileIcon("cookie", Icons.Filled.Cookie, "Cookie"),
+ ProfileIcon("coronavirus", Icons.Filled.Coronavirus, "Coronavirus"),
+ ProfileIcon("cruelty_free", Icons.Filled.CrueltyFree, "Cruelty Free"),
+ ProfileIcon("cyclone", Icons.Filled.Cyclone, "Cyclone"),
+ ProfileIcon("deck", Icons.Filled.Deck, "Deck"),
+ ProfileIcon("diversity_1", Icons.Filled.Diversity1, "Diversity 1"),
+ ProfileIcon("diversity_2", Icons.Filled.Diversity2, "Diversity 2"),
+ ProfileIcon("diversity_3", Icons.Filled.Diversity3, "Diversity 3"),
+ ProfileIcon("domain", Icons.Filled.Domain, "Domain"),
+ ProfileIcon("domain_add", Icons.Filled.DomainAdd, "Domain Add"),
+ ProfileIcon("downhill_skiing", Icons.Filled.DownhillSkiing, "Downhill Skiing"),
+ ProfileIcon("edit_notifications", Icons.Filled.EditNotifications, "Edit Notifications"),
+ ProfileIcon("elderly", Icons.Filled.Elderly, "Elderly"),
+ ProfileIcon("elderly_woman", Icons.Filled.ElderlyWoman, "Elderly Woman"),
+ ProfileIcon("emoji_emotions", Icons.Filled.EmojiEmotions, "Emoji Emotions"),
+ ProfileIcon("emoji_events", Icons.Filled.EmojiEvents, "Emoji Events"),
+ ProfileIcon("emoji_flags", Icons.Filled.EmojiFlags, "Emoji Flags"),
+ ProfileIcon("emoji_food_beverage", Icons.Filled.EmojiFoodBeverage, "Food Beverage"),
+ ProfileIcon("emoji_nature", Icons.Filled.EmojiNature, "Emoji Nature"),
+ ProfileIcon("emoji_objects", Icons.Filled.EmojiObjects, "Emoji Objects"),
+ ProfileIcon("emoji_people", Icons.Filled.EmojiPeople, "Emoji People"),
+ ProfileIcon("emoji_symbols", Icons.Filled.EmojiSymbols, "Emoji Symbols"),
+ ProfileIcon(
+ "emoji_transportation",
+ Icons.Filled.EmojiTransportation,
+ "Emoji Transportation",
+ ),
+ ProfileIcon("engineering", Icons.Filled.Engineering, "Engineering"),
+ ProfileIcon("face", Icons.Filled.Face, "Face"),
+ ProfileIcon("face_2", Icons.Filled.Face2, "Face 2"),
+ ProfileIcon("face_3", Icons.Filled.Face3, "Face 3"),
+ ProfileIcon("face_4", Icons.Filled.Face4, "Face 4"),
+ ProfileIcon("face_5", Icons.Filled.Face5, "Face 5"),
+ ProfileIcon("face_6", Icons.Filled.Face6, "Face 6"),
+ ProfileIcon("facebook", Icons.Filled.Facebook, "Facebook"),
+ ProfileIcon("female", Icons.Filled.Female, "Female"),
+ ProfileIcon("fireplace", Icons.Filled.Fireplace, "Fireplace"),
+ ProfileIcon("fitbit", Icons.Filled.Fitbit, "Fitbit"),
+ ProfileIcon("flood", Icons.Filled.Flood, "Flood"),
+ ProfileIcon("follow_the_signs", Icons.Filled.FollowTheSigns, "Follow Signs"),
+ ProfileIcon("front_hand", Icons.Filled.FrontHand, "Front Hand"),
+ ProfileIcon("girl", Icons.Filled.Girl, "Girl"),
+ ProfileIcon("group", Icons.Filled.Group, "Group"),
+ ProfileIcon("group_add", Icons.Filled.GroupAdd, "Group Add"),
+ ProfileIcon("group_off", Icons.Filled.GroupOff, "Group Off"),
+ ProfileIcon("group_remove", Icons.Filled.GroupRemove, "Group Remove"),
+ ProfileIcon("groups", Icons.Filled.Groups, "Groups"),
+ ProfileIcon("groups_2", Icons.Filled.Groups2, "Groups 2"),
+ ProfileIcon("groups_3", Icons.Filled.Groups3, "Groups 3"),
+ ProfileIcon("handshake", Icons.Filled.Handshake, "Handshake"),
+ ProfileIcon("health_and_safety", Icons.Filled.HealthAndSafety, "Health Safety"),
+ ProfileIcon("heart_broken", Icons.Filled.HeartBroken, "Heart Broken"),
+ ProfileIcon("hiking", Icons.Filled.Hiking, "Hiking"),
+ ProfileIcon("history_edu", Icons.Filled.HistoryEdu, "History Edu"),
+ ProfileIcon("hive", Icons.Filled.Hive, "Hive"),
+ ProfileIcon("ice_skating", Icons.Filled.IceSkating, "Ice Skating"),
+ ProfileIcon("interests", Icons.Filled.Interests, "Interests"),
+ ProfileIcon("ios_share", Icons.Filled.IosShare, "iOS Share"),
+ ProfileIcon("kayaking", Icons.Filled.Kayaking, "Kayaking"),
+ ProfileIcon("king_bed", Icons.Filled.KingBed, "King Bed"),
+ ProfileIcon("kitesurfing", Icons.Filled.Kitesurfing, "Kitesurfing"),
+ ProfileIcon("landslide", Icons.Filled.Landslide, "Landslide"),
+ ProfileIcon("location_city", Icons.Filled.LocationCity, "Location City"),
+ ProfileIcon("luggage", Icons.Filled.Luggage, "Luggage"),
+ ProfileIcon("male", Icons.Filled.Male, "Male"),
+ ProfileIcon("man", Icons.Filled.Man, "Man"),
+ ProfileIcon("man_2", Icons.Filled.Man2, "Man 2"),
+ ProfileIcon("man_3", Icons.Filled.Man3, "Man 3"),
+ ProfileIcon("man_4", Icons.Filled.Man4, "Man 4"),
+ ProfileIcon("masks", Icons.Filled.Masks, "Masks"),
+ ProfileIcon("military_tech", Icons.Filled.MilitaryTech, "Military Tech"),
+ ProfileIcon("mood", Icons.Filled.Mood, "Mood"),
+ ProfileIcon("mood_bad", Icons.Filled.MoodBad, "Mood Bad"),
+ ProfileIcon("nights_stay", Icons.Filled.NightsStay, "Nights Stay"),
+ ProfileIcon("no_adult_content", Icons.Filled.NoAdultContent, "No Adult Content"),
+ ProfileIcon("no_luggage", Icons.Filled.NoLuggage, "No Luggage"),
+ ProfileIcon("nordic_walking", Icons.Filled.NordicWalking, "Nordic Walking"),
+ ProfileIcon("notifications", Icons.Filled.Notifications, "Notifications"),
+ ProfileIcon(
+ "notifications_active",
+ Icons.Filled.NotificationsActive,
+ "Notifications Active",
+ ),
+ ProfileIcon("notifications_none", Icons.Filled.NotificationsNone, "Notifications None"),
+ ProfileIcon("notifications_off", Icons.Filled.NotificationsOff, "Notifications Off"),
+ ProfileIcon(
+ "notifications_paused",
+ Icons.Filled.NotificationsPaused,
+ "Notifications Paused",
+ ),
+ ProfileIcon("outdoor_grill", Icons.Filled.OutdoorGrill, "Outdoor Grill"),
+ ProfileIcon("pages", Icons.Filled.Pages, "Pages"),
+ ProfileIcon("paragliding", Icons.Filled.Paragliding, "Paragliding"),
+ ProfileIcon("party_mode", Icons.Filled.PartyMode, "Party Mode"),
+ ProfileIcon("people", Icons.Filled.People, "People"),
+ ProfileIcon("people_alt", Icons.Filled.PeopleAlt, "People Alt"),
+ ProfileIcon("people_outline", Icons.Filled.PeopleOutline, "People Outline"),
+ ProfileIcon("person", Icons.Filled.Person, "Person"),
+ ProfileIcon("person_2", Icons.Filled.Person2, "Person 2"),
+ ProfileIcon("person_3", Icons.Filled.Person3, "Person 3"),
+ ProfileIcon("person_4", Icons.Filled.Person4, "Person 4"),
+ ProfileIcon("person_add", Icons.Filled.PersonAdd, "Person Add"),
+ ProfileIcon("person_add_alt", Icons.Filled.PersonAddAlt, "Person Add Alt"),
+ ProfileIcon("person_add_alt_1", Icons.Filled.PersonAddAlt1, "Person Add Alt 1"),
+ ProfileIcon("person_off", Icons.Filled.PersonOff, "Person Off"),
+ ProfileIcon("person_outline", Icons.Filled.PersonOutline, "Person Outline"),
+ ProfileIcon("person_remove", Icons.Filled.PersonRemove, "Person Remove"),
+ ProfileIcon("person_remove_alt_1", Icons.Filled.PersonRemoveAlt1, "Person Remove Alt"),
+ ProfileIcon("personal_injury", Icons.Filled.PersonalInjury, "Personal Injury"),
+ ProfileIcon("piano", Icons.Filled.Piano, "Piano"),
+ ProfileIcon("piano_off", Icons.Filled.PianoOff, "Piano Off"),
+ ProfileIcon("pix", Icons.Filled.Pix, "Pix"),
+ ProfileIcon("plus_one", Icons.Filled.PlusOne, "Plus One"),
+ ProfileIcon("poll", Icons.Filled.Poll, "Poll"),
+ ProfileIcon(
+ "precision_manufacturing",
+ Icons.Filled.PrecisionManufacturing,
+ "Precision Manufacturing",
+ ),
+ ProfileIcon("psychology", Icons.Filled.Psychology, "Psychology"),
+ ProfileIcon("psychology_alt", Icons.Filled.PsychologyAlt, "Psychology Alt"),
+ ProfileIcon("public", Icons.Filled.Public, "Public"),
+ ProfileIcon("public_off", Icons.Filled.PublicOff, "Public Off"),
+ ProfileIcon("real_estate_agent", Icons.Filled.RealEstateAgent, "Real Estate Agent"),
+ ProfileIcon("recommend", Icons.Filled.Recommend, "Recommend"),
+ ProfileIcon("recycling", Icons.Filled.Recycling, "Recycling"),
+ ProfileIcon("reduce_capacity", Icons.Filled.ReduceCapacity, "Reduce Capacity"),
+ ProfileIcon("remove_moderator", Icons.Filled.RemoveModerator, "Remove Moderator"),
+ ProfileIcon("roller_skating", Icons.Filled.RollerSkating, "Roller Skating"),
+ ProfileIcon("safety_divider", Icons.Filled.SafetyDivider, "Safety Divider"),
+ ProfileIcon("sanitizer", Icons.Filled.Sanitizer, "Sanitizer"),
+ ProfileIcon("scale", Icons.Filled.Scale, "Scale"),
+ ProfileIcon("school", Icons.Filled.School, "School"),
+ ProfileIcon("science", Icons.Filled.Science, "Science"),
+ ProfileIcon("scoreboard", Icons.Filled.Scoreboard, "Scoreboard"),
+ ProfileIcon("scuba_diving", Icons.Filled.ScubaDiving, "Scuba Diving"),
+ ProfileIcon("self_improvement", Icons.Filled.SelfImprovement, "Self Improvement"),
+ ProfileIcon("sentiment_dissatisfied", Icons.Filled.SentimentDissatisfied, "Dissatisfied"),
+ ProfileIcon("sentiment_neutral", Icons.Filled.SentimentNeutral, "Neutral"),
+ ProfileIcon("sentiment_satisfied", Icons.Filled.SentimentSatisfied, "Satisfied"),
+ ProfileIcon("sentiment_satisfied_alt", Icons.Filled.SentimentSatisfiedAlt, "Satisfied Alt"),
+ ProfileIcon(
+ "sentiment_very_dissatisfied",
+ Icons.Filled.SentimentVeryDissatisfied,
+ "Very Dissatisfied",
+ ),
+ ProfileIcon(
+ "sentiment_very_satisfied",
+ Icons.Filled.SentimentVerySatisfied,
+ "Very Satisfied",
+ ),
+ ProfileIcon("severe_cold", Icons.Filled.SevereCold, "Severe Cold"),
+ ProfileIcon("share", Icons.Filled.Share, "Share"),
+ ProfileIcon("sick", Icons.Filled.Sick, "Sick"),
+ ProfileIcon("sign_language", Icons.Filled.SignLanguage, "Sign Language"),
+ ProfileIcon("single_bed", Icons.Filled.SingleBed, "Single Bed"),
+ ProfileIcon("skateboarding", Icons.Filled.Skateboarding, "Skateboarding"),
+ ProfileIcon("sledding", Icons.Filled.Sledding, "Sledding"),
+ ProfileIcon("snowboarding", Icons.Filled.Snowboarding, "Snowboarding"),
+ ProfileIcon("snowshoeing", Icons.Filled.Snowshoeing, "Snowshoeing"),
+ ProfileIcon("social_distance", Icons.Filled.SocialDistance, "Social Distance"),
+ ProfileIcon("south_america", Icons.Filled.SouthAmerica, "South America"),
+ ProfileIcon("sports", Icons.Filled.Sports, "Sports"),
+ ProfileIcon("sports_baseball", Icons.Filled.SportsBaseball, "Baseball"),
+ ProfileIcon("sports_basketball", Icons.Filled.SportsBasketball, "Basketball"),
+ ProfileIcon("sports_cricket", Icons.Filled.SportsCricket, "Cricket"),
+ ProfileIcon("sports_esports", Icons.Filled.SportsEsports, "Esports"),
+ ProfileIcon("sports_football", Icons.Filled.SportsFootball, "Football"),
+ ProfileIcon("sports_golf", Icons.Filled.SportsGolf, "Golf"),
+ ProfileIcon("sports_gymnastics", Icons.Filled.SportsGymnastics, "Gymnastics"),
+ ProfileIcon("sports_handball", Icons.Filled.SportsHandball, "Handball"),
+ ProfileIcon("sports_hockey", Icons.Filled.SportsHockey, "Hockey"),
+ ProfileIcon("sports_kabaddi", Icons.Filled.SportsKabaddi, "Kabaddi"),
+ ProfileIcon("sports_martial_arts", Icons.Filled.SportsMartialArts, "Martial Arts"),
+ ProfileIcon("sports_mma", Icons.Filled.SportsMma, "MMA"),
+ ProfileIcon("sports_motorsports", Icons.Filled.SportsMotorsports, "Motorsports"),
+ ProfileIcon("sports_rugby", Icons.Filled.SportsRugby, "Rugby"),
+ ProfileIcon("sports_soccer", Icons.Filled.SportsSoccer, "Soccer"),
+ ProfileIcon("sports_tennis", Icons.Filled.SportsTennis, "Tennis"),
+ ProfileIcon("sports_volleyball", Icons.Filled.SportsVolleyball, "Volleyball"),
+ ProfileIcon("surfing", Icons.Filled.Surfing, "Surfing"),
+ ProfileIcon("switch_account", Icons.Filled.SwitchAccount, "Switch Account"),
+ ProfileIcon("thumb_down_alt", Icons.Filled.ThumbDownAlt, "Thumb Down Alt"),
+ ProfileIcon("thumb_up_alt", Icons.Filled.ThumbUpAlt, "Thumb Up Alt"),
+ ProfileIcon("thunderstorm", Icons.Filled.Thunderstorm, "Thunderstorm"),
+ ProfileIcon("tornado", Icons.Filled.Tornado, "Tornado"),
+ ProfileIcon("transgender", Icons.Filled.Transgender, "Transgender"),
+ ProfileIcon("travel_explore", Icons.Filled.TravelExplore, "Travel Explore"),
+ ProfileIcon("tsunami", Icons.Filled.Tsunami, "Tsunami"),
+ ProfileIcon("vaccines", Icons.Filled.Vaccines, "Vaccines"),
+ ProfileIcon("volcano", Icons.Filled.Volcano, "Volcano"),
+ ProfileIcon("wallet", Icons.Filled.Wallet, "Wallet"),
+ ProfileIcon("water_drop", Icons.Filled.WaterDrop, "Water Drop"),
+ ProfileIcon("waving_hand", Icons.Filled.WavingHand, "Waving Hand"),
+ // ProfileIcon("whatsapp", Icons.Filled.WhatsApp, "WhatsApp"),
+ ProfileIcon("whatshot", Icons.Filled.Whatshot, "Whatshot"),
+ ProfileIcon("woman", Icons.Filled.Woman, "Woman"),
+ ProfileIcon("woman_2", Icons.Filled.Woman2, "Woman 2"),
+ ProfileIcon("workspace_premium", Icons.Filled.WorkspacePremium, "Workspace Premium"),
+ ProfileIcon("workspaces", Icons.Filled.Workspaces, "Workspaces"),
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ToggleIcons.kt b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ToggleIcons.kt
new file mode 100644
index 0000000..f147d27
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ToggleIcons.kt
@@ -0,0 +1,44 @@
+package io.nekohasekai.sfa.compose.util.icons
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckBox
+import androidx.compose.material.icons.filled.CheckBoxOutlineBlank
+import androidx.compose.material.icons.filled.IndeterminateCheckBox
+import androidx.compose.material.icons.filled.RadioButtonChecked
+import androidx.compose.material.icons.filled.RadioButtonUnchecked
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material.icons.filled.StarBorder
+import androidx.compose.material.icons.filled.StarBorderPurple500
+import androidx.compose.material.icons.filled.StarHalf
+import androidx.compose.material.icons.filled.StarOutline
+import androidx.compose.material.icons.filled.StarPurple500
+import androidx.compose.material.icons.filled.ToggleOff
+import androidx.compose.material.icons.filled.ToggleOn
+import io.nekohasekai.sfa.compose.util.ProfileIcon
+
+/**
+ * Toggle category icons - Switches and toggles
+ * Based on Google's Material Design Icons taxonomy
+ */
+object ToggleIcons {
+ val icons =
+ listOf(
+ ProfileIcon("check_box", Icons.Filled.CheckBox, "Check Box"),
+ ProfileIcon(
+ "check_box_outline_blank",
+ Icons.Filled.CheckBoxOutlineBlank,
+ "Check Box Blank",
+ ),
+ ProfileIcon("indeterminate_check_box", Icons.Filled.IndeterminateCheckBox, "Indeterminate"),
+ ProfileIcon("radio_button_checked", Icons.Filled.RadioButtonChecked, "Radio Checked"),
+ ProfileIcon("radio_button_unchecked", Icons.Filled.RadioButtonUnchecked, "Radio Unchecked"),
+ ProfileIcon("star", Icons.Filled.Star, "Star"),
+ ProfileIcon("star_border", Icons.Filled.StarBorder, "Star Border"),
+ ProfileIcon("star_border_purple500", Icons.Filled.StarBorderPurple500, "Star Purple"),
+ ProfileIcon("star_half", Icons.Filled.StarHalf, "Star Half"),
+ ProfileIcon("star_outline", Icons.Filled.StarOutline, "Star Outline"),
+ ProfileIcon("star_purple500", Icons.Filled.StarPurple500, "Star Purple"),
+ ProfileIcon("toggle_off", Icons.Filled.ToggleOff, "Toggle Off"),
+ ProfileIcon("toggle_on", Icons.Filled.ToggleOn, "Toggle On"),
+ )
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt b/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt
index c0bb947..fc9e41e 100644
--- a/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt
@@ -4,4 +4,4 @@ object Action {
const val SERVICE = "io.nekohasekai.sfa.SERVICE"
const val SERVICE_CLOSE = "io.nekohasekai.sfa.SERVICE_CLOSE"
const val OPEN_URL = "io.nekohasekai.sfa.SERVICE_OPEN_URL"
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt b/app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt
index ce0c418..d2c9882 100644
--- a/app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt
@@ -7,5 +7,5 @@ enum class Alert {
EmptyConfiguration,
StartCommandServer,
CreateService,
- StartService
-}
\ No newline at end of file
+ StartService,
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/Bugs.kt b/app/src/main/java/io/nekohasekai/sfa/constant/Bugs.kt
index 1dfc0c1..d83575c 100644
--- a/app/src/main/java/io/nekohasekai/sfa/constant/Bugs.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/constant/Bugs.kt
@@ -4,11 +4,10 @@ import android.os.Build
import io.nekohasekai.sfa.BuildConfig
object Bugs {
-
// TODO: remove launch after fixed
// https://github.com/golang/go/issues/68760
- val fixAndroidStack = BuildConfig.DEBUG ||
+ val fixAndroidStack =
+ BuildConfig.DEBUG ||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1 ||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt b/app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt
index 1bf8527..46be667 100644
--- a/app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt
@@ -4,7 +4,9 @@ import android.content.Context
import io.nekohasekai.sfa.R
enum class EnabledType(val boolValue: Boolean) {
- Enabled(true), Disabled(false);
+ Enabled(true),
+ Disabled(false),
+ ;
fun getString(context: Context): String {
return when (this) {
@@ -13,13 +15,15 @@ enum class EnabledType(val boolValue: Boolean) {
}
}
-
companion object {
fun from(value: Boolean): EnabledType {
return if (value) Enabled else Disabled
}
- fun valueOf(context: Context, value: String): EnabledType {
+ fun valueOf(
+ context: Context,
+ value: String,
+ ): EnabledType {
return when (value) {
context.getString(R.string.enabled) -> Enabled
context.getString(R.string.disabled) -> Disabled
@@ -27,4 +31,4 @@ enum class EnabledType(val boolValue: Boolean) {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/Path.kt b/app/src/main/java/io/nekohasekai/sfa/constant/Path.kt
index c731b61..e9d07a8 100644
--- a/app/src/main/java/io/nekohasekai/sfa/constant/Path.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/constant/Path.kt
@@ -3,4 +3,4 @@ package io.nekohasekai.sfa.constant
object Path {
const val SETTINGS_DATABASE_PATH = "settings.db"
const val PROFILES_DATABASE_PATH = "profiles.db"
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/PerAppProxyUpdateType.kt b/app/src/main/java/io/nekohasekai/sfa/constant/PerAppProxyUpdateType.kt
index 00e8110..89ba0f7 100644
--- a/app/src/main/java/io/nekohasekai/sfa/constant/PerAppProxyUpdateType.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/constant/PerAppProxyUpdateType.kt
@@ -5,37 +5,45 @@ import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.database.Settings
enum class PerAppProxyUpdateType {
- Disabled, Select, Deselect;
+ Disabled,
+ Select,
+ Deselect,
+ ;
- fun value() = when (this) {
- Disabled -> Settings.PER_APP_PROXY_DISABLED
- Select -> Settings.PER_APP_PROXY_INCLUDE
- Deselect -> Settings.PER_APP_PROXY_EXCLUDE
- }
+ fun value() =
+ when (this) {
+ Disabled -> Settings.PER_APP_PROXY_DISABLED
+ Select -> Settings.PER_APP_PROXY_INCLUDE
+ Deselect -> Settings.PER_APP_PROXY_EXCLUDE
+ }
fun getString(context: Context): String {
return when (this) {
Disabled -> context.getString(R.string.disabled)
- Select -> context.getString(R.string.action_select)
+ Select -> context.getString(R.string.per_app_proxy_select)
Deselect -> context.getString(R.string.action_deselect)
}
}
companion object {
- fun valueOf(value: Int): PerAppProxyUpdateType = when (value) {
- Settings.PER_APP_PROXY_DISABLED -> Disabled
- Settings.PER_APP_PROXY_INCLUDE -> Select
- Settings.PER_APP_PROXY_EXCLUDE -> Deselect
- else -> throw IllegalArgumentException()
- }
+ fun valueOf(value: Int): PerAppProxyUpdateType =
+ when (value) {
+ Settings.PER_APP_PROXY_DISABLED -> Disabled
+ Settings.PER_APP_PROXY_INCLUDE -> Select
+ Settings.PER_APP_PROXY_EXCLUDE -> Deselect
+ else -> throw IllegalArgumentException()
+ }
- fun valueOf(context: Context, value: String): PerAppProxyUpdateType {
+ fun valueOf(
+ context: Context,
+ value: String,
+ ): PerAppProxyUpdateType {
return when (value) {
context.getString(R.string.disabled) -> Disabled
- context.getString(R.string.action_select) -> Select
+ context.getString(R.string.per_app_proxy_select) -> Select
context.getString(R.string.action_deselect) -> Deselect
else -> Disabled
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/ServiceMode.kt b/app/src/main/java/io/nekohasekai/sfa/constant/ServiceMode.kt
index 1bb0ad9..8b7f8c5 100644
--- a/app/src/main/java/io/nekohasekai/sfa/constant/ServiceMode.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/constant/ServiceMode.kt
@@ -3,4 +3,4 @@ package io.nekohasekai.sfa.constant
object ServiceMode {
const val NORMAL = "normal"
const val VPN = "vpn"
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt
index 25a7cc6..27ed174 100644
--- a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt
@@ -1,13 +1,15 @@
package io.nekohasekai.sfa.constant
object SettingsKey {
-
const val SELECTED_PROFILE = "selected_profile"
const val SERVICE_MODE = "service_mode"
const val CHECK_UPDATE_ENABLED = "check_update_enabled"
const val DISABLE_MEMORY_LIMIT = "disable_memory_limit"
const val DYNAMIC_NOTIFICATION = "dynamic_notification"
+ const val USE_COMPOSE_UI = "use_compose_ui"
+ const val DISABLE_DEPRECATED_WARNINGS = "disable_deprecated_warnings"
+ const val AUTO_REDIRECT = "auto_redirect"
const val PER_APP_PROXY_ENABLED = "per_app_proxy_enabled"
const val PER_APP_PROXY_MODE = "per_app_proxy_mode"
const val PER_APP_PROXY_LIST = "per_app_proxy_list"
@@ -15,8 +17,11 @@ object SettingsKey {
const val SYSTEM_PROXY_ENABLED = "system_proxy_enabled"
+ // dashboard
+ const val DASHBOARD_ITEM_ORDER = "dashboard_item_order"
+ const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items"
+
// cache
const val STARTED_BY_USER = "started_by_user"
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/Status.kt b/app/src/main/java/io/nekohasekai/sfa/constant/Status.kt
index 740637f..49d1da3 100644
--- a/app/src/main/java/io/nekohasekai/sfa/constant/Status.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/constant/Status.kt
@@ -5,4 +5,4 @@ enum class Status {
Starting,
Started,
Stopping,
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Profile.kt b/app/src/main/java/io/nekohasekai/sfa/database/Profile.kt
index 57c1d69..cdc3a74 100644
--- a/app/src/main/java/io/nekohasekai/sfa/database/Profile.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/database/Profile.kt
@@ -1,6 +1,7 @@
package io.nekohasekai.sfa.database
import android.os.Parcelable
+import androidx.room.ColumnInfo
import androidx.room.Delete
import androidx.room.Entity
import androidx.room.Insert
@@ -19,12 +20,11 @@ class Profile(
@PrimaryKey(autoGenerate = true) var id: Long = 0L,
var userOrder: Long = 0L,
var name: String = "",
- var typed: TypedProfile = TypedProfile()
+ @ColumnInfo(defaultValue = "NULL") var icon: String? = null,
+ var typed: TypedProfile = TypedProfile(),
) : Parcelable {
-
@androidx.room.Dao
interface Dao {
-
@Insert
fun insert(profile: Profile): Long
@@ -54,8 +54,5 @@ class Profile(
@Query("SELECT MAX(id) + 1 FROM profiles")
fun nextFileID(): Long?
-
}
-
}
-
diff --git a/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt b/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt
index 24cbff0..e7eee0f 100644
--- a/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt
@@ -2,12 +2,24 @@ package io.nekohasekai.sfa.database
import androidx.room.Database
import androidx.room.RoomDatabase
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
- entities = [Profile::class], version = 1
+ entities = [Profile::class],
+ version = 2,
+ exportSchema = true,
)
abstract class ProfileDatabase : RoomDatabase() {
-
abstract fun profileDao(): Profile.Dao
-}
\ No newline at end of file
+ companion object {
+ val MIGRATION_1_2 =
+ object : Migration(1, 2) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ // Add icon column to profiles table with default value null
+ database.execSQL("ALTER TABLE profiles ADD COLUMN icon TEXT DEFAULT NULL")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt b/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt
index 61f1dc6..7ba4629 100644
--- a/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt
@@ -9,7 +9,6 @@ import kotlinx.coroutines.launch
@Suppress("RedundantSuspendModifier")
object ProfileManager {
-
private val callbacks = mutableListOf<() -> Unit>()
fun registerCallback(callback: () -> Unit) {
@@ -27,9 +26,10 @@ object ProfileManager {
.databaseBuilder(
Application.application,
ProfileDatabase::class.java,
- Path.PROFILES_DATABASE_PATH
+ Path.PROFILES_DATABASE_PATH,
)
- .fallbackToDestructiveMigration()
+ .addMigrations(ProfileDatabase.MIGRATION_1_2)
+ .fallbackToDestructiveMigrationOnDowngrade()
.enableMultiInstanceInvalidation()
.setQueryExecutor { GlobalScope.launch { it.run() } }
.build()
@@ -43,7 +43,6 @@ object ProfileManager {
return instance.profileDao().nextFileID() ?: 1
}
-
suspend fun get(id: Long): Profile? {
return instance.profileDao().get(id)
}
@@ -99,5 +98,4 @@ object ProfileManager {
suspend fun list(): List {
return instance.profileDao().list()
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt
index 6f5bb79..182c11f 100644
--- a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt
@@ -21,14 +21,13 @@ import org.json.JSONObject
import java.io.File
object Settings {
-
@OptIn(DelicateCoroutinesApi::class)
private val instance by lazy {
Application.application.getDatabasePath(Path.SETTINGS_DATABASE_PATH).parentFile?.mkdirs()
Room.databaseBuilder(
Application.application,
KeyValueDatabase::class.java,
- Path.SETTINGS_DATABASE_PATH
+ Path.SETTINGS_DATABASE_PATH,
).allowMainThreadQueries()
.fallbackToDestructiveMigration()
.enableMultiInstanceInvalidation()
@@ -43,12 +42,14 @@ object Settings {
var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { true }
var disableMemoryLimit by dataStore.boolean(SettingsKey.DISABLE_MEMORY_LIMIT)
var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true }
-
+ var useComposeUI by dataStore.boolean(SettingsKey.USE_COMPOSE_UI) { true }
+ var disableDeprecatedWarnings by dataStore.boolean(SettingsKey.DISABLE_DEPRECATED_WARNINGS) { false }
const val PER_APP_PROXY_DISABLED = 0
const val PER_APP_PROXY_EXCLUDE = 1
const val PER_APP_PROXY_INCLUDE = 2
+ var autoRedirect by dataStore.boolean(SettingsKey.AUTO_REDIRECT) { false }
var perAppProxyEnabled by dataStore.boolean(SettingsKey.PER_APP_PROXY_ENABLED) { false }
var perAppProxyMode by dataStore.int(SettingsKey.PER_APP_PROXY_MODE) { PER_APP_PROXY_EXCLUDE }
var perAppProxyList by dataStore.stringSet(SettingsKey.PER_APP_PROXY_LIST) { emptySet() }
@@ -56,6 +57,9 @@ object Settings {
var systemProxyEnabled by dataStore.boolean(SettingsKey.SYSTEM_PROXY_ENABLED) { true }
+ var dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" }
+ var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() }
+
fun serviceClass(): Class<*> {
return when (serviceMode) {
ServiceMode.VPN -> VPNService::class.java
@@ -92,5 +96,4 @@ object Settings {
}
return false
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/database/TypedProfile.kt b/app/src/main/java/io/nekohasekai/sfa/database/TypedProfile.kt
index 6350b56..8c26f30 100644
--- a/app/src/main/java/io/nekohasekai/sfa/database/TypedProfile.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/database/TypedProfile.kt
@@ -11,9 +11,10 @@ import io.nekohasekai.sfa.ktx.unmarshall
import java.util.Date
class TypedProfile() : Parcelable {
-
enum class Type {
- Local, Remote;
+ Local,
+ Remote,
+ ;
fun getString(context: Context): String {
return when (this) {
@@ -53,7 +54,10 @@ class TypedProfile() : Parcelable {
}
}
- override fun writeToParcel(writer: Parcel, flags: Int) {
+ override fun writeToParcel(
+ writer: Parcel,
+ flags: Int,
+ ) {
writer.writeInt(1)
writer.writeString(path)
writer.writeInt(type.ordinal)
@@ -78,14 +82,10 @@ class TypedProfile() : Parcelable {
}
class Convertor {
-
@TypeConverter
fun marshall(profile: TypedProfile) = profile.marshall()
@TypeConverter
- fun unmarshall(content: ByteArray) =
- content.unmarshall(::TypedProfile)
-
+ fun unmarshall(content: ByteArray) = content.unmarshall(::TypedProfile)
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueDatabase.kt b/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueDatabase.kt
index d94ad92..9710291 100644
--- a/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueDatabase.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueDatabase.kt
@@ -4,10 +4,9 @@ import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
- entities = [KeyValueEntity::class], version = 1
+ entities = [KeyValueEntity::class],
+ version = 1,
)
abstract class KeyValueDatabase : RoomDatabase() {
-
abstract fun keyValuePairDao(): KeyValueEntity.Dao
-
}
diff --git a/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueEntity.kt b/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueEntity.kt
index 2389b35..9d6dbab 100644
--- a/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueEntity.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueEntity.kt
@@ -22,20 +22,20 @@ class KeyValueEntity() : Parcelable {
const val TYPE_STRING_SET = 5
@JvmField
- val CREATOR = object : Parcelable.Creator {
- override fun createFromParcel(parcel: Parcel): KeyValueEntity {
- return KeyValueEntity(parcel)
- }
+ val CREATOR =
+ object : Parcelable.Creator {
+ override fun createFromParcel(parcel: Parcel): KeyValueEntity {
+ return KeyValueEntity(parcel)
+ }
- override fun newArray(size: Int): Array {
- return arrayOfNulls(size)
+ override fun newArray(size: Int): Array {
+ return arrayOfNulls(size)
+ }
}
- }
}
@androidx.room.Dao
interface Dao {
-
@Query("SELECT * FROM KeyValueEntity")
fun all(): List
@@ -71,16 +71,19 @@ class KeyValueEntity() : Parcelable {
val string: String?
get() = if (valueType == TYPE_STRING) String(value) else null
val stringSet: Set?
- get() = if (valueType == TYPE_STRING_SET) {
- val buffer = ByteBuffer.wrap(value)
- val result = HashSet()
- while (buffer.hasRemaining()) {
- val chArr = ByteArray(buffer.int)
- buffer.get(chArr)
- result.add(String(chArr))
+ get() =
+ if (valueType == TYPE_STRING_SET) {
+ val buffer = ByteBuffer.wrap(value)
+ val result = HashSet()
+ while (buffer.hasRemaining()) {
+ val chArr = ByteArray(buffer.int)
+ buffer.get(chArr)
+ result.add(String(chArr))
+ }
+ result
+ } else {
+ null
}
- result
- } else null
@Ignore
constructor(key: String) : this() {
@@ -143,7 +146,10 @@ class KeyValueEntity() : Parcelable {
value = parcel.createByteArray()!!
}
- override fun writeToParcel(parcel: Parcel, flags: Int) {
+ override fun writeToParcel(
+ parcel: Parcel,
+ flags: Int,
+ ) {
parcel.writeString(key)
parcel.writeInt(valueType)
parcel.writeByteArray(value)
@@ -152,5 +158,4 @@ class KeyValueEntity() : Parcelable {
override fun describeContents(): Int {
return 0
}
-
}
diff --git a/app/src/main/java/io/nekohasekai/sfa/database/preference/OnPreferenceDataStoreChangeListener.kt b/app/src/main/java/io/nekohasekai/sfa/database/preference/OnPreferenceDataStoreChangeListener.kt
index ac5c7b8..418c0ef 100644
--- a/app/src/main/java/io/nekohasekai/sfa/database/preference/OnPreferenceDataStoreChangeListener.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/database/preference/OnPreferenceDataStoreChangeListener.kt
@@ -3,5 +3,8 @@ package io.nekohasekai.sfa.database.preference
import androidx.preference.PreferenceDataStore
interface OnPreferenceDataStoreChangeListener {
- fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String)
+ fun onPreferenceDataStoreChanged(
+ store: PreferenceDataStore,
+ key: String,
+ )
}
diff --git a/app/src/main/java/io/nekohasekai/sfa/database/preference/RoomPreferenceDataStore.kt b/app/src/main/java/io/nekohasekai/sfa/database/preference/RoomPreferenceDataStore.kt
index ac10693..5868d54 100644
--- a/app/src/main/java/io/nekohasekai/sfa/database/preference/RoomPreferenceDataStore.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/database/preference/RoomPreferenceDataStore.kt
@@ -5,63 +5,121 @@ import androidx.preference.PreferenceDataStore
@Suppress("MemberVisibilityCanBePrivate", "unused")
open class RoomPreferenceDataStore(private val kvPairDao: KeyValueEntity.Dao) :
PreferenceDataStore() {
-
fun getBoolean(key: String) = kvPairDao[key]?.boolean
+
fun getFloat(key: String) = kvPairDao[key]?.float
+
fun getInt(key: String) = kvPairDao[key]?.long?.toInt()
+
fun getLong(key: String) = kvPairDao[key]?.long
+
fun getString(key: String) = kvPairDao[key]?.string
+
fun getStringSet(key: String) = kvPairDao[key]?.stringSet
+
fun reset() = kvPairDao.reset()
- override fun getBoolean(key: String, defValue: Boolean) = getBoolean(key) ?: defValue
- override fun getFloat(key: String, defValue: Float) = getFloat(key) ?: defValue
- override fun getInt(key: String, defValue: Int) = getInt(key) ?: defValue
- override fun getLong(key: String, defValue: Long) = getLong(key) ?: defValue
- override fun getString(key: String, defValue: String?) = getString(key) ?: defValue
- override fun getStringSet(key: String, defValue: MutableSet?) =
- getStringSet(key) ?: defValue
+ override fun getBoolean(
+ key: String,
+ defValue: Boolean,
+ ) = getBoolean(key) ?: defValue
- fun putBoolean(key: String, value: Boolean?) =
- if (value == null) remove(key) else putBoolean(key, value)
+ override fun getFloat(
+ key: String,
+ defValue: Float,
+ ) = getFloat(key) ?: defValue
- fun putFloat(key: String, value: Float?) =
- if (value == null) remove(key) else putFloat(key, value)
+ override fun getInt(
+ key: String,
+ defValue: Int,
+ ) = getInt(key) ?: defValue
- fun putInt(key: String, value: Int?) =
- if (value == null) remove(key) else putLong(key, value.toLong())
+ override fun getLong(
+ key: String,
+ defValue: Long,
+ ) = getLong(key) ?: defValue
- fun putLong(key: String, value: Long?) = if (value == null) remove(key) else putLong(key, value)
- override fun putBoolean(key: String, value: Boolean) {
+ override fun getString(
+ key: String,
+ defValue: String?,
+ ) = getString(key) ?: defValue
+
+ override fun getStringSet(
+ key: String,
+ defValue: MutableSet?,
+ ) = getStringSet(key) ?: defValue
+
+ fun putBoolean(
+ key: String,
+ value: Boolean?,
+ ) = if (value == null) remove(key) else putBoolean(key, value)
+
+ fun putFloat(
+ key: String,
+ value: Float?,
+ ) = if (value == null) remove(key) else putFloat(key, value)
+
+ fun putInt(
+ key: String,
+ value: Int?,
+ ) = if (value == null) remove(key) else putLong(key, value.toLong())
+
+ fun putLong(
+ key: String,
+ value: Long?,
+ ) = if (value == null) remove(key) else putLong(key, value)
+
+ override fun putBoolean(
+ key: String,
+ value: Boolean,
+ ) {
kvPairDao.put(KeyValueEntity(key).put(value))
fireChangeListener(key)
}
- override fun putFloat(key: String, value: Float) {
+ override fun putFloat(
+ key: String,
+ value: Float,
+ ) {
kvPairDao.put(KeyValueEntity(key).put(value))
fireChangeListener(key)
}
- override fun putInt(key: String, value: Int) {
+ override fun putInt(
+ key: String,
+ value: Int,
+ ) {
kvPairDao.put(KeyValueEntity(key).put(value.toLong()))
fireChangeListener(key)
}
- override fun putLong(key: String, value: Long) {
+ override fun putLong(
+ key: String,
+ value: Long,
+ ) {
kvPairDao.put(KeyValueEntity(key).put(value))
fireChangeListener(key)
}
- override fun putString(key: String, value: String?) = if (value == null) remove(key) else {
+ override fun putString(
+ key: String,
+ value: String?,
+ ) = if (value == null) {
+ remove(key)
+ } else {
kvPairDao.put(KeyValueEntity(key).put(value))
fireChangeListener(key)
}
- override fun putStringSet(key: String, values: MutableSet?) =
- if (values == null) remove(key) else {
- kvPairDao.put(KeyValueEntity(key).put(values))
- fireChangeListener(key)
- }
+ override fun putStringSet(
+ key: String,
+ values: MutableSet?,
+ ) = if (values == null) {
+ remove(key)
+ } else {
+ kvPairDao.put(KeyValueEntity(key).put(values))
+ fireChangeListener(key)
+ }
fun remove(key: String) {
kvPairDao.delete(key)
@@ -69,10 +127,12 @@ open class RoomPreferenceDataStore(private val kvPairDao: KeyValueEntity.Dao) :
}
private val listeners = HashSet()
+
private fun fireChangeListener(key: String) {
- val listeners = synchronized(listeners) {
- listeners.toList()
- }
+ val listeners =
+ synchronized(listeners) {
+ listeners.toList()
+ }
listeners.forEach { it.onPreferenceDataStoreChanged(this, key) }
}
@@ -87,4 +147,4 @@ open class RoomPreferenceDataStore(private val kvPairDao: KeyValueEntity.Dao) :
listeners.remove(listener)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt
index 0c4786d..ffb8631 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt
@@ -14,13 +14,13 @@ fun Context.launchCustomTab(link: String) {
CustomTabsIntent.COLOR_SCHEME_LIGHT,
CustomTabColorSchemeParams.Builder().apply {
setToolbarColor(color)
- }.build()
+ }.build(),
)
setColorSchemeParams(
CustomTabsIntent.COLOR_SCHEME_DARK,
CustomTabColorSchemeParams.Builder().apply {
setToolbarColor(color)
- }.build()
+ }.build(),
)
}.build().launchUrl(this, Uri.parse(link))
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Clips.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Clips.kt
index 48ee0f4..b5654ea 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ktx/Clips.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Clips.kt
@@ -9,4 +9,4 @@ var clipboardText: String?
if (plainText != null) {
Application.clipboard.setPrimaryClip(ClipData.newPlainText(null, plainText))
}
- }
\ No newline at end of file
+ }
diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt
index 17631bb..16c16d7 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt
@@ -9,19 +9,21 @@ import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import com.google.android.material.color.MaterialColors
-
@ColorInt
fun Context.getAttrColor(
@AttrRes attrColor: Int,
typedValue: TypedValue = TypedValue(),
- resolveRefs: Boolean = true
+ resolveRefs: Boolean = true,
): Int {
theme.resolveAttribute(attrColor, typedValue, resolveRefs)
return typedValue.data
}
@ColorInt
-fun colorForURLTestDelay(context: Context, urlTestDelay: Int): Int {
+fun colorForURLTestDelay(
+ context: Context,
+ urlTestDelay: Int,
+): Int {
if (urlTestDelay <= 0) {
return Color.GRAY
}
@@ -44,4 +46,4 @@ fun colorForURLTestDelay(context: Context, urlTestDelay: Int): Int {
}
}
return MaterialColors.harmonizeWithPrimary(context, ContextCompat.getColor(context, colorRes))
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt
index aad9f83..b1fe740 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt
@@ -2,7 +2,6 @@ package io.nekohasekai.sfa.ktx
import kotlin.coroutines.Continuation
-
fun Continuation.tryResume(value: T) {
try {
resumeWith(Result.success(value))
diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt
index 80a9866..d2ccc80 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt
@@ -5,7 +5,9 @@ import androidx.annotation.StringRes
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.nekohasekai.sfa.R
-fun Context.errorDialogBuilder(@StringRes messageId: Int): MaterialAlertDialogBuilder {
+fun Context.errorDialogBuilder(
+ @StringRes messageId: Int,
+): MaterialAlertDialogBuilder {
return MaterialAlertDialogBuilder(this)
.setTitle(R.string.error_title)
.setMessage(messageId)
@@ -21,4 +23,4 @@ fun Context.errorDialogBuilder(message: String): MaterialAlertDialogBuilder {
fun Context.errorDialogBuilder(exception: Throwable): MaterialAlertDialogBuilder {
return errorDialogBuilder(exception.localizedMessage ?: exception.toString())
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Dimens.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Dimens.kt
index bb2dceb..c5921d7 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ktx/Dimens.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Dimens.kt
@@ -11,4 +11,4 @@ fun dp2pxf(dpValue: Int): Float {
fun dp2px(dpValue: Int): Int {
return ceil(dp2pxf(dpValue)).toInt()
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt
index 4116af7..166d860 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt
@@ -18,8 +18,9 @@ var TextInputLayout.error: String
editText?.error = value
}
-
-fun TextInputLayout.setSimpleItems(@ArrayRes redId: Int) {
+fun TextInputLayout.setSimpleItems(
+ @ArrayRes redId: Int,
+) {
(editText as? MaterialAutoCompleteTextView)?.setSimpleItems(redId)
}
@@ -41,11 +42,10 @@ fun TextInputLayout.showErrorIfEmpty(): Boolean {
return false
}
-
fun TextInputLayout.addTextChangedListener(listener: (String) -> Unit) {
addOnEditTextAttachedListener {
editText?.addTextChangedListener {
listener(it?.toString() ?: "")
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt
index aea4807..3ab1043 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt
@@ -7,7 +7,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.nekohasekai.sfa.R
fun Activity.startFilesForResult(
- launcher: ActivityResultLauncher, input: String
+ launcher: ActivityResultLauncher,
+ input: String,
) {
try {
return launcher.launch(input)
@@ -18,4 +19,4 @@ fun Activity.startFilesForResult(
builder.setPositiveButton(resources.getString(android.R.string.ok), null)
builder.setMessage(R.string.file_manager_missing)
builder.show()
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt
index f38effe..dda95d0 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt
@@ -55,7 +55,7 @@ fun PreferenceDataStore.stringToLong(
fun PreferenceDataStore.stringSet(
name: String,
- defaultValue: () -> Set = { emptySet() }
+ defaultValue: () -> Set = { emptySet() },
) = PreferenceProxy(name, defaultValue, ::getStringSet, ::putStringSet)
class PreferenceProxy(
@@ -64,8 +64,14 @@ class PreferenceProxy(
val getter: (String, T) -> T?,
val setter: (String, value: T) -> Unit,
) {
+ operator fun setValue(
+ thisObj: Any?,
+ property: KProperty<*>,
+ value: T,
+ ) = setter(name, value)
- operator fun setValue(thisObj: Any?, property: KProperty<*>, value: T) = setter(name, value)
- operator fun getValue(thisObj: Any?, property: KProperty<*>) = getter(name, defaultValue())!!
-
-}
\ No newline at end of file
+ operator fun getValue(
+ thisObj: Any?,
+ property: KProperty<*>,
+ ) = getter(name, defaultValue())!!
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt
index f8c672f..54635fb 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt
@@ -18,4 +18,4 @@ fun ByteArray.unmarshall(constructor: (Parcel) -> T): T {
val result = constructor(parcel)
parcel.recycle()
return result
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt
index 52dc78e..029d6d2 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt
@@ -46,17 +46,18 @@ suspend fun Context.shareProfile(profile: Profile) {
Intent(Intent.ACTION_SEND).setType("application/octet-stream")
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putExtra(Intent.EXTRA_STREAM, uri),
- getString(R.string.abc_shareactionprovider_share_with)
- )
+ getString(R.string.abc_shareactionprovider_share_with),
+ ),
)
}
}
fun FragmentActivity.shareProfileURL(profile: Profile) {
- val link = Libbox.generateRemoteProfileImportLink(
- profile.name,
- profile.typed.remoteURL
- )
+ val link =
+ Libbox.generateRemoteProfileImportLink(
+ profile.name,
+ profile.typed.remoteURL,
+ )
val imageSize = dp2px(256)
val color = getAttrColor(com.google.android.material.R.attr.colorPrimary)
val image = QRCodeWriter().encode(link, BarcodeFormat.QR_CODE, imageSize, imageSize, null)
@@ -67,11 +68,10 @@ fun FragmentActivity.shareProfileURL(profile: Profile) {
val offset = y * imageWidth
for (x in 0 until imageWidth) {
imageArray[offset + x] = if (image.get(x, y)) color else Color.TRANSPARENT
-
}
}
val bitmap = Bitmap.createBitmap(imageWidth, imageHeight, Bitmap.Config.ARGB_8888)
bitmap.setPixels(imageArray, 0, imageSize, 0, 0, imageWidth, imageHeight)
val dialog = QRCodeDialog(bitmap)
dialog.show(supportFragmentManager, "share-profile-url")
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt
index bd8b2f6..8e97959 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt
@@ -3,6 +3,8 @@ package io.nekohasekai.sfa.ktx
import android.net.IpPrefix
import android.os.Build
import androidx.annotation.RequiresApi
+import io.nekohasekai.libbox.LogEntry
+import io.nekohasekai.libbox.LogIterator
import io.nekohasekai.libbox.RoutePrefix
import io.nekohasekai.libbox.StringBox
import io.nekohasekai.libbox.StringIterator
@@ -41,5 +43,13 @@ fun StringIterator.toList(): List {
}
}
+fun LogIterator.toList(): List {
+ return mutableListOf().apply {
+ while (hasNext()) {
+ add(next())
+ }
+ }
+}
+
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
-fun RoutePrefix.toIpPrefix() = IpPrefix(InetAddress.getByName(address()), prefix())
\ No newline at end of file
+fun RoutePrefix.toIpPrefix() = IpPrefix(InetAddress.getByName(address()), prefix())
diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt
index 319157f..6b8a259 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt
@@ -52,9 +52,9 @@ import kotlinx.coroutines.withContext
import java.io.File
import java.util.Date
-class MainActivity : AbstractActivity(),
+class MainActivity :
+ AbstractActivity(),
ServiceConnection.Callback {
-
companion object {
private const val TAG = "MainActivity"
}
@@ -82,7 +82,7 @@ class MainActivity : AbstractActivity(),
R.id.navigation_log,
R.id.navigation_configuration,
R.id.navigation_settings,
- )
+ ),
)
setupActionBarWithNavController(navController, appBarConfiguration)
binding.navView.setupWithNavController(navController)
@@ -100,13 +100,13 @@ class MainActivity : AbstractActivity(),
private fun onDestinationChanged(
navController: NavController,
navDestination: NavDestination,
- bundle: Bundle?
+ bundle: Bundle?,
) {
val destinationId = navDestination.id
binding.dashboardTabContainer.isVisible = destinationId == R.id.navigation_dashboard
}
- override public fun onNewIntent(intent: Intent) {
+ public override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val uri = intent.data ?: return
when (intent.action) {
@@ -116,26 +116,29 @@ class MainActivity : AbstractActivity(),
}
}
if (uri.scheme == "sing-box" && uri.host == "import-remote-profile") {
- val profile = try {
- Libbox.parseRemoteProfileImportLink(uri.toString())
- } catch (e: Exception) {
- errorDialogBuilder(e).show()
- return
- }
+ val profile =
+ try {
+ Libbox.parseRemoteProfileImportLink(uri.toString())
+ } catch (e: Exception) {
+ errorDialogBuilder(e).show()
+ return
+ }
MaterialAlertDialogBuilder(this)
.setTitle(R.string.import_remote_profile)
.setMessage(
getString(
R.string.import_remote_profile_message,
profile.name,
- profile.host
- )
+ profile.host,
+ ),
)
.setPositiveButton(R.string.ok) { _, _ ->
- startActivity(Intent(this, NewProfileActivity::class.java).apply {
- putExtra("importName", profile.name)
- putExtra("importURL", profile.url)
- })
+ startActivity(
+ Intent(this, NewProfileActivity::class.java).apply {
+ putExtra("importName", profile.name)
+ putExtra("importURL", profile.url)
+ },
+ )
}
.setNegativeButton(android.R.string.cancel, null)
.show()
@@ -148,8 +151,8 @@ class MainActivity : AbstractActivity(),
.setMessage(
getString(
R.string.import_profile_message,
- content.name
- )
+ content.name,
+ ),
)
.setPositiveButton(R.string.ok) { _, _ ->
lifecycleScope.launch {
@@ -241,15 +244,16 @@ class MainActivity : AbstractActivity(),
}
}
- private val notificationPermissionLauncher = registerForActivityResult(
- ActivityResultContracts.RequestPermission()
- ) {
- if (Settings.dynamicNotification && !it) {
- onServiceAlert(Alert.RequestNotificationPermission, null)
- } else {
- startService0()
+ private val notificationPermissionLauncher =
+ registerForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) {
+ if (Settings.dynamicNotification && !it) {
+ onServiceAlert(Alert.RequestNotificationPermission, null)
+ } else {
+ startService0()
+ }
}
- }
private val locationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
@@ -269,46 +273,55 @@ class MainActivity : AbstractActivity(),
}
}
- private val prepareLauncher = registerForActivityResult(PrepareService()) {
- if (it) {
- startService()
- } else {
- onServiceAlert(Alert.RequestVPNPermission, null)
+ private val prepareLauncher =
+ registerForActivityResult(PrepareService()) {
+ if (it) {
+ startService()
+ } else {
+ onServiceAlert(Alert.RequestVPNPermission, null)
+ }
}
- }
private class PrepareService : ActivityResultContract() {
- override fun createIntent(context: Context, input: Intent): Intent {
+ override fun createIntent(
+ context: Context,
+ input: Intent,
+ ): Intent {
return input
}
- override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
+ override fun parseResult(
+ resultCode: Int,
+ intent: Intent?,
+ ): Boolean {
return resultCode == RESULT_OK
}
}
- private suspend fun prepare() = withContext(Dispatchers.Main) {
- try {
- val intent = VpnService.prepare(this@MainActivity)
- if (intent != null) {
- prepareLauncher.launch(intent)
- true
- } else {
+ private suspend fun prepare() =
+ withContext(Dispatchers.Main) {
+ try {
+ val intent = VpnService.prepare(this@MainActivity)
+ if (intent != null) {
+ prepareLauncher.launch(intent)
+ true
+ } else {
+ false
+ }
+ } catch (e: Exception) {
+ onServiceAlert(Alert.RequestVPNPermission, e.message)
false
}
- } catch (e: Exception) {
- onServiceAlert(Alert.RequestVPNPermission, e.message)
- false
}
- }
override fun onServiceStatusChanged(status: Status) {
serviceStatus.postValue(status)
}
- override fun onServiceAlert(type: Alert, message: String?) {
- serviceStatus.value = Status.Stopped
-
+ override fun onServiceAlert(
+ type: Alert,
+ message: String?,
+ ) {
when (type) {
Alert.RequestLocationPermission -> {
return requestLocationPermission()
@@ -346,7 +359,6 @@ class MainActivity : AbstractActivity(),
Alert.StartService -> {
builder.setTitle(getString(R.string.service_error_title_start_service))
builder.setMessage(message)
-
}
else -> {}
@@ -363,15 +375,16 @@ class MainActivity : AbstractActivity(),
}
private fun requestFineLocationPermission() {
- val message = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- Html.fromHtml(
- getString(R.string.location_permission_description),
- Html.FROM_HTML_MODE_LEGACY
- )
- } else {
- @Suppress("DEPRECATION")
- Html.fromHtml(getString(R.string.location_permission_description))
- }
+ val message =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ Html.fromHtml(
+ getString(R.string.location_permission_description),
+ Html.FROM_HTML_MODE_LEGACY,
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ Html.fromHtml(getString(R.string.location_permission_description))
+ }
MaterialAlertDialogBuilder(this)
.setTitle(R.string.location_permission_title)
.setMessage(message)
@@ -398,8 +411,8 @@ class MainActivity : AbstractActivity(),
.setMessage(
Html.fromHtml(
getString(R.string.location_permission_background_description),
- Html.FROM_HTML_MODE_LEGACY
- )
+ Html.FROM_HTML_MODE_LEGACY,
+ ),
)
.setPositiveButton(R.string.ok) { _, _ ->
backgroundLocationPermissionLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
@@ -431,5 +444,4 @@ class MainActivity : AbstractActivity(),
connection.disconnect()
super.onDestroy()
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt
index a91e0b6..42231b9 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt
@@ -16,31 +16,31 @@ import io.nekohasekai.sfa.bg.ServiceConnection
import io.nekohasekai.sfa.constant.Status
class ShortcutActivity : Activity(), ServiceConnection.Callback {
-
private val connection = ServiceConnection(this, this, false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent.action == Intent.ACTION_CREATE_SHORTCUT) {
setResult(
- RESULT_OK, ShortcutManagerCompat.createShortcutResultIntent(
+ RESULT_OK,
+ ShortcutManagerCompat.createShortcutResultIntent(
this,
ShortcutInfoCompat.Builder(this, "toggle")
.setIntent(
Intent(
this,
- ShortcutActivity::class.java
- ).setAction(Intent.ACTION_MAIN)
+ ShortcutActivity::class.java,
+ ).setAction(Intent.ACTION_MAIN),
)
.setIcon(
IconCompat.createWithResource(
this,
- R.mipmap.ic_launcher
- )
+ R.mipmap.ic_launcher,
+ ),
)
.setShortLabel(getString(R.string.quick_toggle))
- .build()
- )
+ .build(),
+ ),
)
finish()
} else {
@@ -90,5 +90,4 @@ class ShortcutActivity : Activity(), ServiceConnection.Callback {
connection.disconnect()
super.onDestroy()
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/Groups.kt b/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/Groups.kt
index 4a9cb19..cc65d71 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/Groups.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/Groups.kt
@@ -1,5 +1,6 @@
package io.nekohasekai.sfa.ui.dashboard
+import androidx.compose.runtime.Immutable
import io.nekohasekai.libbox.OutboundGroup
import io.nekohasekai.libbox.OutboundGroupItem
import io.nekohasekai.libbox.OutboundGroupItemIterator
@@ -10,7 +11,7 @@ data class Group(
val selectable: Boolean,
var selected: String,
var isExpand: Boolean,
- var items: List,
+ val items: List,
) {
constructor(item: OutboundGroup) : this(
item.tag,
@@ -22,6 +23,7 @@ data class Group(
)
}
+@Immutable
data class GroupItem(
val tag: String,
val type: String,
@@ -42,4 +44,4 @@ internal fun OutboundGroupItemIterator.toList(): List {
list.add(next())
}
return list
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/GroupsFragment.kt b/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/GroupsFragment.kt
index b851bb5..3838ae0 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/GroupsFragment.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/GroupsFragment.kt
@@ -34,18 +34,17 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-
class GroupsFragment : Fragment(), CommandClient.Handler {
-
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
private var binding: FragmentDashboardGroupsBinding? = null
private var adapter: Adapter? = null
private val commandClient =
CommandClient(lifecycleScope, CommandClient.ConnectionType.Groups, this)
-
override fun onCreateView(
- inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
): View {
val binding = FragmentDashboardGroupsBinding.inflate(inflater, container, false)
this.binding = binding
@@ -73,6 +72,7 @@ class GroupsFragment : Fragment(), CommandClient.Handler {
}
private var displayed = false
+
private fun updateDisplayed(newValue: Boolean) {
val binding = binding ?: return
if (displayed != newValue) {
@@ -104,7 +104,6 @@ class GroupsFragment : Fragment(), CommandClient.Handler {
}
private class Adapter : RecyclerView.Adapter() {
-
private lateinit var groups: MutableList
@SuppressLint("NotifyDataSetChanged")
@@ -122,12 +121,15 @@ class GroupsFragment : Fragment(), CommandClient.Handler {
}
}
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GroupView {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): GroupView {
return GroupView(
ViewDashboardGroupBinding.inflate(
LayoutInflater.from(parent.context),
parent,
- false
+ false,
),
)
}
@@ -139,14 +141,16 @@ class GroupsFragment : Fragment(), CommandClient.Handler {
return groups.size
}
- override fun onBindViewHolder(holder: GroupView, position: Int) {
+ override fun onBindViewHolder(
+ holder: GroupView,
+ position: Int,
+ ) {
holder.bind(groups[position])
}
}
private class GroupView(val binding: ViewDashboardGroupBinding) :
RecyclerView.ViewHolder(binding.root) {
-
private lateinit var group: Group
private lateinit var items: List
private lateinit var adapter: ItemAdapter
@@ -208,22 +212,23 @@ class GroupsFragment : Fragment(), CommandClient.Handler {
binding.groupSelected.isEnabled = group.selectable
if (group.selectable) {
textView.setSimpleItems(group.items.toList().map { it.tag }.toTypedArray())
- textWatcher = textView.addTextChangedListener {
- val selected = textView.text.toString()
- if (selected != group.selected) {
- updateSelected(group, selected)
- }
- GlobalScope.launch {
- runCatching {
- Libbox.newStandaloneCommandClient()
- .selectOutbound(group.tag, selected)
- }.onFailure {
- withContext(Dispatchers.Main) {
- binding.root.context.errorDialogBuilder(it).show()
+ textWatcher =
+ textView.addTextChangedListener {
+ val selected = textView.text.toString()
+ if (selected != group.selected) {
+ updateSelected(group, selected)
+ }
+ GlobalScope.launch {
+ runCatching {
+ Libbox.newStandaloneCommandClient()
+ .selectOutbound(group.tag, selected)
+ }.onFailure {
+ withContext(Dispatchers.Main) {
+ binding.root.context.errorDialogBuilder(it).show()
+ }
}
}
}
- }
}
}
if (newExpandStatus) {
@@ -238,7 +243,10 @@ class GroupsFragment : Fragment(), CommandClient.Handler {
}
}
- fun updateSelected(group: Group, itemTag: String) {
+ fun updateSelected(
+ group: Group,
+ itemTag: String,
+ ) {
val oldSelected = items.indexOfFirst { it.tag == group.selected }
group.selected = itemTag
if (oldSelected != -1) {
@@ -250,10 +258,9 @@ class GroupsFragment : Fragment(), CommandClient.Handler {
private class ItemAdapter(
val groupView: GroupView,
var group: Group,
- private var items: MutableList = mutableListOf()
+ private var items: MutableList = mutableListOf(),
) :
RecyclerView.Adapter() {
-
@SuppressLint("NotifyDataSetChanged")
fun setItems(newItems: List) {
if (items.size != newItems.size) {
@@ -269,13 +276,16 @@ class GroupsFragment : Fragment(), CommandClient.Handler {
}
}
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemGroupView {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): ItemGroupView {
return ItemGroupView(
ViewDashboardGroupItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
- false
- )
+ false,
+ ),
)
}
@@ -283,16 +293,22 @@ class GroupsFragment : Fragment(), CommandClient.Handler {
return items.size
}
- override fun onBindViewHolder(holder: ItemGroupView, position: Int) {
+ override fun onBindViewHolder(
+ holder: ItemGroupView,
+ position: Int,
+ ) {
holder.bind(groupView, group, items[position])
}
}
private class ItemGroupView(val binding: ViewDashboardGroupItemBinding) :
RecyclerView.ViewHolder(binding.root) {
-
@OptIn(DelicateCoroutinesApi::class)
- fun bind(groupView: GroupView, group: Group, item: GroupItem) {
+ fun bind(
+ groupView: GroupView,
+ group: Group,
+ item: GroupItem,
+ ) {
if (group.selectable) {
binding.itemCard.setOnClickListener {
binding.selectedView.isVisible = true
@@ -318,11 +334,10 @@ class GroupsFragment : Fragment(), CommandClient.Handler {
binding.itemStatus.setTextColor(
colorForURLTestDelay(
binding.root.context,
- item.urlTestDelay
- )
+ item.urlTestDelay,
+ ),
)
}
}
}
}
-
diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/OverviewFragment.kt b/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/OverviewFragment.kt
index 75ac88c..531d942 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/OverviewFragment.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/OverviewFragment.kt
@@ -36,7 +36,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class OverviewFragment : Fragment() {
-
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
private var binding: FragmentDashboardOverviewBinding? = null
private val statusClient =
@@ -45,8 +44,11 @@ class OverviewFragment : Fragment() {
CommandClient(lifecycleScope, CommandClient.ConnectionType.ClashMode, ClashModeClient())
private var adapter: Adapter? = null
+
override fun onCreateView(
- inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
): View {
val binding = FragmentDashboardOverviewBinding.inflate(inflater, container, false)
this.binding = binding
@@ -57,10 +59,11 @@ class OverviewFragment : Fragment() {
private fun onCreate() {
val activity = activity ?: return
val binding = binding ?: return
- binding.profileList.adapter = Adapter(lifecycleScope, binding).apply {
- adapter = this
- reload()
- }
+ binding.profileList.adapter =
+ Adapter(lifecycleScope, binding).apply {
+ adapter = this
+ reload()
+ }
binding.profileList.layoutManager = LinearLayoutManager(requireContext())
val divider = MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)
divider.isLastItemDecorated = false
@@ -71,14 +74,12 @@ class OverviewFragment : Fragment() {
Status.Stopped -> {
binding.clashModeCard.isVisible = false
binding.systemProxyCard.isVisible = false
-
}
Status.Started -> {
statusClient.connect()
clashModeClient.connect()
reloadSystemProxyStatus()
-
}
else -> {}
@@ -136,7 +137,6 @@ class OverviewFragment : Fragment() {
}
inner class StatusClient : CommandClient.Handler {
-
override fun onConnected() {
val binding = binding ?: return
lifecycleScope.launch(Dispatchers.Main) {
@@ -170,12 +170,13 @@ class OverviewFragment : Fragment() {
}
}
}
-
}
inner class ClashModeClient : CommandClient.Handler {
-
- override fun initializeClashMode(modeList: List, currentMode: String) {
+ override fun initializeClashMode(
+ modeList: List,
+ currentMode: String,
+ ) {
val binding = binding ?: return
if (modeList.size > 1) {
lifecycleScope.launch(Dispatchers.Main) {
@@ -184,7 +185,7 @@ class OverviewFragment : Fragment() {
binding.clashModeList.layoutManager =
GridLayoutManager(
requireContext(),
- if (modeList.size < 3) modeList.size else 3
+ if (modeList.size < 3) modeList.size else 3,
)
}
} else {
@@ -203,22 +204,25 @@ class OverviewFragment : Fragment() {
adapter.notifyDataSetChanged()
}
}
-
}
private inner class ClashModeAdapter(
val items: List,
- var selected: String
+ var selected: String,
) :
RecyclerView.Adapter() {
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ClashModeItemView {
- val view = ClashModeItemView(
- ViewClashModeButtonBinding.inflate(
- LayoutInflater.from(parent.context),
- parent,
- false
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): ClashModeItemView {
+ val view =
+ ClashModeItemView(
+ ViewClashModeButtonBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
)
- )
view.binding.clashModeButton.clipToOutline = true
return view
}
@@ -227,20 +231,25 @@ class OverviewFragment : Fragment() {
return items.size
}
- override fun onBindViewHolder(holder: ClashModeItemView, position: Int) {
+ override fun onBindViewHolder(
+ holder: ClashModeItemView,
+ position: Int,
+ ) {
holder.bind(items[position], selected)
}
}
private inner class ClashModeItemView(val binding: ViewClashModeButtonBinding) :
RecyclerView.ViewHolder(binding.root) {
-
@OptIn(DelicateCoroutinesApi::class)
- fun bind(item: String, selected: String) {
+ fun bind(
+ item: String,
+ selected: String,
+ ) {
binding.clashModeButtonText.text = item
if (item != selected) {
binding.clashModeButtonText.setTextColor(
- binding.root.context.getAttrColor(com.google.android.material.R.attr.colorOnPrimaryContainer)
+ binding.root.context.getAttrColor(com.google.android.material.R.attr.colorOnPrimaryContainer),
)
binding.clashModeButton.setBackgroundResource(R.drawable.bg_rounded_rectangle)
binding.clashModeButton.setOnClickListener {
@@ -255,25 +264,23 @@ class OverviewFragment : Fragment() {
}
} else {
binding.clashModeButtonText.setTextColor(
- binding.root.context.getAttrColor(com.google.android.material.R.attr.colorOnPrimary)
+ binding.root.context.getAttrColor(com.google.android.material.R.attr.colorOnPrimary),
)
binding.clashModeButton.setBackgroundResource(R.drawable.bg_rounded_rectangle_active)
binding.clashModeButton.isClickable = false
}
-
}
}
-
class Adapter(
internal val scope: CoroutineScope,
- internal val parent: FragmentDashboardOverviewBinding
+ internal val parent: FragmentDashboardOverviewBinding,
) :
RecyclerView.Adapter() {
-
internal var items: MutableList = mutableListOf()
internal var selectedProfileID = -1L
internal var lastSelectedIndex: Int? = null
+
internal fun reload() {
scope.launch(Dispatchers.IO) {
items = ProfileManager.list().toMutableList()
@@ -299,33 +306,37 @@ class OverviewFragment : Fragment() {
}
}
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): Holder {
return Holder(
this,
ViewProfileItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
- false
- )
+ false,
+ ),
)
}
- override fun onBindViewHolder(holder: Holder, position: Int) {
+ override fun onBindViewHolder(
+ holder: Holder,
+ position: Int,
+ ) {
holder.bind(items[position])
}
override fun getItemCount(): Int {
return items.size
}
-
}
class Holder(
private val adapter: Adapter,
- private val binding: ViewProfileItemBinding
+ private val binding: ViewProfileItemBinding,
) :
RecyclerView.ViewHolder(binding.root) {
-
internal fun bind(profile: Profile) {
binding.profileName.text = profile.name
binding.profileSelected.setOnCheckedChangeListener(null)
@@ -375,5 +386,4 @@ class OverviewFragment : Fragment() {
}
}
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt
index cf83791..d7138c1 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt
@@ -7,7 +7,6 @@ import io.nekohasekai.sfa.databinding.ActivityDebugBinding
import io.nekohasekai.sfa.ui.shared.AbstractActivity
class DebugActivity : AbstractActivity() {
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -16,4 +15,4 @@ class DebugActivity : AbstractActivity() {
startActivity(Intent(this, VPNScanActivity::class.java))
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt
index 8439d19..55c34a8 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt
@@ -30,9 +30,9 @@ import java.util.zip.ZipFile
import kotlin.math.roundToInt
class VPNScanActivity : AbstractActivity() {
-
private var adapter: Adapter? = null
private val appInfoList = mutableListOf()
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -44,9 +44,10 @@ class VPNScanActivity : AbstractActivity() {
WindowInsetsCompat.CONSUMED
}
- binding.scanVPNResult.adapter = Adapter().also {
- adapter = it
- }
+ binding.scanVPNResult.adapter =
+ Adapter().also {
+ adapter = it
+ }
binding.scanVPNResult.layoutManager = LinearLayoutManager(this)
lifecycleScope.launch(Dispatchers.IO) {
scanVPN()
@@ -61,7 +62,7 @@ class VPNScanActivity : AbstractActivity() {
class VPNCoreType(
val coreType: String,
val corePath: String,
- val goVersion: String
+ val goVersion: String,
)
class AppInfo(
@@ -70,7 +71,10 @@ class VPNScanActivity : AbstractActivity() {
)
inner class Adapter : RecyclerView.Adapter() {
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): Holder {
return Holder(ViewVpnAppItemBinding.inflate(layoutInflater, parent, false))
}
@@ -78,16 +82,18 @@ class VPNScanActivity : AbstractActivity() {
return appInfoList.size
}
- override fun onBindViewHolder(holder: Holder, position: Int) {
+ override fun onBindViewHolder(
+ holder: Holder,
+ position: Int,
+ ) {
holder.bind(appInfoList[position])
}
}
class Holder(
- private val binding: ViewVpnAppItemBinding
+ private val binding: ViewVpnAppItemBinding,
) :
RecyclerView.ViewHolder(binding.root) {
-
fun bind(element: AppInfo) {
binding.appIcon.setImageDrawable(element.packageInfo.applicationInfo!!.loadIcon(binding.root.context.packageManager))
binding.appName.text =
@@ -126,18 +132,20 @@ class VPNScanActivity : AbstractActivity() {
private suspend fun scanVPN() {
val adapter = adapter ?: return
val flag =
- PackageManager.GET_SERVICES or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- PackageManager.MATCH_UNINSTALLED_PACKAGES
+ PackageManager.GET_SERVICES or
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ PackageManager.MATCH_UNINSTALLED_PACKAGES
+ } else {
+ @Suppress("DEPRECATION")
+ PackageManager.GET_UNINSTALLED_PACKAGES
+ }
+ val installedPackages =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(flag.toLong()))
} else {
@Suppress("DEPRECATION")
- PackageManager.GET_UNINSTALLED_PACKAGES
+ packageManager.getInstalledPackages(flag)
}
- val installedPackages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(flag.toLong()))
- } else {
- @Suppress("DEPRECATION")
- packageManager.getInstalledPackages(flag)
- }
val vpnAppList =
installedPackages.filter {
it.services?.any { it.permission == Manifest.permission.BIND_VPN_SERVICE && it.applicationInfo != null }
@@ -152,7 +160,7 @@ class VPNScanActivity : AbstractActivity() {
binding.scanVPNResult.scrollToPosition(index)
binding.scanVPNProgress.setProgressCompat(
(((index + 1).toFloat() / vpnAppList.size.toFloat()) * 100).roundToInt(),
- true
+ true,
)
}
System.gc()
@@ -163,41 +171,48 @@ class VPNScanActivity : AbstractActivity() {
}
companion object {
+ private val v2rayNGClasses =
+ listOf(
+ "com.v2ray.ang",
+ ".dto.V2rayConfig",
+ ".service.V2RayVpnService",
+ )
- private val v2rayNGClasses = listOf(
- "com.v2ray.ang",
- ".dto.V2rayConfig",
- ".service.V2RayVpnService",
- )
+ private val clashForAndroidClasses =
+ listOf(
+ "com.github.kr328.clash",
+ ".core.Clash",
+ ".service.TunService",
+ )
- private val clashForAndroidClasses = listOf(
- "com.github.kr328.clash",
- ".core.Clash",
- ".service.TunService",
- )
+ private val sfaClasses =
+ listOf(
+ "io.nekohasekai.sfa",
+ )
- private val sfaClasses = listOf(
- "io.nekohasekai.sfa"
- )
+ private val legacySagerNetClasses =
+ listOf(
+ "io.nekohasekai.sagernet",
+ ".fmt.ConfigBuilder",
+ )
- private val legacySagerNetClasses = listOf(
- "io.nekohasekai.sagernet",
- ".fmt.ConfigBuilder"
- )
-
- private val shadowsocksAndroidClasses = listOf(
- "com.github.shadowsocks",
- ".bg.VpnService",
- "GuardedProcessPool"
- )
+ private val shadowsocksAndroidClasses =
+ listOf(
+ "com.github.shadowsocks",
+ ".bg.VpnService",
+ "GuardedProcessPool",
+ )
}
private fun getVPNAppType(packageInfo: PackageInfo): String? {
ZipFile(File(packageInfo.applicationInfo!!.publicSourceDir)).use { packageFile ->
for (packageEntry in packageFile.entries()) {
- if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith(
- ".dex"
- ))
+ if (!(
+ packageEntry.name.startsWith("classes") &&
+ packageEntry.name.endsWith(
+ ".dex",
+ )
+ )
) {
continue
}
@@ -205,16 +220,18 @@ class VPNScanActivity : AbstractActivity() {
continue
}
val input = packageFile.getInputStream(packageEntry).buffered()
- val dexFile = try {
- DexBackedDexFile.fromInputStream(null, input)
- } catch (e: Exception) {
- Log.e("VPNScanActivity", "Failed to read dex file", e)
- continue
- }
+ val dexFile =
+ try {
+ DexBackedDexFile.fromInputStream(null, input)
+ } catch (e: Exception) {
+ Log.e("VPNScanActivity", "Failed to read dex file", e)
+ continue
+ }
for (clazz in dexFile.classes) {
- val clazzName = clazz.type.substring(1, clazz.type.length - 1)
- .replace("/", ".")
- .replace("$", ".")
+ val clazzName =
+ clazz.type.substring(1, clazz.type.length - 1)
+ .replace("/", ".")
+ .replace("$", ".")
for (v2rayNGClass in v2rayNGClasses) {
if (clazzName.contains(v2rayNGClass)) {
return "V2RayNG"
@@ -251,12 +268,12 @@ class VPNScanActivity : AbstractActivity() {
packageInfo.applicationInfo!!.splitPublicSourceDirs?.also {
packageFiles.addAll(it)
}
- val vpnType = try {
- Libbox.readAndroidVPNType(packageFiles.toStringIterator())
- } catch (ignored: Exception) {
- return null
- }
+ val vpnType =
+ try {
+ Libbox.readAndroidVPNType(packageFiles.toStringIterator())
+ } catch (ignored: Exception) {
+ return null
+ }
return VPNCoreType(vpnType.coreType, vpnType.corePath, vpnType.goVersion)
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt
index 5d40991..5981730 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt
@@ -39,11 +39,12 @@ import java.text.DateFormat
import java.util.Collections
class ConfigurationFragment : Fragment() {
-
private var adapter: Adapter? = null
override fun onCreateView(
- inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
): View {
val binding = FragmentConfigurationBinding.inflate(inflater, container, false)
val adapter = Adapter(binding)
@@ -51,30 +52,34 @@ class ConfigurationFragment : Fragment() {
binding.profileList.also {
it.layoutManager = LinearLayoutManager(requireContext())
it.adapter = adapter
- ItemTouchHelper(object :
- ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) {
-
- override fun onMove(
- recyclerView: RecyclerView,
- viewHolder: RecyclerView.ViewHolder,
- target: RecyclerView.ViewHolder
- ): Boolean {
- return adapter.move(viewHolder.adapterPosition, target.adapterPosition)
- }
-
- override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
- }
-
- override fun onSelectedChanged(
- viewHolder: RecyclerView.ViewHolder?,
- actionState: Int
- ) {
- super.onSelectedChanged(viewHolder, actionState)
- if (actionState == ItemTouchHelper.ACTION_STATE_IDLE) {
- adapter.updateUserOrder()
+ ItemTouchHelper(
+ object :
+ ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) {
+ override fun onMove(
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ target: RecyclerView.ViewHolder,
+ ): Boolean {
+ return adapter.move(viewHolder.adapterPosition, target.adapterPosition)
}
- }
- }).attachToRecyclerView(it)
+
+ override fun onSwiped(
+ viewHolder: RecyclerView.ViewHolder,
+ direction: Int,
+ ) {
+ }
+
+ override fun onSelectedChanged(
+ viewHolder: RecyclerView.ViewHolder?,
+ actionState: Int,
+ ) {
+ super.onSelectedChanged(viewHolder, actionState)
+ if (actionState == ItemTouchHelper.ACTION_STATE_IDLE) {
+ adapter.updateUserOrder()
+ }
+ }
+ },
+ ).attachToRecyclerView(it)
}
adapter.reload()
binding.fab.setOnClickListener {
@@ -85,14 +90,16 @@ class ConfigurationFragment : Fragment() {
}
class AddProfileDialog : BottomSheetDialogFragment(R.layout.sheet_add_profile) {
-
private val importFromFile =
registerForActivityResult(ActivityResultContracts.GetContent(), ::onImportResult)
private val scanQrCode =
registerForActivityResult(QRScanActivity.Contract(), ::onScanResult)
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ override fun onViewCreated(
+ view: View,
+ savedInstanceState: Bundle?,
+ ) {
super.onViewCreated(view, savedInstanceState)
val binding = SheetAddProfileBinding.bind(view)
binding.importFromFile.setOnClickListener {
@@ -134,10 +141,9 @@ class ConfigurationFragment : Fragment() {
}
inner class Adapter(
- private val parent: FragmentConfigurationBinding
+ private val parent: FragmentConfigurationBinding,
) :
RecyclerView.Adapter() {
-
internal var items: MutableList = mutableListOf()
internal val scope = lifecycleScope
internal val fragmentActivity = requireActivity()
@@ -160,7 +166,10 @@ class ConfigurationFragment : Fragment() {
}
}
- internal fun move(from: Int, to: Int): Boolean {
+ internal fun move(
+ from: Int,
+ to: Int,
+ ): Boolean {
if (from < to) {
for (i in from until to) {
Collections.swap(items, i, i + 1)
@@ -184,38 +193,43 @@ class ConfigurationFragment : Fragment() {
}
}
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): Holder {
return Holder(
this,
ViewConfigutationItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
- false
- )
+ false,
+ ),
)
}
- override fun onBindViewHolder(holder: Holder, position: Int) {
+ override fun onBindViewHolder(
+ holder: Holder,
+ position: Int,
+ ) {
holder.bind(items[position])
}
override fun getItemCount(): Int {
return items.size
}
-
}
class Holder(private val adapter: Adapter, private val binding: ViewConfigutationItemBinding) :
RecyclerView.ViewHolder(binding.root) {
-
internal fun bind(profile: Profile) {
binding.profileName.text = profile.name
if (profile.typed.type == TypedProfile.Type.Remote) {
binding.profileLastUpdated.isVisible = true
- binding.profileLastUpdated.text = binding.root.context.getString(
- R.string.profile_item_last_updated,
- DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated)
- )
+ binding.profileLastUpdated.text =
+ binding.root.context.getString(
+ R.string.last_updated_format,
+ DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated),
+ )
} else {
binding.profileLastUpdated.isVisible = false
}
@@ -277,5 +291,4 @@ class ConfigurationFragment : Fragment() {
}
}
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt b/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt
index 9a83679..194a6e2 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt
@@ -27,12 +27,14 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DashboardFragment : Fragment(R.layout.fragment_dashboard) {
-
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
private var binding: FragmentDashboardBinding? = null
private var mediator: TabLayoutMediator? = null
+
override fun onCreateView(
- inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
): View {
val binding = FragmentDashboardBinding.inflate(inflater, container, false)
this.binding = binding
@@ -41,6 +43,7 @@ class DashboardFragment : Fragment(R.layout.fragment_dashboard) {
}
private val adapter by lazy { Adapter(this) }
+
private fun onCreate() {
val activity = activity ?: return
val binding = binding ?: return
@@ -96,12 +99,13 @@ class DashboardFragment : Fragment(R.layout.fragment_dashboard) {
val activityBinding = activity?.binding ?: return
val binding = binding ?: return
if (mediator != null) return
- mediator = TabLayoutMediator(
- activityBinding.dashboardTabLayout,
- binding.dashboardPager
- ) { tab, position ->
- tab.setText(Page.values()[position].titleRes)
- }.apply { attach() }
+ mediator =
+ TabLayoutMediator(
+ activityBinding.dashboardTabLayout,
+ binding.dashboardPager,
+ ) { tab, position ->
+ tab.setText(Page.values()[position].titleRes)
+ }.apply { attach() }
}
override fun onDestroyView() {
@@ -163,9 +167,12 @@ class DashboardFragment : Fragment(R.layout.fragment_dashboard) {
binding.dashboardPager.setCurrentItem(0, false)
}
- enum class Page(@StringRes val titleRes: Int, val fragmentClass: Class) {
+ enum class Page(
+ @StringRes val titleRes: Int,
+ val fragmentClass: Class,
+ ) {
Overview(R.string.title_overview, OverviewFragment::class.java),
- Groups(R.string.title_groups, GroupsFragment::class.java);
+ Groups(R.string.title_groups, GroupsFragment::class.java),
}
class Adapter(parent: Fragment) : FragmentStateAdapter(parent) {
@@ -177,5 +184,4 @@ class DashboardFragment : Fragment(R.layout.fragment_dashboard) {
return Page.entries[position].fragmentClass.getConstructor().newInstance()
}
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt b/app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt
index 2ae6da0..e47ee2d 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt
@@ -9,6 +9,7 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import io.nekohasekai.libbox.LogEntry
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.BoxService
import io.nekohasekai.sfa.constant.Status
@@ -30,7 +31,9 @@ class LogFragment : Fragment(), CommandClient.Handler {
private val logList = LinkedList()
override fun onCreateView(
- inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
): View {
val binding = FragmentLogBinding.inflate(inflater, container, false)
this.binding = binding
@@ -89,7 +92,10 @@ class LogFragment : Fragment(), CommandClient.Handler {
}
}
- private fun updateViews(removeLen: Int = 0, insertLen: Int = 0) {
+ private fun updateViews(
+ removeLen: Int = 0,
+ insertLen: Int = 0,
+ ) {
val activity = activity ?: return
val logAdapter = adapter ?: return
val binding = binding ?: return
@@ -135,11 +141,18 @@ class LogFragment : Fragment(), CommandClient.Handler {
}
}
- override fun appendLogs(messageList: List) {
+ private var defaultLogLevel = 0
+
+ override fun setDefaultLogLevel(level: Int) {
+ defaultLogLevel = level
+ }
+
+ override fun appendLogs(messageList: List) {
+ val messageList = messageList.filter { it.level <= defaultLogLevel }
lifecycleScope.launch(Dispatchers.Main) {
val messageLen = messageList.size
val removeLen = logList.size + messageLen - 300
- logList.addAll(messageList)
+ logList.addAll(messageList.map { it.message })
if (removeLen > 0) {
repeat(removeLen) {
logList.removeFirst()
@@ -149,33 +162,37 @@ class LogFragment : Fragment(), CommandClient.Handler {
}
}
-
class Adapter(private val logList: LinkedList) :
RecyclerView.Adapter() {
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int,
+ ): LogViewHolder {
return LogViewHolder(
ViewLogTextItemBinding.inflate(
- LayoutInflater.from(parent.context), parent, false
- )
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
)
}
- override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
+ override fun onBindViewHolder(
+ holder: LogViewHolder,
+ position: Int,
+ ) {
holder.bind(logList.getOrElse(position) { "" })
}
override fun getItemCount(): Int {
return logList.size
}
-
}
class LogViewHolder(private val binding: ViewLogTextItemBinding) :
RecyclerView.ViewHolder(binding.root) {
-
fun bind(message: String) {
binding.text.text = ColorUtils.ansiEscapeToSpannable(binding.root.context, message)
}
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt b/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt
index 93f7d38..edde2fe 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt
@@ -33,10 +33,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SettingsFragment : Fragment() {
-
private lateinit var binding: FragmentSettingsBinding
+
override fun onCreateView(
- inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
): View {
binding = FragmentSettingsBinding.inflate(inflater, container, false)
onCreate()
@@ -44,13 +46,14 @@ class SettingsFragment : Fragment() {
}
@RequiresApi(Build.VERSION_CODES.M)
- private val requestIgnoreBatteryOptimizations = registerForActivityResult(
- ActivityResultContracts.StartActivityForResult()
- ) { result ->
- if (Application.powerManager.isIgnoringBatteryOptimizations(Application.application.packageName)) {
- binding.backgroundPermissionCard.isGone = true
+ private val requestIgnoreBatteryOptimizations =
+ registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult(),
+ ) { result ->
+ if (Application.powerManager.isIgnoringBatteryOptimizations(Application.application.packageName)) {
+ binding.backgroundPermissionCard.isGone = true
+ }
}
- }
@SuppressLint("BatteryLife")
private fun onCreate() {
@@ -63,6 +66,21 @@ class SettingsFragment : Fragment() {
reloadSettings()
}
}
+ binding.useComposeUIEnabled.addTextChangedListener {
+ lifecycleScope.launch(Dispatchers.IO) {
+ val newValue = EnabledType.valueOf(requireContext(), it).boolValue
+ Settings.useComposeUI = newValue
+ if (newValue) {
+ withContext(Dispatchers.Main) {
+ // Restart with Compose UI
+ val intent = Intent(requireContext(), Class.forName("io.nekohasekai.sfa.compose.ComposeActivity"))
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ startActivity(intent)
+ activity.finish()
+ }
+ }
+ }
+ }
if (!Vendor.checkUpdateAvailable()) {
binding.checkUpdateEnabled.isVisible = false
binding.checkUpdateButton.isVisible = false
@@ -101,8 +119,8 @@ class SettingsFragment : Fragment() {
requestIgnoreBatteryOptimizations.launch(
Intent(
android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
- Uri.parse("package:${Application.application.packageName}")
- )
+ Uri.parse("package:${Application.application.packageName}"),
+ ),
)
}
}
@@ -123,17 +141,20 @@ class SettingsFragment : Fragment() {
private suspend fun reloadSettings() {
val activity = activity ?: return
val binding = binding ?: return
- val dataSize = Libbox.formatBytes(
- (activity.getExternalFilesDir(null) ?: activity.filesDir)
- .walkTopDown().filter { it.isFile }.map { it.length() }.sum()
- )
+ val dataSize =
+ Libbox.formatBytes(
+ (activity.getExternalFilesDir(null) ?: activity.filesDir)
+ .walkTopDown().filter { it.isFile }.map { it.length() }.sum(),
+ )
val checkUpdateEnabled = Settings.checkUpdateEnabled
- val removeBackgroundPermissionPage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- Application.powerManager.isIgnoringBatteryOptimizations(Application.application.packageName)
- } else {
- true
- }
+ val removeBackgroundPermissionPage =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ Application.powerManager.isIgnoringBatteryOptimizations(Application.application.packageName)
+ } else {
+ true
+ }
val dynamicNotification = Settings.dynamicNotification
+ val useComposeUI = Settings.useComposeUI
withContext(Dispatchers.Main) {
binding.dataSizeText.text = dataSize
binding.checkUpdateEnabled.text =
@@ -146,7 +167,9 @@ class SettingsFragment : Fragment() {
binding.dynamicNotificationEnabled.text =
EnabledType.from(dynamicNotification).getString(requireContext())
binding.dynamicNotificationEnabled.setSimpleItems(R.array.enabled)
+ binding.useComposeUIEnabled.text =
+ EnabledType.from(useComposeUI).getString(requireContext())
+ binding.useComposeUIEnabled.setSimpleItems(R.array.enabled)
}
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt
index da26bbe..a2e1d42 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt
@@ -8,6 +8,7 @@ import androidx.lifecycle.lifecycleScope
import io.nekohasekai.libbox.Libbox
import io.nekohasekai.sfa.R
import io.nekohasekai.sfa.bg.UpdateProfileWork
+import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter
import io.nekohasekai.sfa.constant.EnabledType
import io.nekohasekai.sfa.database.Profile
import io.nekohasekai.sfa.database.ProfileManager
@@ -25,12 +26,11 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
-import java.text.DateFormat
import java.util.Date
class EditProfileActivity : AbstractActivity() {
-
private lateinit var profile: Profile
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -72,10 +72,11 @@ class EditProfileActivity : AbstractActivity() {
startActivity(
Intent(
this@EditProfileActivity,
- EditProfileContentActivity::class.java
+ EditProfileContentActivity::class.java,
).apply {
putExtra("profile_id", profile.id)
- })
+ },
+ )
}
when (profile.typed.type) {
TypedProfile.Type.Local -> {
@@ -88,9 +89,10 @@ class EditProfileActivity : AbstractActivity() {
binding.remoteFields.isVisible = true
binding.remoteURL.text = profile.typed.remoteURL
binding.lastUpdated.text =
- DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated)
- binding.autoUpdate.text = EnabledType.from(profile.typed.autoUpdate)
- .getString(this@EditProfileActivity)
+ RelativeTimeFormatter.format(this@EditProfileActivity, profile.typed.lastUpdated)
+ binding.autoUpdate.text =
+ EnabledType.from(profile.typed.autoUpdate)
+ .getString(this@EditProfileActivity)
binding.autoUpdate.setSimpleItems(R.array.enabled)
binding.autoUpdateInterval.isVisible = profile.typed.autoUpdate
binding.autoUpdateInterval.text = profile.typed.autoUpdateInterval.toString()
@@ -105,7 +107,6 @@ class EditProfileActivity : AbstractActivity() {
}
}
-
private fun updateRemoteURL(newValue: String) {
profile.typed.remoteURL = newValue
updateProfile()
@@ -131,12 +132,13 @@ class EditProfileActivity : AbstractActivity() {
binding.autoUpdateInterval.error = getString(R.string.profile_input_required)
return
}
- val intValue = try {
- newValue.toInt()
- } catch (e: Exception) {
- binding.autoUpdateInterval.error = e.localizedMessage
- return
- }
+ val intValue =
+ try {
+ newValue.toInt()
+ } catch (e: Exception) {
+ binding.autoUpdateInterval.error = e.localizedMessage
+ return
+ }
if (intValue < 15) {
binding.autoUpdateInterval.error =
getString(R.string.profile_auto_update_interval_minimum_hint)
@@ -188,7 +190,7 @@ class EditProfileActivity : AbstractActivity() {
}
withContext(Dispatchers.Main) {
binding.lastUpdated.text =
- DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated)
+ RelativeTimeFormatter.format(this@EditProfileActivity, profile.typed.lastUpdated)
binding.progressView.isVisible = false
}
if (selectedProfileUpdated) {
@@ -198,5 +200,4 @@ class EditProfileActivity : AbstractActivity() {
}
}
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt
index df762eb..28c6f08 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt
@@ -23,8 +23,8 @@ import kotlinx.coroutines.withContext
import java.io.File
class EditProfileContentActivity : AbstractActivity() {
-
private var profile: Profile? = null
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -135,5 +135,4 @@ class EditProfileContentActivity : AbstractActivity() {
- enum class FileSource(@StringRes val formattedRes: Int) {
+ enum class FileSource(
+ @StringRes val formattedRes: Int,
+ ) {
CreateNew(R.string.profile_source_create_new),
- Import(R.string.profile_source_import);
+ Import(R.string.profile_source_import),
+ ;
fun formatted(context: Context): String {
return context.getString(formattedRes)
@@ -100,7 +103,9 @@ class NewProfileActivity : AbstractActivity() {
binding.autoUpdateInterval.addTextChangedListener(this::updateAutoUpdateInterval)
}
- private fun createProfile(@Suppress("UNUSED_PARAMETER") view: View) {
+ private fun createProfile(
+ @Suppress("UNUSED_PARAMETER") view: View,
+ ) {
if (binding.name.showErrorIfEmpty()) {
return
}
@@ -154,17 +159,18 @@ class NewProfileActivity : AbstractActivity() {
FileSource.Import.formatted(this) -> {
val sourceURL = binding.sourceURL.text
- val content = if (sourceURL.startsWith("content://")) {
- val inputStream =
- contentResolver.openInputStream(Uri.parse(sourceURL)) as InputStream
- inputStream.use { it.bufferedReader().readText() }
- } else if (sourceURL.startsWith("file://")) {
- File(sourceURL).readText()
- } else if (sourceURL.startsWith("http://") || sourceURL.startsWith("https://")) {
- HTTPClient().use { it.getString(sourceURL) }
- } else {
- error("unsupported source: $sourceURL")
- }
+ val content =
+ if (sourceURL.startsWith("content://")) {
+ val inputStream =
+ contentResolver.openInputStream(Uri.parse(sourceURL)) as InputStream
+ inputStream.use { it.bufferedReader().readText() }
+ } else if (sourceURL.startsWith("file://")) {
+ File(sourceURL).readText()
+ } else if (sourceURL.startsWith("http://") || sourceURL.startsWith("https://")) {
+ HTTPClient().use { it.getString(sourceURL) }
+ } else {
+ error("unsupported source: $sourceURL")
+ }
Libbox.checkConfig(content)
configFile.writeText(content)
}
@@ -198,12 +204,13 @@ class NewProfileActivity : AbstractActivity() {
binding.autoUpdateInterval.error = getString(R.string.profile_input_required)
return
}
- val intValue = try {
- newValue.toInt()
- } catch (e: Exception) {
- binding.autoUpdateInterval.error = e.localizedMessage
- return
- }
+ val intValue =
+ try {
+ newValue.toInt()
+ } catch (e: Exception) {
+ binding.autoUpdateInterval.error = e.localizedMessage
+ return
+ }
if (intValue < 15) {
binding.autoUpdateInterval.error =
getString(R.string.profile_auto_update_interval_minimum_hint)
@@ -211,6 +218,4 @@ class NewProfileActivity : AbstractActivity() {
}
binding.autoUpdateInterval.error = null
}
-
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt
index ce3dedd..3297434 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt
@@ -30,8 +30,8 @@ import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class QRScanActivity : AbstractActivity() {
-
private lateinit var analysisExecutor: ExecutorService
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -46,7 +46,7 @@ class QRScanActivity : AbstractActivity() {
}
}
if (ContextCompat.checkSelfPermission(
- this, Manifest.permission.CAMERA
+ this, Manifest.permission.CAMERA,
) == PackageManager.PERMISSION_GRANTED
) {
startCamera()
@@ -81,6 +81,7 @@ class QRScanActivity : AbstractActivity() {
}
private val vendorAnalyzer = Vendor.createQRCodeAnalyzer(onSuccess, onFailure)
private var useVendorAnalyzer = vendorAnalyzer != null
+
private fun resetAnalyzer() {
if (useVendorAnalyzer) {
useVendorAnalyzer = false
@@ -95,31 +96,35 @@ class QRScanActivity : AbstractActivity() {
private lateinit var camera: Camera
private fun startCamera() {
- val cameraProviderFuture = try {
- ProcessCameraProvider.getInstance(this)
- } catch (e: Exception) {
- fatalError(e)
- return
- }
- cameraProviderFuture.addListener({
- cameraProvider = try {
- cameraProviderFuture.get()
+ val cameraProviderFuture =
+ try {
+ ProcessCameraProvider.getInstance(this)
} catch (e: Exception) {
fatalError(e)
- return@addListener
+ return
}
+ cameraProviderFuture.addListener({
+ cameraProvider =
+ try {
+ cameraProviderFuture.get()
+ } catch (e: Exception) {
+ fatalError(e)
+ return@addListener
+ }
- cameraPreview = Preview.Builder().build()
- .also { it.setSurfaceProvider(binding.previewView.surfaceProvider) }
+ cameraPreview =
+ Preview.Builder().build()
+ .also { it.setSurfaceProvider(binding.previewView.surfaceProvider) }
imageAnalysis = ImageAnalysis.Builder().build()
imageAnalyzer = vendorAnalyzer ?: ZxingQRCodeAnalyzer(onSuccess, onFailure)
imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer)
cameraProvider.unbindAll()
try {
- camera = cameraProvider.bindToLifecycle(
- this, CameraSelector.DEFAULT_BACK_CAMERA, cameraPreview, imageAnalysis
- )
+ camera =
+ cameraProvider.bindToLifecycle(
+ this, CameraSelector.DEFAULT_BACK_CAMERA, cameraPreview, imageAnalysis,
+ )
} catch (e: Exception) {
fatalError(e)
}
@@ -151,9 +156,12 @@ class QRScanActivity : AbstractActivity() {
val uri = Uri.parse(uriString)
if (uri.scheme != "sing-box" || uri.host != "import-remote-profile") error("Not a valid sing-box remote profile URI")
Libbox.parseRemoteProfileImportLink(uri.toString())
- setResult(RESULT_OK, Intent().apply {
- setData(uri)
- })
+ setResult(
+ RESULT_OK,
+ Intent().apply {
+ setData(uri)
+ },
+ )
finish()
}
@@ -181,12 +189,13 @@ class QRScanActivity : AbstractActivity() {
item.isChecked = !item.isChecked
cameraProvider.unbindAll()
try {
- camera = cameraProvider.bindToLifecycle(
- this,
- if (!item.isChecked) CameraSelector.DEFAULT_BACK_CAMERA else CameraSelector.DEFAULT_FRONT_CAMERA,
- cameraPreview,
- imageAnalysis
- )
+ camera =
+ cameraProvider.bindToLifecycle(
+ this,
+ if (!item.isChecked) CameraSelector.DEFAULT_BACK_CAMERA else CameraSelector.DEFAULT_FRONT_CAMERA,
+ cameraPreview,
+ imageAnalysis,
+ )
} catch (e: Exception) {
fatalError(e)
}
@@ -200,11 +209,12 @@ class QRScanActivity : AbstractActivity() {
R.id.action_use_vendor_analyzer -> {
item.isChecked = !item.isChecked
imageAnalysis.clearAnalyzer()
- imageAnalyzer = if (item.isChecked) {
- vendorAnalyzer!!
- } else {
- ZxingQRCodeAnalyzer(onSuccess, onFailure)
- }
+ imageAnalyzer =
+ if (item.isChecked) {
+ vendorAnalyzer!!
+ } else {
+ ZxingQRCodeAnalyzer(onSuccess, onFailure)
+ }
imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer)
}
@@ -213,23 +223,25 @@ class QRScanActivity : AbstractActivity() {
return true
}
-
override fun onDestroy() {
super.onDestroy()
analysisExecutor.shutdown()
}
class Contract : ActivityResultContract() {
+ override fun createIntent(
+ context: Context,
+ input: Nothing?,
+ ): Intent = Intent(context, QRScanActivity::class.java)
- override fun createIntent(context: Context, input: Nothing?): Intent =
- Intent(context, QRScanActivity::class.java)
-
- override fun parseResult(resultCode: Int, intent: Intent?): Intent? {
+ override fun parseResult(
+ resultCode: Int,
+ intent: Intent?,
+ ): Intent? {
return when (resultCode) {
RESULT_OK -> intent
else -> null
}
}
}
-
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt
index 5d35d27..372b85d 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt
@@ -13,8 +13,8 @@ class ZxingQRCodeAnalyzer(
private val onSuccess: ((String) -> Unit),
private val onFailure: ((Exception) -> Unit),
) : ImageAnalysis.Analyzer {
-
private val qrCodeReader = QRCodeReader()
+
override fun analyze(image: ImageProxy) {
try {
val bitmap = image.toBitmap()
@@ -26,18 +26,19 @@ class ZxingQRCodeAnalyzer(
0,
0,
bitmap.getWidth(),
- bitmap.getHeight()
+ bitmap.getHeight(),
)
val source = RGBLuminanceSource(bitmap.getWidth(), bitmap.getHeight(), intArray)
- val result = try {
- qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source)))
- } catch (e: NotFoundException) {
+ val result =
try {
- qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())))
- } catch (ignore: NotFoundException) {
- return
+ qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source)))
+ } catch (e: NotFoundException) {
+ try {
+ qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())))
+ } catch (ignore: NotFoundException) {
+ return
+ }
}
- }
Log.d("ZxingQRCodeAnalyzer", "barcode decode success: ${result.text}")
onSuccess(result.text)
} catch (e: Exception) {
@@ -46,4 +47,4 @@ class ZxingQRCodeAnalyzer(
image.close()
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt
index 6165b3b..8d13a8b 100644
--- a/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt
+++ b/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt
@@ -32,6 +32,7 @@ import io.nekohasekai.sfa.databinding.DialogProgressbarBinding
import io.nekohasekai.sfa.databinding.ViewAppListItemBinding
import io.nekohasekai.sfa.ktx.clipboardText
import io.nekohasekai.sfa.ui.shared.AbstractActivity
+import io.nekohasekai.sfa.vendor.Vendor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -44,7 +45,11 @@ import java.util.zip.ZipFile
class PerAppProxyActivity : AbstractActivity() {
enum class SortMode {
- NAME, PACKAGE_NAME, UID, INSTALL_TIME, UPDATE_TIME,
+ NAME,
+ PACKAGE_NAME,
+ UID,
+ INSTALL_TIME,
+ UPDATE_TIME,
}
private var proxyMode = Settings.PER_APP_PROXY_INCLUDE
@@ -58,7 +63,6 @@ class PerAppProxyActivity : AbstractActivity() {
private val packageInfo: PackageInfo,
private val appInfo: ApplicationInfo,
) {
-
val packageName: String get() = packageInfo.packageName
val uid get() = packageInfo.applicationInfo!!.uid
@@ -87,7 +91,20 @@ class PerAppProxyActivity : AbstractActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setTitle(R.string.title_per_app_proxy)
+ // Check if Per-app Proxy is available
+ if (!Vendor.isPerAppProxyAvailable()) {
+ MaterialAlertDialogBuilder(this)
+ .setTitle("Unavailable")
+ .setMessage(getString(R.string.per_app_proxy_disabled_message))
+ .setPositiveButton(R.string.ok) { _, _ ->
+ finish()
+ }
+ .setCancelable(false)
+ .show()
+ return
+ }
+
+ setTitle(R.string.per_app_proxy)
ViewCompat.setOnApplyWindowInsetsListener(binding.appList) { view, windowInsets ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
@@ -97,11 +114,12 @@ class PerAppProxyActivity : AbstractActivity() {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
- proxyMode = if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) {
- Settings.PER_APP_PROXY_INCLUDE
- } else {
- Settings.PER_APP_PROXY_EXCLUDE
- }
+ proxyMode =
+ if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) {
+ Settings.PER_APP_PROXY_INCLUDE
+ } else {
+ Settings.PER_APP_PROXY_EXCLUDE
+ }
withContext(Dispatchers.Main) {
if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) {
binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_include_description)
@@ -122,21 +140,24 @@ class PerAppProxyActivity : AbstractActivity() {
}
private fun reloadApplicationList() {
- val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- PackageManager.GET_PERMISSIONS or PackageManager.MATCH_UNINSTALLED_PACKAGES
- } else {
- @Suppress("DEPRECATION")
- PackageManager.GET_PERMISSIONS or PackageManager.GET_UNINSTALLED_PACKAGES
- }
- val installedPackages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- packageManager.getInstalledPackages(
- PackageManager.PackageInfoFlags.of(
- packageManagerFlags.toLong()
+ val packageManagerFlags =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ PackageManager.GET_PERMISSIONS or PackageManager.MATCH_UNINSTALLED_PACKAGES
+ } else {
+ @Suppress("DEPRECATION")
+ PackageManager.GET_PERMISSIONS or PackageManager.GET_UNINSTALLED_PACKAGES
+ }
+ val installedPackages =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ packageManager.getInstalledPackages(
+ PackageManager.PackageInfoFlags.of(
+ packageManagerFlags.toLong(),
+ ),
)
- )
- } else {
- @Suppress("DEPRECATION") packageManager.getInstalledPackages(packageManagerFlags)
- }
+ } else {
+ @Suppress("DEPRECATION")
+ packageManager.getInstalledPackages(packageManagerFlags)
+ }
val packages = mutableListOf()
for (packageInfo in installedPackages) {
if (packageInfo.packageName == packageName) continue
@@ -162,38 +183,48 @@ class PerAppProxyActivity : AbstractActivity() {
if (hideDisabledApps && packageCache.isDisabled) continue
displayPackages.add(packageCache)
}
- displayPackages.sortWith(compareBy {
- !selectedUIDs.contains(it.uid)
- }.let {
- if (!sortReverse) it.thenBy {
- when (sortMode) {
- SortMode.NAME -> it.applicationLabel
- SortMode.PACKAGE_NAME -> it.packageName
- SortMode.UID -> it.uid
- SortMode.INSTALL_TIME -> it.installTime
- SortMode.UPDATE_TIME -> it.updateTime
+ displayPackages.sortWith(
+ compareBy {
+ !selectedUIDs.contains(it.uid)
+ }.let {
+ if (!sortReverse) {
+ it.thenBy {
+ when (sortMode) {
+ SortMode.NAME -> it.applicationLabel
+ SortMode.PACKAGE_NAME -> it.packageName
+ SortMode.UID -> it.uid
+ SortMode.INSTALL_TIME -> it.installTime
+ SortMode.UPDATE_TIME -> it.updateTime
+ }
+ }
+ } else {
+ it.thenByDescending {
+ when (sortMode) {
+ SortMode.NAME -> it.applicationLabel
+ SortMode.PACKAGE_NAME -> it.packageName
+ SortMode.UID -> it.uid
+ SortMode.INSTALL_TIME -> it.installTime
+ SortMode.UPDATE_TIME -> it.updateTime
+ }
+ }
}
- } else it.thenByDescending {
- when (sortMode) {
- SortMode.NAME -> it.applicationLabel
- SortMode.PACKAGE_NAME -> it.packageName
- SortMode.UID -> it.uid
- SortMode.INSTALL_TIME -> it.installTime
- SortMode.UPDATE_TIME -> it.updateTime
- }
- }
- })
+ },
+ )
this.displayPackages = displayPackages
this.currentPackages = displayPackages
}
- private fun updateApplicationSelection(packageCache: PackageCache, selected: Boolean) {
- val performed = if (selected) {
- selectedUIDs.add(packageCache.uid)
- } else {
- selectedUIDs.remove(packageCache.uid)
- }
+ private fun updateApplicationSelection(
+ packageCache: PackageCache,
+ selected: Boolean,
+ ) {
+ val performed =
+ if (selected) {
+ selectedUIDs.add(packageCache.uid)
+ } else {
+ selectedUIDs.remove(packageCache.uid)
+ }
if (!performed) return
currentPackages.forEachIndexed { index, it ->
if (it.uid == packageCache.uid) {
@@ -207,7 +238,6 @@ class PerAppProxyActivity : AbstractActivity() {
inner class ApplicationAdapter(private var applicationList: List) :
RecyclerView.Adapter() {
-
@SuppressLint("NotifyDataSetChanged")
fun setApplicationList(applicationList: List) {
this.applicationList = applicationList
@@ -215,12 +245,15 @@ class PerAppProxyActivity : AbstractActivity() {
}
override fun onCreateViewHolder(
- parent: ViewGroup, viewType: Int
+ parent: ViewGroup,
+ viewType: Int,
): ApplicationViewHolder {
return ApplicationViewHolder(
ViewAppListItemBinding.inflate(
- LayoutInflater.from(parent.context), parent, false
- )
+ LayoutInflater.from(parent.context),
+ parent,
+ false,
+ ),
)
}
@@ -229,13 +262,16 @@ class PerAppProxyActivity : AbstractActivity() {
}
override fun onBindViewHolder(
- holder: ApplicationViewHolder, position: Int
+ holder: ApplicationViewHolder,
+ position: Int,
) {
holder.bind(applicationList[position])
}
override fun onBindViewHolder(
- holder: ApplicationViewHolder, position: Int, payloads: MutableList
+ holder: ApplicationViewHolder,
+ position: Int,
+ payloads: MutableList,
) {
if (payloads.isEmpty()) {
onBindViewHolder(holder, position)
@@ -250,9 +286,8 @@ class PerAppProxyActivity : AbstractActivity() {
}
inner class ApplicationViewHolder(
- private val binding: ViewAppListItemBinding
+ private val binding: ViewAppListItemBinding,
) : RecyclerView.ViewHolder(binding.root) {
-
@SuppressLint("SetTextI18n")
fun bind(packageCache: PackageCache) {
binding.appIcon.setImageDrawable(packageCache.applicationIcon)
@@ -298,17 +333,19 @@ class PerAppProxyActivity : AbstractActivity() {
}
private fun searchApplications(searchText: String) {
- currentPackages = if (searchText.isEmpty()) {
- displayPackages
- } else {
- displayPackages.filter {
- it.applicationLabel.contains(
- searchText, ignoreCase = true
- ) || it.packageName.contains(
- searchText, ignoreCase = true
- ) || it.uid.toString().contains(searchText)
+ currentPackages =
+ if (searchText.isEmpty()) {
+ displayPackages
+ } else {
+ displayPackages.filter {
+ it.applicationLabel.contains(
+ searchText, ignoreCase = true,
+ ) ||
+ it.packageName.contains(
+ searchText, ignoreCase = true,
+ ) || it.uid.toString().contains(searchText)
+ }
}
- }
adapter.setApplicationList(currentPackages)
}
@@ -317,16 +354,18 @@ class PerAppProxyActivity : AbstractActivity() {
if (menu != null) {
val searchView = menu.findItem(R.id.action_search).actionView as SearchView
- searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
- override fun onQueryTextSubmit(query: String): Boolean {
- return true
- }
+ searchView.setOnQueryTextListener(
+ object : SearchView.OnQueryTextListener {
+ override fun onQueryTextSubmit(query: String): Boolean {
+ return true
+ }
- override fun onQueryTextChange(newText: String): Boolean {
- searchApplications(newText)
- return true
- }
- })
+ override fun onQueryTextChange(newText: String): Boolean {
+ searchApplications(newText)
+ return true
+ }
+ },
+ )
searchView.setOnCloseListener {
searchApplications("")
true
@@ -483,20 +522,21 @@ class PerAppProxyActivity : AbstractActivity() {
Toast.makeText(
this@PerAppProxyActivity,
R.string.toast_copied_to_clipboard,
- Toast.LENGTH_SHORT
+ Toast.LENGTH_SHORT,
).show()
}
}
}
R.id.action_import -> {
- val packageNames = clipboardText?.split("\n")?.distinct()
- ?.takeIf { it.isNotEmpty() && it[0].isNotEmpty() }
+ val packageNames =
+ clipboardText?.split("\n")?.distinct()
+ ?.takeIf { it.isNotEmpty() && it[0].isNotEmpty() }
if (packageNames.isNullOrEmpty()) {
Toast.makeText(
this@PerAppProxyActivity,
R.string.toast_clipboard_empty,
- Toast.LENGTH_SHORT
+ Toast.LENGTH_SHORT,
).show()
return true
}
@@ -512,11 +552,10 @@ class PerAppProxyActivity : AbstractActivity() {
Toast.makeText(
this@PerAppProxyActivity,
R.string.toast_imported_from_clipboard,
- Toast.LENGTH_SHORT
+ Toast.LENGTH_SHORT,
).show()
}
}
-
}
R.id.action_scan_china_apps -> {
@@ -539,30 +578,33 @@ class PerAppProxyActivity : AbstractActivity() {
} else {
com.google.android.material.R.style.Theme_MaterialComponents_Light_Dialog
}
- val progress = MaterialAlertDialogBuilder(
- this, dialogTheme
- ).setView(binding.root).setCancelable(false).create()
+ val progress =
+ MaterialAlertDialogBuilder(
+ this,
+ dialogTheme,
+ ).setView(binding.root).setCancelable(false).create()
progress.show()
lifecycleScope.launch {
val startTime = System.currentTimeMillis()
- val foundApps = withContext(Dispatchers.Default) {
- mutableMapOf().also { foundApps ->
- val progressInt = AtomicInteger()
- currentPackages.map { it ->
- async {
- if (scanChinaPackage(it.packageName)) {
- foundApps[it.packageName] = it
+ val foundApps =
+ withContext(Dispatchers.Default) {
+ mutableMapOf().also { foundApps ->
+ val progressInt = AtomicInteger()
+ currentPackages.map { it ->
+ async {
+ if (scanChinaPackage(it.packageName)) {
+ foundApps[it.packageName] = it
+ }
+ runOnUiThread {
+ binding.progress.progress = progressInt.addAndGet(1)
+ }
}
- runOnUiThread {
- binding.progress.progress = progressInt.addAndGet(1)
- }
- }
- }.awaitAll()
+ }.awaitAll()
+ }
}
- }
Log.d(
"PerAppProxyActivity",
- "Scan China apps took ${(System.currentTimeMillis() - startTime).toDouble() / 1000}s"
+ "Scan China apps took ${(System.currentTimeMillis() - startTime).toDouble() / 1000}s",
)
withContext(Dispatchers.Main) {
progress.dismiss()
@@ -573,14 +615,15 @@ class PerAppProxyActivity : AbstractActivity() {
return@withContext
}
val dialogContent =
- getString(R.string.message_scan_app_found) + "\n\n" + foundApps.entries.joinToString(
- "\n"
- ) {
- "${it.value.applicationLabel} (${it.key})"
- }
+ getString(R.string.message_scan_app_found) + "\n\n" +
+ foundApps.entries.joinToString(
+ "\n",
+ ) {
+ "${it.value.applicationLabel} (${it.key})"
+ }
MaterialAlertDialogBuilder(this@PerAppProxyActivity).setTitle(R.string.title_scan_result)
.setMessage(dialogContent)
- .setPositiveButton(R.string.action_select) { dialog, _ ->
+ .setPositiveButton(R.string.per_app_proxy_select) { dialog, _ ->
dialog.dismiss()
lifecycleScope.launch {
val selectedUIDs = selectedUIDs.toMutableSet()
@@ -601,7 +644,6 @@ class PerAppProxyActivity : AbstractActivity() {
}.setNeutralButton(android.R.string.cancel, null).show()
}
}
-
}
@SuppressLint("NotifyDataSetChanged")
@@ -611,75 +653,77 @@ class PerAppProxyActivity : AbstractActivity() {
selectedUIDs = newUIDs
adapter.notifyDataSetChanged()
}
- val packageList = selectedUIDs.mapNotNull { uid ->
- packages.find { it.uid == uid }?.packageName
- }
+ val packageList =
+ selectedUIDs.mapNotNull { uid ->
+ packages.find { it.uid == uid }?.packageName
+ }
Settings.perAppProxyList = packageList.toSet()
}
private fun saveSelectedApplications() {
lifecycleScope.launch {
- val packageList = selectedUIDs.mapNotNull { uid ->
- packages.find { it.uid == uid }?.packageName
- }
+ val packageList =
+ selectedUIDs.mapNotNull { uid ->
+ packages.find { it.uid == uid }?.packageName
+ }
Settings.perAppProxyList = packageList.toSet()
}
}
companion object {
+ private val skipPrefixList =
+ listOf(
+ "com.google",
+ "com.android.chrome",
+ "com.android.vending",
+ "com.microsoft",
+ "com.apple",
+ "com.zhiliaoapp.musically", // Banned by China
+ "com.android.providers.downloads",
+ )
- private val skipPrefixList = listOf(
- "com.google",
- "com.android.chrome",
- "com.android.vending",
- "com.microsoft",
- "com.apple",
- "com.zhiliaoapp.musically", // Banned by China
- "com.android.providers.downloads",
- )
-
- private val chinaAppPrefixList = listOf(
- "com.tencent",
- "com.alibaba",
- "com.umeng",
- "com.qihoo",
- "com.ali",
- "com.alipay",
- "com.amap",
- "com.sina",
- "com.weibo",
- "com.vivo",
- "com.xiaomi",
- "com.huawei",
- "com.taobao",
- "com.secneo",
- "s.h.e.l.l",
- "com.stub",
- "com.kiwisec",
- "com.secshell",
- "com.wrapper",
- "cn.securitystack",
- "com.mogosec",
- "com.secoen",
- "com.netease",
- "com.mx",
- "com.qq.e",
- "com.baidu",
- "com.bytedance",
- "com.bugly",
- "com.miui",
- "com.oppo",
- "com.coloros",
- "com.iqoo",
- "com.meizu",
- "com.gionee",
- "cn.nubia",
- "com.oplus",
- "andes.oplus",
- "com.unionpay",
- "cn.wps"
- )
-
+ private val chinaAppPrefixList =
+ listOf(
+ "com.tencent",
+ "com.alibaba",
+ "com.umeng",
+ "com.qihoo",
+ "com.ali",
+ "com.alipay",
+ "com.amap",
+ "com.sina",
+ "com.weibo",
+ "com.vivo",
+ "com.xiaomi",
+ "com.huawei",
+ "com.taobao",
+ "com.secneo",
+ "s.h.e.l.l",
+ "com.stub",
+ "com.kiwisec",
+ "com.secshell",
+ "com.wrapper",
+ "cn.securitystack",
+ "com.mogosec",
+ "com.secoen",
+ "com.netease",
+ "com.mx",
+ "com.qq.e",
+ "com.baidu",
+ "com.bytedance",
+ "com.bugly",
+ "com.miui",
+ "com.oppo",
+ "com.coloros",
+ "com.iqoo",
+ "com.meizu",
+ "com.gionee",
+ "cn.nubia",
+ "com.oplus",
+ "andes.oplus",
+ "com.unionpay",
+ "cn.wps",
+ )
private val chinaAppRegex by lazy {
("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex()
@@ -690,27 +734,31 @@ class PerAppProxyActivity : AbstractActivity