Refactor to Compose based UI
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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>()!! }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
40
app/src/main/java/io/nekohasekai/sfa/LauncherActivity.kt
Normal file
40
app/src/main/java/io/nekohasekai/sfa/LauncherActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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!!)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -58,4 +58,4 @@ class ServiceBinder(private val status: MutableLiveData<Status>) : IService.Stub
|
||||
fun close() {
|
||||
callbacks.kill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
611
app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt
Normal file
611
app/src/main/java/io/nekohasekai/sfa/compose/ComposeActivity.kt
Normal 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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
131
app/src/main/java/io/nekohasekai/sfa/compose/LineChart.kt
Normal file
131
app/src/main/java/io/nekohasekai/sfa/compose/LineChart.kt
Normal 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
43
app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt
Normal file
43
app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
15
app/src/main/java/io/nekohasekai/sfa/compose/base/UiState.kt
Normal file
15
app/src/main/java/io/nekohasekai/sfa/compose/base/UiState.kt
Normal 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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
32
app/src/main/java/io/nekohasekai/sfa/compose/theme/Color.kt
Normal file
32
app/src/main/java/io/nekohasekai/sfa/compose/theme/Color.kt
Normal 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)
|
||||
14
app/src/main/java/io/nekohasekai/sfa/compose/theme/Shape.kt
Normal file
14
app/src/main/java/io/nekohasekai/sfa/compose/theme/Shape.kt
Normal 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),
|
||||
)
|
||||
69
app/src/main/java/io/nekohasekai/sfa/compose/theme/Theme.kt
Normal file
69
app/src/main/java/io/nekohasekai/sfa/compose/theme/Theme.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
137
app/src/main/java/io/nekohasekai/sfa/compose/theme/Type.kt
Normal file
137
app/src/main/java/io/nekohasekai/sfa/compose/theme/Type.kt
Normal 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,
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
146
app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeUtils.kt
Normal file
146
app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeUtils.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@ enum class Alert {
|
||||
EmptyConfiguration,
|
||||
StartCommandServer,
|
||||
CreateService,
|
||||
StartService
|
||||
}
|
||||
StartService,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,4 @@ package io.nekohasekai.sfa.constant
|
||||
object ServiceMode {
|
||||
const val NORMAL = "normal"
|
||||
const val VPN = "vpn"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,4 @@ enum class Status {
|
||||
Starting,
|
||||
Started,
|
||||
Stopping,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package io.nekohasekai.sfa.database
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Insert
|
||||
@@ -19,12 +20,11 @@ class Profile(
|
||||
@PrimaryKey(autoGenerate = true) var id: Long = 0L,
|
||||
var userOrder: Long = 0L,
|
||||
var name: String = "",
|
||||
var typed: TypedProfile = TypedProfile()
|
||||
@ColumnInfo(defaultValue = "NULL") var icon: String? = null,
|
||||
var typed: TypedProfile = TypedProfile(),
|
||||
) : Parcelable {
|
||||
|
||||
@androidx.room.Dao
|
||||
interface Dao {
|
||||
|
||||
@Insert
|
||||
fun insert(profile: Profile): Long
|
||||
|
||||
@@ -54,8 +54,5 @@ class Profile(
|
||||
|
||||
@Query("SELECT MAX(id) + 1 FROM profiles")
|
||||
fun nextFileID(): Long?
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,24 @@ package io.nekohasekai.sfa.database
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
@Database(
|
||||
entities = [Profile::class], version = 1
|
||||
entities = [Profile::class],
|
||||
version = 2,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class ProfileDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun profileDao(): Profile.Dao
|
||||
|
||||
}
|
||||
companion object {
|
||||
val MIGRATION_1_2 =
|
||||
object : Migration(1, 2) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Add icon column to profiles table with default value null
|
||||
database.execSQL("ALTER TABLE profiles ADD COLUMN icon TEXT DEFAULT NULL")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@Suppress("RedundantSuspendModifier")
|
||||
object ProfileManager {
|
||||
|
||||
private val callbacks = mutableListOf<() -> Unit>()
|
||||
|
||||
fun registerCallback(callback: () -> Unit) {
|
||||
@@ -27,9 +26,10 @@ object ProfileManager {
|
||||
.databaseBuilder(
|
||||
Application.application,
|
||||
ProfileDatabase::class.java,
|
||||
Path.PROFILES_DATABASE_PATH
|
||||
Path.PROFILES_DATABASE_PATH,
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.addMigrations(ProfileDatabase.MIGRATION_1_2)
|
||||
.fallbackToDestructiveMigrationOnDowngrade()
|
||||
.enableMultiInstanceInvalidation()
|
||||
.setQueryExecutor { GlobalScope.launch { it.run() } }
|
||||
.build()
|
||||
@@ -43,7 +43,6 @@ object ProfileManager {
|
||||
return instance.profileDao().nextFileID() ?: 1
|
||||
}
|
||||
|
||||
|
||||
suspend fun get(id: Long): Profile? {
|
||||
return instance.profileDao().get(id)
|
||||
}
|
||||
@@ -99,5 +98,4 @@ object ProfileManager {
|
||||
suspend fun list(): List<Profile> {
|
||||
return instance.profileDao().list()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,13 @@ import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
object Settings {
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private val instance by lazy {
|
||||
Application.application.getDatabasePath(Path.SETTINGS_DATABASE_PATH).parentFile?.mkdirs()
|
||||
Room.databaseBuilder(
|
||||
Application.application,
|
||||
KeyValueDatabase::class.java,
|
||||
Path.SETTINGS_DATABASE_PATH
|
||||
Path.SETTINGS_DATABASE_PATH,
|
||||
).allowMainThreadQueries()
|
||||
.fallbackToDestructiveMigration()
|
||||
.enableMultiInstanceInvalidation()
|
||||
@@ -43,12 +42,14 @@ object Settings {
|
||||
var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { true }
|
||||
var disableMemoryLimit by dataStore.boolean(SettingsKey.DISABLE_MEMORY_LIMIT)
|
||||
var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true }
|
||||
|
||||
var useComposeUI by dataStore.boolean(SettingsKey.USE_COMPOSE_UI) { true }
|
||||
var disableDeprecatedWarnings by dataStore.boolean(SettingsKey.DISABLE_DEPRECATED_WARNINGS) { false }
|
||||
|
||||
const val PER_APP_PROXY_DISABLED = 0
|
||||
const val PER_APP_PROXY_EXCLUDE = 1
|
||||
const val PER_APP_PROXY_INCLUDE = 2
|
||||
|
||||
var autoRedirect by dataStore.boolean(SettingsKey.AUTO_REDIRECT) { false }
|
||||
var perAppProxyEnabled by dataStore.boolean(SettingsKey.PER_APP_PROXY_ENABLED) { false }
|
||||
var perAppProxyMode by dataStore.int(SettingsKey.PER_APP_PROXY_MODE) { PER_APP_PROXY_EXCLUDE }
|
||||
var perAppProxyList by dataStore.stringSet(SettingsKey.PER_APP_PROXY_LIST) { emptySet() }
|
||||
@@ -56,6 +57,9 @@ object Settings {
|
||||
|
||||
var systemProxyEnabled by dataStore.boolean(SettingsKey.SYSTEM_PROXY_ENABLED) { true }
|
||||
|
||||
var dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" }
|
||||
var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() }
|
||||
|
||||
fun serviceClass(): Class<*> {
|
||||
return when (serviceMode) {
|
||||
ServiceMode.VPN -> VPNService::class.java
|
||||
@@ -92,5 +96,4 @@ object Settings {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user