Refactor to Compose based UI

This commit is contained in:
世界
2025-09-24 14:50:50 +08:00
parent f3763ba71d
commit 19da240d5b
164 changed files with 22460 additions and 1352 deletions

37
.editorconfig Normal file
View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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')"
]
}
}

View File

@@ -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')"
]
}
}

View File

@@ -21,6 +21,10 @@
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- For saving images to gallery on older Android versions -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
@@ -41,18 +45,10 @@
android:resource="@xml/shortcuts" />
<activity
android:name=".ui.MainActivity"
android:name=".LauncherActivity"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:launchMode="singleTask">
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<meta-data
android:name="firebase_crashlytics_collection_enabled"
android:value="false" />
android:theme="@style/AppTheme.Translucent">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -99,6 +95,33 @@
</activity>
<activity
android:name=".ui.MainActivity"
android:exported="false"
android:icon="@mipmap/ic_launcher"
android:launchMode="singleTask">
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<meta-data
android:name="firebase_crashlytics_collection_enabled"
android:value="false" />
</activity>
<activity
android:name=".compose.ComposeActivity"
android:exported="false"
android:icon="@mipmap/ic_launcher"
android:launchMode="singleTask"
android:theme="@style/AppTheme">
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity
android:name=".ui.ShortcutActivity"
android:excludeFromRecents="true"
@@ -114,6 +137,18 @@
<activity
android:name="io.nekohasekai.sfa.ui.profile.NewProfileActivity"
android:exported="false" />
<activity
android:name="io.nekohasekai.sfa.compose.NewProfileComposeActivity"
android:exported="false"
android:theme="@style/AppTheme" />
<activity
android:name="io.nekohasekai.sfa.compose.EditProfileComposeActivity"
android:exported="false"
android:theme="@style/AppTheme" />
<activity
android:name="io.nekohasekai.sfa.compose.GroupsComposeActivity"
android:exported="false"
android:theme="@style/AppTheme" />
<activity
android:name="io.nekohasekai.sfa.ui.profile.EditProfileActivity"
android:exported="false" />

View File

@@ -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<WifiManager>()!! }
val clipboard by lazy { application.getSystemService<ClipboardManager>()!! }
}
}
}

View File

@@ -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()
}
}

View File

@@ -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")
}
}
}
}

View File

@@ -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() {
}
}
}
}

View File

@@ -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())
}
}
}
override fun writeDebugMessage(message: String?) {
Log.d("sing-box", message!!)
}
}

View File

@@ -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<Network>()
}
@@ -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<NetworkMessage>(Dispatchers.Unconfined) {
val listeners = mutableMapOf<Any, (Network?) -> Unit>()
var network: Network? = null
val pendingRequests = arrayListOf<NetworkMessage.Get>()
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<NetworkMessage>(Dispatchers.Unconfined) {
val listeners = mutableMapOf<Any, (Network?) -> Unit>()
var network: Network? = null
val pendingRequests = arrayListOf<NetworkMessage.Get>()
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)
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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<ByteArray> {
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<Collection<InetAddress>> {
@Suppress("ThrowableNotThrown")
override fun onAnswer(answer: Collection<InetAddress>, rcode: Int) {
val callback =
object : DnsResolver.Callback<ByteArray> {
override fun onAnswer(
answer: ByteArray,
rcode: Int,
) {
if (rcode == 0) {
ctx.success((answer as Collection<InetAddress?>).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<Collection<InetAddress>> {
@Suppress("ThrowableNotThrown")
override fun onAnswer(
answer: Collection<InetAddress>,
rcode: Int,
) {
if (rcode == 0) {
ctx.success(
(answer as Collection<InetAddress?>).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"))
}
}
}
}
}

View File

@@ -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 == "<unknown ssid>") {
@@ -182,12 +189,12 @@ interface PlatformInterfaceWrapper : PlatformInterface {
val certificates = mutableListOf<String>()
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<LibboxNetworkInterface>) :
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<String>) : StringIterator {
class StringArray(private val iterator: Iterator<String>) : 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
}
}
}

View File

@@ -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)
}
override fun sendNotification(notification: Notification) = service.sendNotification(notification)
}

View File

@@ -58,4 +58,4 @@ class ServiceBinder(private val status: MutableLiveData<Status>) : IService.Stub
fun close() {
callbacks.kill()
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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<Status>, private val service: Service
private val status: MutableLiveData<Status>,
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
}
}
}
}

View File

@@ -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 -> {}
}
}
}
}

View File

@@ -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()
}
}
}
}
}

View File

@@ -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)
}
override fun sendNotification(notification: Notification) = service.sendNotification(notification)
}

View File

@@ -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<Pair<Alert, String?>?>(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))
}
},
)
}
}

View File

@@ -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,
)
}
}
}
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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<Float>,
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),
)
}
}
}
}

View File

@@ -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()
},
)
}
}
}
}
}

View File

@@ -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<State, Event> : ViewModel() {
private val _uiState: MutableStateFlow<State> by lazy { MutableStateFlow(createInitialState()) }
val uiState: StateFlow<State> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = _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")
}
}

View File

@@ -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<UiEvent>(
replay = 0,
extraBufferCapacity = 10,
)
val events: SharedFlow<UiEvent> = _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)
}
}

View File

@@ -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<T : UiEvent> {
val events: SharedFlow<T>
suspend fun sendEvent(event: T)
}
class UiEventHandler<T : UiEvent> : EventHandler<T> {
private val _events = MutableSharedFlow<T>()
override val events: SharedFlow<T> = _events.asSharedFlow()
override suspend fun sendEvent(event: T) {
_events.emit(event)
}
}

View File

@@ -0,0 +1,15 @@
package io.nekohasekai.sfa.compose.base
sealed class UiState<out T> {
object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val exception: Throwable, val message: String? = null) : UiState<Nothing>()
}
data class BaseUiState<T>(
val isLoading: Boolean = false,
val data: T? = null,
val error: String? = null,
)

View File

@@ -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,
)

View File

@@ -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)
}
}
}

View File

@@ -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),
)
}
}
}
}
}
}
}

View File

@@ -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<NewProfileUiState> = _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<Application>()
// 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<Application>()
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<Application>()
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
}
}

View File

@@ -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")
}
}
}

View File

@@ -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))
}
}
}
}
}
}

View File

@@ -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<String>,
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)
}
}
}
}
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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<Profile> = 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,
)
}
}
}
}

View File

@@ -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<CardGroup>,
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<CardGroup>,
visibleCards: Set<CardGroup>,
cardWidths: Map<CardGroup, CardWidth>,
): List<CardRenderItem> {
val renderItems = mutableListOf<CardRenderItem>()
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
}
}

View File

@@ -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<CardGroup>,
cardOrder: List<CardGroup>,
onToggleCard: (CardGroup) -> Unit,
onReorderCards: (List<CardGroup>) -> 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<CardGroup?>(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
)
}
}
}

View File

@@ -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<Profile> = emptyList(),
val selectedProfileId: Long = -1L,
val selectedProfileName: String? = null,
val isLoading: Boolean = false,
val hasGroups: Boolean = false,
val deprecatedNotes: List<DeprecatedNote> = 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<Float> = List(30) { 0f },
val downlinkHistory: List<Float> = List(30) { 0f },
// Clash Mode
val clashModeVisible: Boolean = false,
val clashModes: List<String> = emptyList(),
val selectedClashMode: String = "",
// System Proxy
val systemProxyVisible: Boolean = false,
val systemProxyEnabled: Boolean = false,
val systemProxySwitching: Boolean = false,
// Card visibility settings
val visibleCards: Set<CardGroup> =
setOf(
CardGroup.ClashMode,
CardGroup.UploadTraffic,
CardGroup.DownloadTraffic,
CardGroup.Debug,
CardGroup.Connections,
CardGroup.SystemProxy,
CardGroup.Profiles,
),
val cardOrder: List<CardGroup> =
listOf(
CardGroup.UploadTraffic,
CardGroup.DownloadTraffic,
CardGroup.Debug,
CardGroup.Connections,
CardGroup.SystemProxy,
CardGroup.ClashMode,
CardGroup.Profiles,
CardGroup.Groups,
),
val cardWidths: Map<CardGroup, CardWidth> =
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<DashboardUiState, UiEvent>(), CommandClient.Handler {
private val _serviceStatus = MutableStateFlow(Status.Stopped)
val serviceStatus: StateFlow<Status> = _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<DashboardUiState.DeprecatedNote>()
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<String>,
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<OutboundGroup>) {
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<CardGroup>) {
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<CardGroup> {
val savedOrder = Settings.dashboardItemOrder
if (savedOrder.isBlank()) {
return getDefaultItemOrder()
}
return try {
val jsonArray = JSONArray(savedOrder)
val order = mutableListOf<CardGroup>()
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<CardGroup>) {
val jsonArray = JSONArray()
order.forEach { item ->
jsonArray.put(cardGroupToString(item))
}
Settings.dashboardItemOrder = jsonArray.toString()
}
private fun loadDisabledItems(): Set<CardGroup> {
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<CardGroup>) {
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
}
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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<Float>,
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(),
)
}
}
}

View File

@@ -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 <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): 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<GroupItem>,
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,
)
}

View File

@@ -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<Profile>,
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<Profile?>(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<Profile>,
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,
)
},
)
}
}
}
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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<Float>,
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(),
)
}
}
}

View File

@@ -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<GroupItem>,
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,
)
}

View File

@@ -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<Group> = emptyList(),
val isLoading: Boolean = false,
val expandedGroups: Set<String> = 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<GroupsUiState, GroupsEvent>(), 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<OutboundGroup>) {
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,
)
}
}
}
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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<ProcessedLogEntry> = 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<Int> = emptySet(),
)
class LogViewModel : ViewModel(), CommandClient.Handler {
companion object {
private val maxLines = 3000
}
private val _uiState = MutableStateFlow(LogUiState())
val uiState: StateFlow<LogUiState> = _uiState.asStateFlow()
private val _autoScrollEnabled = MutableStateFlow(true)
val isAtBottom: StateFlow<Boolean> = _autoScrollEnabled.asStateFlow()
private val _scrollToBottomTrigger = MutableStateFlow(0)
val scrollToBottomTrigger: StateFlow<Int> = _scrollToBottomTrigger.asStateFlow()
private val _searchQueryInternal = MutableStateFlow("")
private val logIdGenerator = AtomicLong(0)
private val allLogs = LinkedList<ProcessedLogEntry>()
private val bufferedLogs = LinkedList<ProcessedLogEntry>()
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<LogEntry>) {
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<Int>()
} else {
_uiState.value.selectedLogIndices
}
_uiState.update { it.copy(logs = logsToDisplay, selectedLogIndices = selectionCleared) }
}
override fun onCleared() {
super.onCleared()
commandClient.disconnect()
}
}

View File

@@ -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()
}
}

View File

@@ -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<EditProfileContentUiState> = _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<Int>()
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<Int>()
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<Int>()
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 <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(EditProfileContentViewModel::class.java)) {
return EditProfileContentViewModel(profileId, initialProfileName, initialIsReadOnly) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
}

View File

@@ -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,
)
}
}
}
}
}
}
}
}
}

View File

@@ -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<EditProfileUiState> = _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<Application>().getString(R.string.profile_input_required)
intValue < 15 -> getApplication<Application>().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
}
}
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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<String?>(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<IconCategory>,
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<ProfileIcon>,
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,
}

View File

@@ -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);
}
}

View File

@@ -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))
}
}

View File

@@ -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))
}
},
)
}
}
}

View File

@@ -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))
}
}

View File

@@ -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))
}
}

View File

@@ -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)

View File

@@ -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),
)

View File

@@ -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,
)
}

View File

@@ -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,
),
)

View File

@@ -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
}
}
}

View File

@@ -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<ProfileIcon>,
)
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<ProfileIcon> {
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<ProfileIcon> {
val lowercaseQuery = query.lowercase()
return getAllIcons().filter { icon ->
icon.id.contains(lowercaseQuery) ||
icon.label.lowercase().contains(lowercaseQuery)
}
}
}

View File

@@ -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<ProfileIcon>
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<ProfileIcon> {
return MaterialIconsLibrary.searchIcons(query)
}
fun getCategories() = MaterialIconsLibrary.categories
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -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"),
)
}

View File

@@ -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"),
)
}

View File

@@ -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"),
)
}

View File

@@ -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"),
)
}

View File

@@ -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"),
)
}

View File

@@ -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"),
)
}

View File

@@ -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"),
)
}

View File

@@ -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"),
)
}

View File

@@ -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"),
)
}

View File

@@ -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<ProfileIcon>,
) {
val size: Int get() = icons.size
}

View File

@@ -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"),
)
}

View File

@@ -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"),
)
}

View File

@@ -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<IconCategory> =
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<ProfileIcon> {
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<ProfileIcon> {
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<ProfileIcon> {
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<String> {
return categories.map { it.name }
}
}

View File

@@ -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"),
)
}

View File

@@ -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"),
)
}

View File

@@ -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"),
)
}

View File

@@ -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"),
)
}

View File

@@ -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"),
)
}

View File

@@ -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"
}
}

View File

@@ -7,5 +7,5 @@ enum class Alert {
EmptyConfiguration,
StartCommandServer,
CreateService,
StartService
}
StartService,
}

View File

@@ -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
}
}

View File

@@ -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) {
}
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -3,4 +3,4 @@ package io.nekohasekai.sfa.constant
object ServiceMode {
const val NORMAL = "normal"
const val VPN = "vpn"
}
}

View File

@@ -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"
}
}

View File

@@ -5,4 +5,4 @@ enum class Status {
Starting,
Started,
Stopping,
}
}

Some files were not shown because too many files have changed in this diff Show More