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() { if (packageName == it || packageName.startsWith("$it.")) return false } - val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS - } else { - @Suppress("DEPRECATION") - PackageManager.GET_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS - } + val packageManagerFlags = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS + } else { + @Suppress("DEPRECATION") + PackageManager.GET_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS + } if (packageName.matches(chinaAppRegex)) { Log.d("PerAppProxyActivity", "Match package name: $packageName") return true } try { - val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - Application.packageManager.getPackageInfo( - packageName, - PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong()) - ) - } else { - @Suppress("DEPRECATION") Application.packageManager.getPackageInfo( - packageName, packageManagerFlags - ) - } + val packageInfo = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Application.packageManager.getPackageInfo( + packageName, + PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong()), + ) + } else { + @Suppress("DEPRECATION") + Application.packageManager.getPackageInfo( + packageName, + packageManagerFlags, + ) + } val appInfo = packageInfo.applicationInfo ?: return false packageInfo.services?.forEach { if (it.name.matches(chinaAppRegex)) { @@ -741,26 +789,30 @@ class PerAppProxyActivity : AbstractActivity() { if (packageEntry.name.startsWith("firebase-")) return false } for (packageEntry in it.entries()) { - if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith( - ".dex" - )) + if (!( + packageEntry.name.startsWith("classes") && + packageEntry.name.endsWith( + ".dex", + ) + ) ) { continue } if (packageEntry.size > 15000000) { Log.d( "PerAppProxyActivity", - "Confirm $packageName due to large dex file" + "Confirm $packageName due to large dex file", ) return true } val input = it.getInputStream(packageEntry).buffered() - val dexFile = try { - DexBackedDexFile.fromInputStream(null, input) - } catch (e: Exception) { - Log.e("PerAppProxyActivity", "Error reading dex file", e) - return false - } + val dexFile = + try { + DexBackedDexFile.fromInputStream(null, input) + } catch (e: Exception) { + Log.e("PerAppProxyActivity", "Error reading dex file", e) + return false + } for (clazz in dexFile.classes) { val clazzName = clazz.type.substring(1, clazz.type.length - 1).replace("/", ".") @@ -778,5 +830,4 @@ class PerAppProxyActivity : AbstractActivity() { return false } } - } diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt index 4be88f2..39bc75e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt @@ -17,11 +17,10 @@ import kotlinx.coroutines.withContext class ProfileOverrideActivity : AbstractActivity() { - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setTitle(R.string.title_profile_override) + setTitle(R.string.profile_override) binding.switchPerAppProxy.isChecked = Settings.perAppProxyEnabled binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked -> Settings.perAppProxyEnabled = isChecked @@ -55,4 +54,4 @@ class ProfileOverrideActivity : binding.perAppProxyUpdateOnChange.setSimpleItems(R.array.per_app_proxy_update_on_change_value) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt b/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt index 92005c1..d83c2e2 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt @@ -19,7 +19,6 @@ import io.nekohasekai.sfa.utils.MIUIUtils import java.lang.reflect.ParameterizedType abstract class AbstractActivity : AppCompatActivity() { - private var _binding: Binding? = null internal val binding get() = _binding!! @@ -32,34 +31,40 @@ abstract class AbstractActivity : AppCompatActivity() { if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O) { val nightFlag = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK if (nightFlag != Configuration.UI_MODE_NIGHT_YES) { - val insetsController = WindowCompat.getInsetsController( - window, - window.decorView - ) + val insetsController = + WindowCompat.getInsetsController( + window, + window.decorView, + ) insetsController.isAppearanceLightNavigationBars = true } } - _binding = createBindingInstance(layoutInflater).also { - setContentView(it.root) - } + _binding = + createBindingInstance(layoutInflater).also { + setContentView(it.root) + } findViewById(R.id.toolbar)?.also { setSupportActionBar(it) } // MIUI overrides colorSurfaceContainer to colorSurface without below flags - @Suppress("DEPRECATION") if (MIUIUtils.isMIUI) { + @Suppress("DEPRECATION") + if (MIUIUtils.isMIUI) { window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) } if (this !is MainActivity) { - supportActionBar?.setHomeAsUpIndicator(AppCompatResources.getDrawable( - this@AbstractActivity, R.drawable.ic_arrow_back_24 - )!!.apply { - setTint(getAttrColor(com.google.android.material.R.attr.colorOnSurface)) - }) + supportActionBar?.setHomeAsUpIndicator( + AppCompatResources.getDrawable( + this@AbstractActivity, + R.drawable.ic_arrow_back_24, + )!!.apply { + setTint(getAttrColor(com.google.android.material.R.attr.colorOnSurface)) + }, + ) supportActionBar?.setDisplayHomeAsUpEnabled(true) } } @@ -75,13 +80,10 @@ abstract class AbstractActivity : AppCompatActivity() { } @Suppress("UNCHECKED_CAST") - private fun createBindingInstance( - inflater: LayoutInflater, - ): Binding { + private fun createBindingInstance(inflater: LayoutInflater): Binding { val vbType = (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] val vbClass = vbType as Class val method = vbClass.getMethod("inflate", LayoutInflater::class.java) return method.invoke(null, inflater) as Binding } - -} \ No newline at end of file +} diff --git a/app/src/main/java/io/nekohasekai/sfa/ui/shared/QRCodeDialog.kt b/app/src/main/java/io/nekohasekai/sfa/ui/shared/QRCodeDialog.kt index 60b8a2c..4c01676 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ui/shared/QRCodeDialog.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ui/shared/QRCodeDialog.kt @@ -14,7 +14,7 @@ class QRCodeDialog(private val bitmap: Bitmap) : override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ): View { val binding = FragmentQrcodeDialogBinding.inflate(inflater, container, false) val behavior = BottomSheetBehavior.from(binding.qrcodeLayout) @@ -22,5 +22,4 @@ class QRCodeDialog(private val bitmap: Bitmap) : binding.qrCode.setImageBitmap(bitmap) return binding.root } - -} \ No newline at end of file +} diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt b/app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt index 8d03180..868047e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt +++ b/app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt @@ -14,10 +14,12 @@ import io.nekohasekai.sfa.R import java.util.Stack object ColorUtils { - private val ansiRegex by lazy { Regex("\u001B\\[[;\\d]*m") } - fun ansiEscapeToSpannable(context: Context, text: String): Spannable { + fun ansiEscapeToSpannable( + context: Context, + text: String, + ): Spannable { val spannable = SpannableString(text.replace(ansiRegex, "")) val stack = Stack() val spans = mutableListOf() @@ -33,11 +35,12 @@ object ColorUtils { if (ansiInstruction.decorationCode == "0" && stack.isNotEmpty()) { spans.add(stack.pop().copy(end = end - offset)) } else { - val span = AnsiSpan( - AnsiInstruction(context, stringCode), - start - if (offset > start) start else offset - 1, - 0 - ) + val span = + AnsiSpan( + AnsiInstruction(context, stringCode), + start - if (offset > start) start else offset - 1, + 0, + ) stack.push(span) } } @@ -48,7 +51,7 @@ object ColorUtils { it, ansiSpan.start, ansiSpan.end, - Spannable.SPAN_EXCLUSIVE_INCLUSIVE + Spannable.SPAN_EXCLUSIVE_INCLUSIVE, ) } } @@ -57,14 +60,16 @@ object ColorUtils { } private data class AnsiSpan( - val instruction: AnsiInstruction, val start: Int, val end: Int + val instruction: AnsiInstruction, + val start: Int, + val end: Int, ) private class AnsiInstruction(context: Context, code: String) { - val spans: List by lazy { listOfNotNull( - getSpan(colorCode, context), getSpan(decorationCode, context) + getSpan(colorCode, context), + getSpan(decorationCode, context), ) } @@ -93,29 +98,33 @@ object ColorUtils { } } - private fun getSpan(code: String?, context: Context): ParcelableSpan? = when (code) { - "0", null -> null - "1" -> StyleSpan(Typeface.NORMAL) - "3" -> StyleSpan(Typeface.ITALIC) - "4" -> UnderlineSpan() - "30" -> ForegroundColorSpan(Color.BLACK) - "31" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_red)) - "32" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_green)) - "33" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_yellow)) - "34" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_blue)) - "35" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_purple)) - "36" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_blue_light)) - "37" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_white)) - else -> { - var codeInt = code.toIntOrNull() - if (codeInt != null) { - codeInt %= 125 - val row = codeInt / 36 - val column = codeInt % 36 - ForegroundColorSpan(Color.rgb(row * 51, column / 6 * 51, column % 6 * 51)) - } else { - null + private fun getSpan( + code: String?, + context: Context, + ): ParcelableSpan? = + when (code) { + "0", null -> null + "1" -> StyleSpan(Typeface.NORMAL) + "3" -> StyleSpan(Typeface.ITALIC) + "4" -> UnderlineSpan() + "30" -> ForegroundColorSpan(Color.BLACK) + "31" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_red)) + "32" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_green)) + "33" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_yellow)) + "34" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_blue)) + "35" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_purple)) + "36" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_blue_light)) + "37" -> ForegroundColorSpan(ContextCompat.getColor(context, R.color.log_white)) + else -> { + var codeInt = code.toIntOrNull() + if (codeInt != null) { + codeInt %= 125 + val row = codeInt / 36 + val column = codeInt % 36 + ForegroundColorSpan(Color.rgb(row * 51, column / 6 * 51, column % 6 * 51)) + } else { + null + } } } - } } diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt b/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt index 4b6c3bb..0586fc9 100644 --- a/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt +++ b/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt @@ -1,11 +1,14 @@ package io.nekohasekai.sfa.utils +import android.util.Log import go.Seq import io.nekohasekai.libbox.CommandClient import io.nekohasekai.libbox.CommandClientHandler import io.nekohasekai.libbox.CommandClientOptions import io.nekohasekai.libbox.Connections import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.LogEntry +import io.nekohasekai.libbox.LogIterator import io.nekohasekai.libbox.OutboundGroup import io.nekohasekai.libbox.OutboundGroupIterator import io.nekohasekai.libbox.StatusMessage @@ -19,41 +22,86 @@ import kotlinx.coroutines.launch open class CommandClient( private val scope: CoroutineScope, - private val connectionType: ConnectionType, + private val connectionTypes: List, private val handler: Handler, ) { + constructor( + scope: CoroutineScope, + connectionType: ConnectionType, + handler: Handler, + ) : this(scope, listOf(connectionType), handler) + + private val additionalHandlers = mutableListOf() + private var cachedGroups: MutableList? = null + + fun addHandler(handler: Handler) { + synchronized(additionalHandlers) { + if (!additionalHandlers.contains(handler)) { + additionalHandlers.add(handler) + cachedGroups?.let { groups -> + handler.updateGroups(groups) + } + } + } + } + + fun removeHandler(handler: Handler) { + synchronized(additionalHandlers) { + additionalHandlers.remove(handler) + } + } + + private fun getAllHandlers(): List { + return synchronized(additionalHandlers) { + listOf(handler) + additionalHandlers + } + } enum class ConnectionType { - Status, Groups, Log, ClashMode + Status, + Groups, + Log, + ClashMode, } interface Handler { - fun onConnected() {} + fun onDisconnected() {} fun updateStatus(status: StatusMessage) {} + fun setDefaultLogLevel(level: Int) {} + fun clearLogs() {} - fun appendLogs(message: List) {} + + fun appendLogs(message: List) {} fun updateGroups(newGroups: MutableList) {} - fun initializeClashMode(modeList: List, currentMode: String) {} - fun updateClashMode(newMode: String) {} + fun initializeClashMode( + modeList: List, + currentMode: String, + ) {} + fun updateClashMode(newMode: String) {} } private var commandClient: CommandClient? = null private val clientHandler = ClientHandler() + fun connect() { disconnect() val options = CommandClientOptions() - options.command = when (connectionType) { - ConnectionType.Status -> Libbox.CommandStatus - ConnectionType.Groups -> Libbox.CommandGroup - ConnectionType.Log -> Libbox.CommandLog - ConnectionType.ClashMode -> Libbox.CommandClashMode + connectionTypes.forEach { connectionType -> + val command = + when (connectionType) { + ConnectionType.Status -> Libbox.CommandStatus + ConnectionType.Groups -> Libbox.CommandGroup + ConnectionType.Log -> Libbox.CommandLog + ConnectionType.ClashMode -> Libbox.CommandClashMode + } + options.addCommand(command) } options.statusInterval = 1 * 1000 * 1000 * 1000 val commandClient = CommandClient(clientHandler, options) @@ -91,13 +139,14 @@ open class CommandClient( } private inner class ClientHandler : CommandClientHandler { - override fun connected() { - handler.onConnected() + getAllHandlers().forEach { it.onConnected() } + Log.d("CommandClient", "connected") } override fun disconnected(message: String?) { - handler.onDisconnected() + getAllHandlers().forEach { it.onDisconnected() } + Log.d("CommandClient", "disconnected: $message") } override fun writeGroups(message: OutboundGroupIterator?) { @@ -108,34 +157,43 @@ open class CommandClient( while (message.hasNext()) { groups.add(message.next()) } - handler.updateGroups(groups) + cachedGroups = groups + getAllHandlers().forEach { it.updateGroups(groups) } + } + + override fun setDefaultLogLevel(level: Int) { + getAllHandlers().forEach { it.setDefaultLogLevel(level) } } override fun clearLogs() { - handler.clearLogs() + getAllHandlers().forEach { it.clearLogs() } } - override fun writeLogs(messageList: StringIterator?) { + override fun writeLogs(messageList: LogIterator?) { if (messageList == null) { return } - handler.appendLogs(messageList.toList()) + val logs = messageList.toList() + getAllHandlers().forEach { it.appendLogs(logs) } } override fun writeStatus(message: StatusMessage) { - handler.updateStatus(message) + getAllHandlers().forEach { it.updateStatus(message) } } - override fun initializeClashMode(modeList: StringIterator, currentMode: String) { - handler.initializeClashMode(modeList.toList(), currentMode) + override fun initializeClashMode( + modeList: StringIterator, + currentMode: String, + ) { + val modes = modeList.toList() + getAllHandlers().forEach { it.initializeClashMode(modes, currentMode) } } override fun updateClashMode(newMode: String) { - handler.updateClashMode(newMode) + getAllHandlers().forEach { it.updateClashMode(newMode) } } override fun writeConnections(message: Connections?) { } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt b/app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt index 64f785b..5bdaef4 100644 --- a/app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt +++ b/app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt @@ -7,7 +7,6 @@ import java.io.Closeable import java.util.Locale class HTTPClient : Closeable { - companion object { val userAgent by lazy { var userAgent = "SFA/" @@ -40,6 +39,4 @@ class HTTPClient : Closeable { override fun close() { client.close() } - - -} \ No newline at end of file +} diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/MIUIUtils.kt b/app/src/main/java/io/nekohasekai/sfa/utils/MIUIUtils.kt index 37eae60..ebc678e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/utils/MIUIUtils.kt +++ b/app/src/main/java/io/nekohasekai/sfa/utils/MIUIUtils.kt @@ -6,7 +6,6 @@ import android.content.Intent import android.os.Process object MIUIUtils { - val isMIUI by lazy { !getSystemProperty("ro.miui.ui.version.name").isNullOrBlank() } @@ -27,5 +26,4 @@ object MIUIUtils { intent.putExtra("extra_pkgname", context.packageName) context.startActivity(intent) } - -} \ No newline at end of file +} diff --git a/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt index 7188969..e204f3e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt +++ b/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt @@ -5,9 +5,20 @@ import androidx.camera.core.ImageAnalysis interface VendorInterface { fun checkUpdateAvailable(): Boolean - fun checkUpdate(activity: Activity, byUser: Boolean) + + fun checkUpdate( + activity: Activity, + byUser: Boolean, + ) + fun createQRCodeAnalyzer( onSuccess: (String) -> Unit, - onFailure: (Exception) -> Unit + onFailure: (Exception) -> Unit, ): ImageAnalysis.Analyzer? -} \ No newline at end of file + + /** + * Check if Per-app Proxy feature is available + * @return true if available, false if disabled (e.g., for Play Store builds) + */ + fun isPerAppProxyAvailable(): Boolean = true +} diff --git a/app/src/main/res/drawable/ic_filter_list_24.xml b/app/src/main/res/drawable/ic_filter_list_24.xml new file mode 100644 index 0000000..344421f --- /dev/null +++ b/app/src/main/res/drawable/ic_filter_list_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_pause_24.xml b/app/src/main/res/drawable/ic_pause_24.xml new file mode 100644 index 0000000..7b10b56 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_search_24.xml b/app/src/main/res/drawable/ic_search_24.xml new file mode 100644 index 0000000..3285a07 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_config_override.xml b/app/src/main/res/layout/activity_config_override.xml index 163350e..cabfdd6 100644 --- a/app/src/main/res/layout/activity_config_override.xml +++ b/app/src/main/res/layout/activity_config_override.xml @@ -42,7 +42,7 @@ android:id="@+id/switchPerAppProxy" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="@string/title_per_app_proxy" + android:text="@string/per_app_proxy" android:textAppearance="?attr/textAppearanceTitleLarge" /> diff --git a/app/src/main/res/layout/activity_debug.xml b/app/src/main/res/layout/activity_debug.xml index af6a025..cf7dfb2 100644 --- a/app/src/main/res/layout/activity_debug.xml +++ b/app/src/main/res/layout/activity_debug.xml @@ -60,7 +60,7 @@ style="@style/Widget.Material3.Button.TextButton" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="@string/action_scan_vpn" /> + android:text="@string/per_app_proxy_scan" /> diff --git a/app/src/main/res/layout/fragment_dashboard_overview.xml b/app/src/main/res/layout/fragment_dashboard_overview.xml index c478a16..72908df 100644 --- a/app/src/main/res/layout/fragment_dashboard_overview.xml +++ b/app/src/main/res/layout/fragment_dashboard_overview.xml @@ -62,7 +62,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp" - android:text="@string/status_status" + android:text="@string/status" android:textAppearance="?attr/textAppearanceTitleSmall"> @@ -77,7 +77,7 @@ style="?attr/textAppearanceBodySmall" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="@string/status_memory" /> + android:text="@string/memory" /> + android:text="@string/goroutines" /> @@ -169,7 +169,7 @@ style="?attr/textAppearanceBodySmall" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="@string/status_connections_inbound" /> + android:text="@string/connections_in" /> + android:text="@string/connections_out" /> @@ -441,7 +441,7 @@ android:layout_height="wrap_content" android:paddingHorizontal="16dp" android:paddingTop="16dp" - android:text="Mode" + android:text="@string/mode" android:textAppearance="?attr/textAppearanceTitleSmall"> diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index 08c4ce5..711e9f2 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -278,7 +278,7 @@ @@ -308,6 +308,56 @@ + + + + + + + + + + + + + + + + + + + + + android:title="@string/profile_name" /> diff --git a/app/src/main/res/menu/per_app_menu.xml b/app/src/main/res/menu/per_app_menu.xml index 96d1468..0be4bb2 100644 --- a/app/src/main/res/menu/per_app_menu.xml +++ b/app/src/main/res/menu/per_app_menu.xml @@ -66,7 +66,7 @@ - + 停止 @@ -24,7 +25,6 @@ 通过二维码分享 URL 必须 无配置 - 最后更新:%s 最后更新 更新 自动更新 @@ -56,30 +56,36 @@ 您的设备缺少 Android 标准文件选择器,请安装一个,例如 Material Files。 加载中... 缺少 VPN 权限 - 缺少通知权限 空配置 启动命令服务器 创建服务 启动服务 弃用警告 文档 - 状态 - 内存 - 连接 - 入站 - 出站 上传 下载 - 速率 流量 选中 配置 版本 + 核心版本 核心 在通知中显示实时速度 数据大小 + 计算中... + 选项 + 禁用弃用警告 + 工作目录 + 销毁 + 忽略内存限制 + 不对 sing-box 强制执行内存限制。 + 自动重定向 + 需要 ROOT 权限 + 分应用代理 + 不可用 自动检查更新 检查更新 + 没有可用的更新 隐私政策 应用 内存限制 @@ -89,10 +95,9 @@ 忽略电池优化 导入远程配置 您确定要导入远程配置文件 %1$s 吗?您将连接到 %2$s 来下载配置。 - 配置覆盖 + 配置覆盖 使用平台特定的值覆盖文件配置项。 配置 - 分应用代理 覆盖配置中的 include_package 和 exclude_package。 代理模式 白名单 @@ -100,7 +105,6 @@ 黑名单 选定的应用将从 VPN 中排除 复制 - 名称 包名 UID 排序 @@ -124,16 +128,12 @@ 中国应用 App 图标 剪切板为空 - 应用列表为空 已导出到剪切板 已从剪贴板导入 - 从剪贴板导入应用列表将覆盖当前列表。您确定要继续吗? 扫描中... - 扫描应用程序时出错 未找到匹配的应用 找到以下应用程序,请选择您想要的操作。 扫描结果 - 选择 取消选择 新中国应用安装时更新 导入配置 @@ -141,10 +141,18 @@ 当前平台不支持 iCloud 配置文件 搜索 展开 + 收起 + 全部展开 + 全部收起 + 关闭 + 关闭所有连接? + %d 个图标 + 未找到图标 + 没有匹配 \"%s\" 的图标 HTTP 代理 + 系统 HTTP 代理 扫描 VPN 应用 检查设备上安装的 VPN 及其内容 - 扫描 一些调试工具 打开 应用类型 @@ -153,14 +161,149 @@ Go 版本 其他 未知 - 没有可用更新 赞助 支持我的工作 启动 位置权限 - wifi_ssidwifi_bssid 路由规则。为了使它们正常工作,sing-box 在后台使用 位置 权限来获取有关所连接 Wi-Fi 网络的信息。该信息将仅用于路由目的。]]> - 后台位置权限。选择始终允许以授予权限。]]> - 打开设置 + 您的个人资料包含 <strong><tt>wifi_ssid</tt> 或 <tt>wifi_bssid</tt> 路由规则</strong>。为了使它们正常工作,sing-box 在<strong>后台</strong>使用 <strong>位置</strong> 权限来获取有关所连接 Wi-Fi 网络的信息。该信息将<strong>仅用于路由目的</strong>。 + 在 Android 10 及更高版本中,需要<strong>后台位置</strong>权限。选择<strong>始终允许</strong>以授予权限。 通知权限 sing-box 无法在没有发送通知权限的情况下显示实时网速。请授予权限或禁用实时网速通知后再启动服务。 + Play 商店版本中不可用 + Google Play 拒绝允许我们使用 QUERY_ALL_PACKAGES 权限(同时不禁止其他类似应用这样做),而这是列出应用程序所必需的。 + 需要 Root 权限 + 连接 + 其他 + 实验性功能 + 尝试仍在开发中的新功能 + 使用新的 Material You UI(测试版) + 没有配置的配置文件 + 状态 + 内存 + 协程 + 流量 + 上传 + 下载 + 入站 + 出站 + Clash 模式 + 模式 + 系统代理 + 测试 + 刚刚 + 昨天 + 现在 + 1天 + %d分 + %d时 + %d天 + + %d 分钟前 + + + %d 小时前 + + + %d 天前 + + 空文件 + 解码配置文件失败:%s + 无效的 sing-box 配置:%s + 仪表项目 + 重置顺序 + 重置 + 拖动手柄重新排序项目 + 拖动重新排序 + 添加配置文件 + 从本地文件导入配置 + 扫描配置二维码 + 从头创建新配置文件 + 配置文件保存成功 + 保存配置文件失败:%s + 远程 • %s + 更新成功 + 更新配置文件 + 更多选项 + 编辑 + 另存为文件 + 分享为文件 + 服务 + 关于 + 源代码 + 切换到旧版 UI + 未保存的更改 + 您有未保存的更改。要放弃它们吗? + 放弃 + 保存 + 分享配置文件 + 取消 + 日志已复制到剪贴板 + 没有日志可复制 + 日志保存成功 + 保存日志失败:%s + 分享日志失败:%s + 没有日志可分享 + 已复制到剪贴板 + 二维码已保存到相册 + 保存二维码失败:%s + 分享二维码失败:%s + 返回 + 滚动到底部 + 退出选择模式 + 复制选中 + 清除搜索 + 二维码 + 搜索日志… + 分享日志 + 分享二维码 + 恢复日志 + 暂停日志 + 折叠搜索 + 搜索日志 + 配置文件二维码:%s + + 基本信息 + 图标 + 自定义 + 默认 + 远程配置 + 最后更新:%s + 内容 + JSON 查看器 + JSON 编辑器 + 成功 + 配置文件未找到 + 导出失败:%s + 配置导出成功 + 读取配置失败:%s + + 查看配置 + 清除 + 上一个 + 下一个 + 关闭 + 配置已保存 + 在文档中查找 + + 配置文件图标 + 选择图标 + 搜索图标... + 关闭搜索 + 搜索图标 + 当前:%s + 分类 + 所有图标 + 返回分类 + 选择配置文件图标 + 自动 + + + 过滤器:%s + 清除 + 日志级别 + 复制到剪贴板 + 保存到文件 + 清除日志 + 已选择 %d 项 + 未选择 \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 276c633..dcb5628 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -14,7 +14,7 @@ @string/disabled - @string/action_select + @string/per_app_proxy_select @string/action_deselect \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d8d1db7..c8d442d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,10 +1,9 @@ + sing-box - Stop OK No, thanks - Dashboard Profiles Logs @@ -15,7 +14,24 @@ Overview Groups Debug - + Connections + Others + Experimental Features + Try out new features that are still in development + Use New Material You UI (Beta) + No profiles configured + Status + Memory + Goroutines + Traffic + Upload + Download + Inbound + Outbound + Clash Mode + Mode + System Proxy + Test Toggle Name Type @@ -29,75 +45,89 @@ Share URL as QR Code Required Empty profiles - Last Updated: %s Last Updated + Just now + Yesterday + Now + 1d + %dm + %dh + %dd + + %d minute ago + %d minutes ago + + + %d hour ago + %d hours ago + + + %d day ago + %d days ago + Update Auto Update Auto Update Interval (Minutes) Minimum value is 15 - Local Remote Create New Import - Import from file Scan QR code Front camera MLKit analyzer Torch - Create Manually - Undo Redo Format Delete Share - Service not started Service starting… Service stopping… Service started - Enabled Disabled - Clear Working Directory - Error Your device lacks an Android standard file selector, please install one, such as Material Files. Loading… - Missiong VPN permission - Missing notification permission Empty configuration + Empty file + Failed to decode profile: %s + Invalid sing-box configuration: %s Start command server Create service Start service Deprecated Warning Documentation - - Status - Memory - Goroutines - Connections - Inbound - Outbound Uplink Downlink - Traffic Traffic Total - Selected - Profile Version + Core Version Core Display realtime speed in notification Data Size + Calculating... + Options + Disable Deprecated Warnings + Working Directory + Destroy + Ignore Memory Limit + Do not enforce memory limits on sing-box. + Auto Redirect + ROOT permission required + Per-App Proxy + Unavailable Automatic Update Check Check Update + No updates available Privacy Policy App Memory Limit @@ -107,12 +137,9 @@ Ignore Battery Optimizations Import remote profile Are you sure to import remote profile %1$s? You will connect to %2$s to download the configuration. - - Profile Override + Profile Override Overrides profile configuration items with platform-specific values. Configure - - Per-app Proxy Override include_package and exclude_package in the configuration. Proxy Mode Include @@ -120,10 +147,8 @@ Exclude Selected apps will be excluded from VPN Copy - Name Package Name UID - Sort By By name By package name @@ -131,35 +156,26 @@ By install time By update time Reverse - Filter Hide system apps Hide offline apps Hide disabled apps - Select Select all Deselect all - Backup Import from clipboard Export to clipboard - Scan China apps - App icon Clipboard is empty - App list is empty Exported to clipboard Imported from clipboard - Importing app list from clipboard will overwrite your current list. Are you sure to continue? Scanning… - Error scanning apps No matching apps found Found the following apps, please choose the action you want. Scan Result - Select Deselect Update on new China App Installed Import profile @@ -168,10 +184,18 @@ Search URLTest Expand + Collapse + Expand All + Collapse All + Close + Close all connections? + %d icons + No icons found + No icons match \"%s\" HTTP Proxy + System HTTP Proxy Scan VPN apps Check the VPN installed on the device and its contents - Scan Some debug utilities Open App Type @@ -180,14 +204,112 @@ Go Version Other Unknown - No updates available Sponsor If I\'ve defended your modern life, please consider sponsoring me. Start Location permission - wifi_ssid or wifi_bssid routing rules. To make them work, sing-box uses the location permission in the background to get information about the connected Wi-Fi network. The information will be used for routing purposes only.]]> - background location permission is required. Select Allow all the time to grant the permission.]]> - Open Settings + Your profile contains <strong><tt>wifi_ssid</tt> or <tt>wifi_bssid</tt> routing rules</strong>. To make them work, sing-box uses the <strong>location</strong> permission <strong>in the background</strong> to get information about the connected Wi-Fi network. The information will be used <strong>for routing purposes only</strong>. + On Android 10 and up, <strong>background location</strong> permission is required. Select <strong>Allow all the time</strong> to grant the permission. Notification permission sing-box is unable to show real-time network speeds without the permission to send notifications. Please grant the permission or disable real-time network speeds notification before starting the service. + Unavailable in the Play Store version + Google Play refuses to allow us to use the QUERY_ALL_PACKAGES permission (while not prohibiting other similar apps from doing so), which is required for listing apps. + Root access required + Dashboard Items + Reset order + Reset + Drag handle to reorder items + Drag to reorder + Add Profile + Import configuration from a local file + Scan a configuration QR code + Create a new profile from scratch + Profile saved successfully + Failed to save profile: %s + Remote • %s + Update successful + Update profile + More options + Edit + Save As File + Share As File + Service + About + Source Code + Switch to legacy UI + Unsaved Changes + You have unsaved changes. Do you want to discard them? + Discard + Save + Share Profile + Cancel + Logs copied to clipboard + No logs to copy + Logs saved successfully + Failed to save logs: %s + Failed to share logs: %s + No logs to share + Copied to clipboard + QR code saved to gallery + Failed to save QR code: %s + Failed to share QR code: %s + Back + Scroll to bottom + Exit selection mode + Copy selected + Clear search + QR Code + Search logs… + Share Logs + Share QR Code + Resume logs + Pause logs + Collapse search + Search logs + Profile QR Code: %s + + Basic Information + Icon + Custom + Default + Remote Configuration + Last updated: %s + Content + JSON Viewer + JSON Editor + Success + Configuration file not found + Export failed: %s + Configuration exported successfully + Failed to read configuration: %s + + View Configuration + Clear + Previous + Next + Dismiss + Configuration saved + Find in document + + Profile Icon + Select Icon + Search icons... + Close search + Search icons + Current: %s + Categories + All Icons + Back to categories + Select Profile Icon + Auto + + + Filter: %s + Clear + Log Level + To Clipboard + To File + Clear Logs + %d selected + Not selected \ No newline at end of file diff --git a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt index da86b2f..b542108 100644 --- a/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/other/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -4,18 +4,25 @@ import android.app.Activity import androidx.camera.core.ImageAnalysis object Vendor : VendorInterface { - override fun checkUpdateAvailable(): Boolean { return false } - override fun checkUpdate(activity: Activity, byUser: Boolean) { + override fun checkUpdate( + activity: Activity, + byUser: Boolean, + ) { } override fun createQRCodeAnalyzer( onSuccess: (String) -> Unit, - onFailure: (Exception) -> Unit + onFailure: (Exception) -> Unit, ): ImageAnalysis.Analyzer? { return null } -} \ No newline at end of file + + override fun isPerAppProxyAvailable(): Boolean { + // Per-app Proxy is available for non-Play Store builds + return true + } +} diff --git a/app/src/play/AndroidManifest.xml b/app/src/play/AndroidManifest.xml new file mode 100644 index 0000000..df4efac --- /dev/null +++ b/app/src/play/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt b/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt index 4ac8132..4c091b1 100644 --- a/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt +++ b/app/src/play/java/io/nekohasekai/sfa/vendor/MLKitQRCodeAnalyzer.kt @@ -15,10 +15,9 @@ class MLKitQRCodeAnalyzer( private val onSuccess: ((String) -> Unit), private val onFailure: ((Exception) -> Unit), ) : ImageAnalysis.Analyzer { - private val barcodeScanner = BarcodeScanning.getClient( - BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build() + BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build(), ) @Volatile @@ -60,6 +59,5 @@ class MLKitQRCodeAnalyzer( @ExperimentalGetImage @Suppress("UnsafeCallOnNullableType") - private fun ImageProxy.toInputImage() = - InputImage.fromMediaImage(image!!, imageInfo.rotationDegrees) -} \ No newline at end of file + private fun ImageProxy.toInputImage() = InputImage.fromMediaImage(image!!, imageInfo.rotationDegrees) +} diff --git a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt index a2b5a41..d7a4cad 100644 --- a/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt +++ b/app/src/play/java/io/nekohasekai/sfa/vendor/Vendor.kt @@ -14,13 +14,16 @@ import com.google.mlkit.common.MlKitException import io.nekohasekai.sfa.R object Vendor : VendorInterface { - private const val TAG = "Vendor" + override fun checkUpdateAvailable(): Boolean { return true } - override fun checkUpdate(activity: Activity, byUser: Boolean) { + override fun checkUpdate( + activity: Activity, + byUser: Boolean, + ) { val appUpdateManager = AppUpdateManagerFactory.create(activity) val appUpdateInfoTask = appUpdateManager.appUpdateInfo appUpdateInfoTask.addOnSuccessListener { appUpdateInfo -> @@ -45,13 +48,13 @@ object Vendor : VendorInterface { appUpdateManager.startUpdateFlow( appUpdateInfo, activity, - AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build() + AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build(), ) } else if (appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)) { appUpdateManager.startUpdateFlow( appUpdateInfo, activity, - AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build() + AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(), ) } } @@ -78,7 +81,7 @@ object Vendor : VendorInterface { override fun createQRCodeAnalyzer( onSuccess: (String) -> Unit, - onFailure: (Exception) -> Unit + onFailure: (Exception) -> Unit, ): ImageAnalysis.Analyzer? { try { return MLKitQRCodeAnalyzer(onSuccess, onFailure) @@ -90,4 +93,8 @@ object Vendor : VendorInterface { } } -} \ No newline at end of file + override fun isPerAppProxyAvailable(): Boolean { + // Per-app Proxy is disabled for Play Store builds due to QUERY_ALL_PACKAGES permission restriction + return false + } +} diff --git a/build.gradle b/build.gradle index c3c1066..fdb16ae 100644 --- a/build.gradle +++ b/build.gradle @@ -5,11 +5,25 @@ buildscript { } plugins { - id 'com.android.application' version '8.11.1' apply false - id 'com.android.library' version '8.11.1' apply false + id 'com.android.application' version '8.13.0' apply false + id 'com.android.library' version '8.13.0' apply false id 'org.jetbrains.kotlin.android' version '2.2.0' apply false id 'com.google.devtools.ksp' version '2.2.0-2.0.2' apply false id 'com.github.triplet.play' version '3.12.1' apply false id 'org.jetbrains.kotlin.plugin.compose' version '2.2.0' apply false + id 'org.jlleitschuh.gradle.ktlint' version '13.1.0' apply false + id 'io.gitlab.arturbosch.detekt' version '1.23.8' +} + +detekt { + buildUponDefaultConfig = true + allRules = false + config.setFrom("$projectDir/config/detekt/detekt.yml") + baseline = file("$projectDir/config/detekt/baseline.xml") + source.setFrom("app/src/main/java", "app/src/main/kotlin") +} + +dependencies { + detektPlugins "io.gitlab.arturbosch.detekt:detekt-formatting:1.23.8" } diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..2dce447 --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,1066 @@ +build: + maxIssues: 0 + excludeCorrectable: false + weights: + # complexity: 2 + # LongParameterList: 1 + # style: 1 + # comments: 1 + +config: + validation: true + warningsAsErrors: false + checkExhaustiveness: false + # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' + excludes: '' + +processors: + active: true + exclude: + - 'DetektProgressListener' + # - 'KtFileCountProcessor' + # - 'PackageCountProcessor' + # - 'ClassCountProcessor' + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ProjectComplexityProcessor' + # - 'ProjectCognitiveComplexityProcessor' + # - 'ProjectLLOCProcessor' + # - 'ProjectCLOCProcessor' + # - 'ProjectLOCProcessor' + # - 'ProjectSLOCProcessor' + # - 'LicenseHeaderLoaderExtension' + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'FindingsReport' + - 'FileBasedFindingsReport' + # - 'LiteFindingsReport' + +output-reports: + active: true + exclude: + # - 'TxtOutputReport' + # - 'XmlOutputReport' + # - 'HtmlOutputReport' + # - 'MdOutputReport' + # - 'SarifOutputReport' + +comments: + active: true + AbsentOrWrongFileLicense: + active: false + licenseTemplateFile: 'license.template' + licenseTemplateIsRegex: false + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + DeprecatedBlockTag: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' + KDocReferencesNonPublicProperty: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + OutdatedDocumentation: + active: false + matchTypeParameters: true + matchDeclarationsOrder: true + allowParamOnConstructorProperties: false + UndocumentedPublicClass: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + searchInProtectedClass: false + ignoreDefaultCompanionObject: false + UndocumentedPublicFunction: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchProtectedFunction: false + UndocumentedPublicProperty: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchProtectedProperty: false + +complexity: + active: true + CognitiveComplexMethod: + active: false + threshold: 15 + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: false + threshold: 10 + includeStaticDeclarations: false + includePrivateDeclarations: false + ignoreOverloaded: false + CyclomaticComplexMethod: + active: true + threshold: 15 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + ignoreNestingFunctions: false + nestingFunctions: + - 'also' + - 'apply' + - 'forEach' + - 'isNotNull' + - 'ifNull' + - 'let' + - 'run' + - 'use' + - 'with' + LabeledExpression: + active: false + ignoredLabels: [] + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 60 + LongParameterList: + active: true + functionThreshold: 6 + constructorThreshold: 7 + ignoreDefaultParameters: false + ignoreDataClasses: true + ignoreAnnotatedParameter: [] + MethodOverloading: + active: false + threshold: 6 + NamedArguments: + active: false + threshold: 3 + ignoreArgumentsMatchingNames: false + NestedBlockDepth: + active: true + threshold: 4 + NestedScopeFunctions: + active: false + threshold: 1 + functions: + - 'kotlin.apply' + - 'kotlin.run' + - 'kotlin.with' + - 'kotlin.let' + - 'kotlin.also' + ReplaceSafeCallChainWithRun: + active: false + StringLiteralDuplication: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + threshold: 3 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + thresholdInFiles: 11 + thresholdInClasses: 11 + thresholdInInterfaces: 11 + thresholdInObjects: 11 + thresholdInEnums: 11 + ignoreDeprecated: false + ignorePrivate: false + ignoreOverridden: false + ignoreAnnotatedFunctions: [] + +coroutines: + active: true + GlobalCoroutineUsage: + active: false + InjectDispatcher: + active: true + dispatcherNames: + - 'IO' + - 'Default' + - 'Unconfined' + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + SuspendFunSwallowedCancellation: + active: false + SuspendFunWithCoroutineScopeReceiver: + active: false + SuspendFunWithFlowReturnType: + active: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: true + methodNames: + - 'equals' + - 'finalize' + - 'hashCode' + - 'toString' + InstanceOfCheckForException: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + NotImplementedDeclaration: + active: false + ObjectExtendsThrowable: + active: false + PrintStackTrace: + active: true + RethrowCaughtException: + active: true + ReturnFromFinally: + active: true + ignoreLabeled: false + SwallowedException: + active: true + ignoredExceptionTypes: + - 'InterruptedException' + - 'MalformedURLException' + - 'NumberFormatException' + - 'ParseException' + allowedExceptionNameRegex: '_|(ignore|expected).*' + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + exceptions: + - 'ArrayIndexOutOfBoundsException' + - 'Exception' + - 'IllegalArgumentException' + - 'IllegalMonitorStateException' + - 'IllegalStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + exceptionNames: + - 'ArrayIndexOutOfBoundsException' + - 'Error' + - 'Exception' + - 'IllegalMonitorStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + allowedExceptionNameRegex: '_|(ignore|expected).*' + TooGenericExceptionThrown: + active: true + exceptionNames: + - 'Error' + - 'Exception' + - 'RuntimeException' + - 'Throwable' + +naming: + active: true + BooleanPropertyNaming: + active: false + allowedPattern: '^(is|has|are)' + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + forbiddenName: [] + FunctionMaxLength: + active: false + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + functionPattern: '[a-z][a-zA-Z0-9]*' + excludeClassPattern: '$^' + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + InvalidPackageDeclaration: + active: true + rootPackage: '' + requireRootInDeclaration: false + LambdaParameterNaming: + active: false + parameterPattern: '[a-z][A-Za-z0-9]*|_' + MatchingDeclarationName: + active: true + mustBeFirst: true + multiplatformTargets: + - 'ios' + - 'android' + - 'js' + - 'jvm' + - 'native' + - 'iosArm64' + - 'iosX64' + - 'macosX64' + - 'mingwX64' + - 'linuxX64' + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + NoNameShadowing: + active: true + NonBooleanPropertyPrefixedWithIs: + active: false + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + maximumVariableNameLength: 64 + VariableMinLength: + active: false + minimumVariableNameLength: 1 + VariableNaming: + active: true + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + +performance: + active: true + ArrayPrimitive: + active: true + CouldBeSequence: + active: false + threshold: 3 + ForEachOnRange: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + SpreadOperator: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + UnnecessaryPartOfBinaryExpression: + active: false + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + AvoidReferentialEquality: + active: true + forbiddenTypePatterns: + - 'kotlin.String' + CastNullableToNonNullableType: + active: false + CastToNullableType: + active: false + Deprecation: + active: false + DontDowncastCollectionTypes: + active: false + DoubleMutabilityForCollection: + active: true + mutableTypes: + - 'kotlin.collections.MutableList' + - 'kotlin.collections.MutableMap' + - 'kotlin.collections.MutableSet' + - 'java.util.ArrayList' + - 'java.util.LinkedHashSet' + - 'java.util.HashSet' + - 'java.util.LinkedHashMap' + - 'java.util.HashMap' + ElseCaseInsteadOfExhaustiveWhen: + active: false + ignoredSubjectTypes: [] + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExitOutsideMain: + active: false + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: true + IgnoredReturnValue: + active: true + restrictToConfig: true + returnValueAnnotations: + - 'CheckResult' + - '*.CheckResult' + - 'CheckReturnValue' + - '*.CheckReturnValue' + ignoreReturnValueAnnotations: + - 'CanIgnoreReturnValue' + - '*.CanIgnoreReturnValue' + returnValueTypes: + - 'kotlin.sequences.Sequence' + - 'kotlinx.coroutines.flow.*Flow' + - 'java.util.stream.*Stream' + ignoreFunctionCall: [] + ImplicitDefaultLocale: + active: true + ImplicitUnitReturnType: + active: false + allowExplicitReturnType: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + ignoreOnClassesPattern: '' + MapGetWithNotNullAssertionOperator: + active: true + MissingPackageDeclaration: + active: false + excludes: ['**/*.kts'] + NullCheckOnMutableProperty: + active: false + NullableToStringCall: + active: false + PropertyUsedBeforeDeclaration: + active: false + UnconditionalJumpStatementInLoop: + active: false + UnnecessaryNotNullCheck: + active: false + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCatchBlock: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + UnsafeCast: + active: true + UnusedUnaryOperator: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +style: + active: true + AlsoCouldBeApply: + active: false + BracesOnIfStatements: + active: false + singleLine: 'never' + multiLine: 'always' + BracesOnWhenStatements: + active: false + singleLine: 'necessary' + multiLine: 'consistent' + CanBeNonNullable: + active: false + CascadingCallWrapping: + active: false + includeElvis: true + ClassOrdering: + active: false + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: + - 'to' + allowOperators: false + DataClassShouldBeImmutable: + active: false + DestructuringDeclarationWithTooManyEntries: + active: true + maxDestructuringEntries: 3 + DoubleNegativeLambda: + active: false + negativeFunctions: + - reason: 'Use `takeIf` instead.' + value: 'takeUnless' + - reason: 'Use `all` instead.' + value: 'none' + negativeFunctionNameParts: + - 'not' + - 'non' + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: false + ExplicitItLambdaParameter: + active: true + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenAnnotation: + active: false + annotations: + - reason: 'it is a java annotation. Use `Suppress` instead.' + value: 'java.lang.SuppressWarnings' + - reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.' + value: 'java.lang.Deprecated' + - reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.' + value: 'java.lang.annotation.Documented' + - reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.' + value: 'java.lang.annotation.Target' + - reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.' + value: 'java.lang.annotation.Retention' + - reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.' + value: 'java.lang.annotation.Repeatable' + - reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265' + value: 'java.lang.annotation.Inherited' + ForbiddenComment: + active: true + comments: + - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' + value: 'FIXME:' + - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' + value: 'STOPSHIP:' + - reason: 'Forbidden TODO todo marker in comment, please do the changes.' + value: 'TODO:' + allowedPatterns: '' + ForbiddenImport: + active: false + imports: [] + forbiddenPatterns: '' + ForbiddenMethodCall: + active: false + methods: + - reason: 'print does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.print' + - reason: 'println does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.println' + ForbiddenSuppress: + active: false + rules: [] + ForbiddenVoid: + active: true + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + ignoreActualFunction: true + excludedFunctions: [] + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts'] + ignoreNumbers: + - '-1' + - '0' + - '1' + - '2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: false + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + ignoreExtensionFunctions: true + MandatoryBracesLoops: + active: false + MaxChainedCallsOnSameLine: + active: false + maxChainedCalls: 5 + MaxLineLength: + active: true + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + excludeRawStrings: true + MayBeConst: + active: true + ModifierOrder: + active: true + MultilineLambdaItParameter: + active: false + MultilineRawStringIndentation: + active: false + indentSize: 4 + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + NestedClassesVisibility: + active: true + NewLineAtEndOfFile: + active: true + NoTabs: + active: false + NullableBooleanCheck: + active: false + ObjectLiteralToLambda: + active: true + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + PreferToOverPairSyntax: + active: false + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: false + RedundantHigherOrderMapUsage: + active: true + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 2 + excludedFunctions: + - 'equals' + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: true + SpacingBetweenPackageAndImports: + active: false + StringShouldBeRawString: + active: false + maxEscapedCharacterCount: 2 + ignoredCharacters: [] + ThrowsCount: + active: true + max: 2 + excludeGuardClauses: false + TrailingWhitespace: + active: false + TrimMultilineRawString: + active: false + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + UnderscoresInNumericLiterals: + active: false + acceptableLength: 4 + allowNonStandardGrouping: false + UnnecessaryAbstractClass: + active: true + UnnecessaryAnnotationUseSiteTarget: + active: false + UnnecessaryApply: + active: true + UnnecessaryBackticks: + active: false + UnnecessaryBracesAroundTrailingLambda: + active: false + UnnecessaryFilter: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryInnerClass: + active: false + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + allowForUnclearPrecedence: false + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: false + UnusedParameter: + active: true + allowedNames: 'ignored|expected' + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + allowedNames: '' + UnusedPrivateProperty: + active: true + allowedNames: '_|ignored|expected|serialVersionUID' + UseAnyOrNoneInsteadOfFind: + active: true + UseArrayLiteralsInAnnotations: + active: true + UseCheckNotNull: + active: true + UseCheckOrError: + active: true + UseDataClass: + active: false + allowVars: false + UseEmptyCounterpart: + active: false + UseIfEmptyOrIfBlank: + active: false + UseIfInsteadOfWhen: + active: false + ignoreWhenContainingVariableDeclaration: false + UseIsNullOrEmpty: + active: true + UseLet: + active: false + UseOrEmpty: + active: true + UseRequire: + active: true + UseRequireNotNull: + active: true + UseSumOfInsteadOfFlatMapSize: + active: false + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + ignoreLateinitVar: false + WildcardImport: + active: true + excludeImports: + - 'java.util.*' + +formatting: + active: true + android: false + autoCorrect: true + AnnotationOnSeparateLine: + active: true + autoCorrect: true + indentSize: 4 + AnnotationSpacing: + active: true + autoCorrect: true + ArgumentListWrapping: + active: true + autoCorrect: true + indentSize: 4 + maxLineLength: 120 + BlockCommentInitialStarAlignment: + active: true + autoCorrect: true + ChainWrapping: + active: true + autoCorrect: true + indentSize: 4 + ClassName: + active: false + CommentSpacing: + active: true + autoCorrect: true + CommentWrapping: + active: true + autoCorrect: true + indentSize: 4 + ContextReceiverMapping: + active: false + autoCorrect: true + maxLineLength: 120 + indentSize: 4 + DiscouragedCommentLocation: + active: false + autoCorrect: true + EnumEntryNameCase: + active: true + autoCorrect: true + EnumWrapping: + active: false + autoCorrect: true + indentSize: 4 + Filename: + active: true + FinalNewline: + active: true + autoCorrect: true + insertFinalNewLine: true + FunKeywordSpacing: + active: true + autoCorrect: true + FunctionName: + active: false + FunctionReturnTypeSpacing: + active: true + autoCorrect: true + maxLineLength: 120 + FunctionSignature: + active: false + autoCorrect: true + forceMultilineWhenParameterCountGreaterOrEqualThan: 2147483647 + functionBodyExpressionWrapping: 'default' + maxLineLength: 120 + indentSize: 4 + FunctionStartOfBodySpacing: + active: true + autoCorrect: true + FunctionTypeReferenceSpacing: + active: true + autoCorrect: true + IfElseBracing: + active: false + autoCorrect: true + indentSize: 4 + IfElseWrapping: + active: false + autoCorrect: true + indentSize: 4 + ImportOrdering: + active: true + autoCorrect: true + layout: '*,java.**,javax.**,kotlin.**,^' + Indentation: + active: true + autoCorrect: true + indentSize: 4 + KdocWrapping: + active: true + autoCorrect: true + indentSize: 4 + MaximumLineLength: + active: true + maxLineLength: 120 + ignoreBackTickedIdentifier: false + ModifierListSpacing: + active: true + autoCorrect: true + ModifierOrdering: + active: true + autoCorrect: true + MultiLineIfElse: + active: true + autoCorrect: true + indentSize: 4 + MultilineExpressionWrapping: + active: false + autoCorrect: true + indentSize: 4 + NoBlankLineBeforeRbrace: + active: true + autoCorrect: true + NoBlankLineInList: + active: false + autoCorrect: true + NoBlankLinesInChainedMethodCalls: + active: true + autoCorrect: true + NoConsecutiveBlankLines: + active: true + autoCorrect: true + NoConsecutiveComments: + active: false + NoEmptyClassBody: + active: true + autoCorrect: true + NoEmptyFirstLineInClassBody: + active: false + autoCorrect: true + indentSize: 4 + NoEmptyFirstLineInMethodBlock: + active: true + autoCorrect: true + NoLineBreakAfterElse: + active: true + autoCorrect: true + NoLineBreakBeforeAssignment: + active: true + autoCorrect: true + NoMultipleSpaces: + active: true + autoCorrect: true + NoSemicolons: + active: true + autoCorrect: true + NoSingleLineBlockComment: + active: false + autoCorrect: true + indentSize: 4 + NoTrailingSpaces: + active: true + autoCorrect: true + NoUnitReturn: + active: true + autoCorrect: true + NoUnusedImports: + active: true + autoCorrect: true + NoWildcardImports: + active: true + packagesToUseImportOnDemandProperty: 'java.util.*,kotlinx.android.synthetic.**' + NullableTypeSpacing: + active: true + autoCorrect: true + PackageName: + active: true + autoCorrect: true + ParameterListSpacing: + active: false + autoCorrect: true + ParameterListWrapping: + active: true + autoCorrect: true + maxLineLength: 120 + indentSize: 4 + ParameterWrapping: + active: true + autoCorrect: true + indentSize: 4 + maxLineLength: 120 + PropertyName: + active: false + PropertyWrapping: + active: true + autoCorrect: true + indentSize: 4 + maxLineLength: 120 + SpacingAroundAngleBrackets: + active: true + autoCorrect: true + SpacingAroundColon: + active: true + autoCorrect: true + SpacingAroundComma: + active: true + autoCorrect: true + SpacingAroundCurly: + active: true + autoCorrect: true + SpacingAroundDot: + active: true + autoCorrect: true + SpacingAroundDoubleColon: + active: true + autoCorrect: true + SpacingAroundKeyword: + active: true + autoCorrect: true + SpacingAroundOperators: + active: true + autoCorrect: true + SpacingAroundParens: + active: true + autoCorrect: true + SpacingAroundRangeOperator: + active: true + autoCorrect: true + SpacingAroundUnaryOperator: + active: true + autoCorrect: true + SpacingBetweenDeclarationsWithAnnotations: + active: true + autoCorrect: true + SpacingBetweenDeclarationsWithComments: + active: true + autoCorrect: true + SpacingBetweenFunctionNameAndOpeningParenthesis: + active: true + autoCorrect: true + StringTemplate: + active: true + autoCorrect: true + StringTemplateIndent: + active: false + autoCorrect: true + indentSize: 4 + TrailingCommaOnCallSite: + active: false + autoCorrect: true + useTrailingCommaOnCallSite: true + TrailingCommaOnDeclarationSite: + active: false + autoCorrect: true + useTrailingCommaOnDeclarationSite: true + TryCatchFinallySpacing: + active: false + autoCorrect: true + indentSize: 4 + TypeArgumentListSpacing: + active: false + autoCorrect: true + indentSize: 4 + TypeParameterListSpacing: + active: false + autoCorrect: true + indentSize: 4 + UnnecessaryParenthesesBeforeTrailingLambda: + active: true + autoCorrect: true + Wrapping: + active: true + autoCorrect: true + indentSize: 4 + maxLineLength: 120