add android code
add android code
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
plugins {
|
||||
kotlin("android")
|
||||
kotlin("kapt")
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
|
||||
implementation("com.airbnb.android:lottie:6.5.2")
|
||||
|
||||
implementation(project(":common"))
|
||||
implementation(project(":core"))
|
||||
implementation(project(":service"))
|
||||
|
||||
implementation(libs.kotlin.coroutine)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.coordinator)
|
||||
implementation(libs.androidx.recyclerview)
|
||||
implementation(libs.androidx.fragment)
|
||||
implementation(libs.androidx.viewpager)
|
||||
implementation(libs.google.material)
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1 @@
|
||||
<manifest package="com.github.kr328.clash.design" />
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import com.github.kr328.clash.design.adapter.AppAdapter
|
||||
import com.github.kr328.clash.design.component.AccessControlMenu
|
||||
import com.github.kr328.clash.design.databinding.DesignAccessControlBinding
|
||||
import com.github.kr328.clash.design.databinding.DialogSearchBinding
|
||||
import com.github.kr328.clash.design.dialog.FullScreenDialog
|
||||
import com.github.kr328.clash.design.model.AppInfo
|
||||
import com.github.kr328.clash.design.store.UiStore
|
||||
import com.github.kr328.clash.design.util.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
|
||||
class AccessControlDesign(
|
||||
context: Context,
|
||||
uiStore: UiStore,
|
||||
private val selected: MutableSet<String>,
|
||||
) : Design<AccessControlDesign.Request>(context) {
|
||||
enum class Request {
|
||||
ReloadApps,
|
||||
SelectAll,
|
||||
SelectNone,
|
||||
SelectInvert,
|
||||
Import,
|
||||
Export,
|
||||
}
|
||||
|
||||
private val binding = DesignAccessControlBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
|
||||
private val adapter = AppAdapter(context, selected)
|
||||
|
||||
private val menu: AccessControlMenu by lazy {
|
||||
AccessControlMenu(context, binding.menuView, uiStore, requests)
|
||||
}
|
||||
|
||||
val apps: List<AppInfo>
|
||||
get() = adapter.apps
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
suspend fun patchApps(apps: List<AppInfo>) {
|
||||
adapter.swapDataSet(adapter::apps, apps, false)
|
||||
}
|
||||
|
||||
suspend fun rebindAll() {
|
||||
withContext(Dispatchers.Main) {
|
||||
adapter.rebindAll()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
binding.self = this
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.mainList.recyclerList.also {
|
||||
it.bindAppBarElevation(binding.activityBarLayout)
|
||||
it.applyLinearAdapter(context, adapter)
|
||||
}
|
||||
|
||||
binding.menuView.setOnClickListener {
|
||||
menu.show()
|
||||
}
|
||||
|
||||
binding.searchView.setOnClickListener {
|
||||
launch {
|
||||
try {
|
||||
requestSearch()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
rebindAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestSearch() {
|
||||
coroutineScope {
|
||||
val binding = DialogSearchBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
val adapter = AppAdapter(context, selected)
|
||||
val dialog = FullScreenDialog(context)
|
||||
val filter = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
dialog.setContentView(binding.root)
|
||||
|
||||
binding.surface = dialog.surface
|
||||
binding.mainList.applyLinearAdapter(context, adapter)
|
||||
binding.keywordView.addTextChangedListener {
|
||||
filter.trySend(Unit)
|
||||
}
|
||||
binding.closeView.setOnClickListener {
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
dialog.setOnDismissListener {
|
||||
cancel()
|
||||
}
|
||||
|
||||
dialog.setOnShowListener {
|
||||
binding.keywordView.requestTextInput()
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
|
||||
while (isActive) {
|
||||
filter.receive()
|
||||
|
||||
val keyword = binding.keywordView.text?.toString() ?: ""
|
||||
|
||||
val apps: List<AppInfo> = if (keyword.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
withContext(Dispatchers.Default) {
|
||||
apps.filter {
|
||||
it.label.contains(keyword, ignoreCase = true) ||
|
||||
it.packageName.contains(keyword, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
adapter.patchDataSet(adapter::apps, apps, false, AppInfo::packageName)
|
||||
|
||||
delay(200)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.github.kr328.clash.design.databinding.DesignSettingsCommonBinding
|
||||
import com.github.kr328.clash.design.preference.category
|
||||
import com.github.kr328.clash.design.preference.clickable
|
||||
import com.github.kr328.clash.design.preference.preferenceScreen
|
||||
import com.github.kr328.clash.design.preference.tips
|
||||
import com.github.kr328.clash.design.util.applyFrom
|
||||
import com.github.kr328.clash.design.util.bindAppBarElevation
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import com.github.kr328.clash.design.util.root
|
||||
|
||||
class ApkBrokenDesign(context: Context) : Design<ApkBrokenDesign.Request>(context) {
|
||||
data class Request(val url: String)
|
||||
|
||||
private val binding = DesignSettingsCommonBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
init {
|
||||
binding.surface = surface
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
|
||||
|
||||
val screen = preferenceScreen(context) {
|
||||
tips(R.string.application_broken_tips)
|
||||
|
||||
category(R.string.reinstall)
|
||||
|
||||
clickable(
|
||||
title = R.string.github_releases,
|
||||
summary = R.string.meta_github_url
|
||||
) {
|
||||
clicked {
|
||||
requests.trySend(Request(context.getString(R.string.meta_github_url)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.content.addView(screen.root)
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.github.kr328.clash.design.databinding.DesignAppCrashedBinding
|
||||
import com.github.kr328.clash.design.util.applyFrom
|
||||
import com.github.kr328.clash.design.util.bindAppBarElevation
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import com.github.kr328.clash.design.util.root
|
||||
|
||||
class AppCrashedDesign(context: Context) : Design<Unit>(context) {
|
||||
private val binding = DesignAppCrashedBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
fun setAppLogs(logs: String) {
|
||||
binding.logsView.text = logs
|
||||
}
|
||||
|
||||
init {
|
||||
binding.self = this
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.github.kr328.clash.design.databinding.DesignSettingsCommonBinding
|
||||
import com.github.kr328.clash.design.model.Behavior
|
||||
import com.github.kr328.clash.design.model.DarkMode
|
||||
import com.github.kr328.clash.design.preference.*
|
||||
import com.github.kr328.clash.design.store.UiStore
|
||||
import com.github.kr328.clash.design.util.applyFrom
|
||||
import com.github.kr328.clash.design.util.bindAppBarElevation
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import com.github.kr328.clash.design.util.root
|
||||
import com.github.kr328.clash.service.store.ServiceStore
|
||||
|
||||
class AppSettingsDesign(
|
||||
context: Context,
|
||||
uiStore: UiStore,
|
||||
srvStore: ServiceStore,
|
||||
behavior: Behavior,
|
||||
running: Boolean,
|
||||
) : Design<AppSettingsDesign.Request>(context) {
|
||||
enum class Request {
|
||||
ReCreateAllActivities
|
||||
}
|
||||
|
||||
private val binding = DesignSettingsCommonBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
init {
|
||||
binding.surface = surface
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
|
||||
|
||||
val screen = preferenceScreen(context) {
|
||||
category(R.string.behavior)
|
||||
|
||||
switch(
|
||||
value = behavior::autoRestart,
|
||||
icon = R.drawable.ic_baseline_restore,
|
||||
title = R.string.auto_restart,
|
||||
summary = R.string.allow_clash_auto_restart,
|
||||
)
|
||||
|
||||
category(R.string.interface_)
|
||||
|
||||
selectableList(
|
||||
value = uiStore::darkMode,
|
||||
values = DarkMode.values(),
|
||||
valuesText = arrayOf(
|
||||
R.string.follow_system_android_10,
|
||||
R.string.always_light,
|
||||
R.string.always_dark
|
||||
),
|
||||
icon = R.drawable.ic_baseline_brightness_4,
|
||||
title = R.string.dark_mode
|
||||
) {
|
||||
listener = OnChangedListener {
|
||||
requests.trySend(Request.ReCreateAllActivities)
|
||||
}
|
||||
}
|
||||
|
||||
category(R.string.service)
|
||||
|
||||
switch(
|
||||
value = srvStore::dynamicNotification,
|
||||
icon = R.drawable.ic_baseline_domain,
|
||||
title = R.string.show_traffic,
|
||||
summary = R.string.show_traffic_summary
|
||||
) {
|
||||
enabled = !running
|
||||
}
|
||||
}
|
||||
|
||||
binding.content.addView(screen.root)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.github.kr328.clash.design.ui.Surface
|
||||
import com.github.kr328.clash.design.ui.ToastDuration
|
||||
import com.github.kr328.clash.design.util.setOnInsertsChangedListener
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
abstract class Design<R>(val context: Context) :
|
||||
CoroutineScope by CoroutineScope(Dispatchers.Unconfined) {
|
||||
abstract val root: View
|
||||
|
||||
val surface = Surface()
|
||||
val requests: Channel<R> = Channel(Channel.UNLIMITED)
|
||||
|
||||
suspend fun showToast(
|
||||
resId: Int,
|
||||
duration: ToastDuration,
|
||||
configure: Snackbar.() -> Unit = {}
|
||||
) {
|
||||
return showToast(context.getString(resId), duration, configure)
|
||||
}
|
||||
|
||||
suspend fun showToast(
|
||||
message: CharSequence,
|
||||
duration: ToastDuration,
|
||||
configure: Snackbar.() -> Unit = {}
|
||||
) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Snackbar.make(
|
||||
root,
|
||||
message,
|
||||
when (duration) {
|
||||
ToastDuration.Short -> Snackbar.LENGTH_SHORT
|
||||
ToastDuration.Long -> Snackbar.LENGTH_LONG
|
||||
ToastDuration.Indefinite -> Snackbar.LENGTH_INDEFINITE
|
||||
}
|
||||
).apply(configure).show()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
when (context) {
|
||||
is AppCompatActivity -> {
|
||||
context.window.decorView.setOnInsertsChangedListener {
|
||||
if (surface.insets != it) {
|
||||
surface.insets = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.github.kr328.clash.design.adapter.FileAdapter
|
||||
import com.github.kr328.clash.design.databinding.DesignFilesBinding
|
||||
import com.github.kr328.clash.design.databinding.DialogFilesMenuBinding
|
||||
import com.github.kr328.clash.design.dialog.AppBottomSheetDialog
|
||||
import com.github.kr328.clash.design.dialog.requestModelTextInput
|
||||
import com.github.kr328.clash.design.model.File
|
||||
import com.github.kr328.clash.design.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class FilesDesign(context: Context) : Design<FilesDesign.Request>(context) {
|
||||
sealed class Request {
|
||||
data class OpenFile(val file: File) : Request()
|
||||
data class OpenDirectory(val file: File) : Request()
|
||||
data class RenameFile(val file: File) : Request()
|
||||
data class DeleteFile(val file: File) : Request()
|
||||
data class ImportFile(val file: File?) : Request()
|
||||
data class ExportFile(val file: File) : Request()
|
||||
|
||||
object PopStack : Request()
|
||||
}
|
||||
|
||||
private val binding = DesignFilesBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
private val adapter: FileAdapter = FileAdapter(context, this::requestOpen, this::requestMore)
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
var configurationEditable: Boolean
|
||||
get() = binding.configurationEditable
|
||||
set(value) {
|
||||
binding.configurationEditable = value
|
||||
}
|
||||
|
||||
suspend fun swapFiles(files: List<File>, currentInBaseDir: Boolean) {
|
||||
withContext(Dispatchers.Main) {
|
||||
adapter.swapDataSet(adapter::files, files)
|
||||
binding.currentInBaseDir = currentInBaseDir
|
||||
}
|
||||
}
|
||||
|
||||
fun updateElapsed() {
|
||||
adapter.updateElapsed()
|
||||
}
|
||||
|
||||
suspend fun requestFileName(name: String): String {
|
||||
return context.requestModelTextInput(
|
||||
initial = name,
|
||||
title = context.getText(R.string.file_name),
|
||||
hint = context.getText(R.string.file_name),
|
||||
error = context.getText(R.string.invalid_file_name),
|
||||
validator = ValidatorFileName,
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
binding.self = this
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.mainList.recyclerList.also {
|
||||
it.applyLinearAdapter(context, adapter)
|
||||
it.bindAppBarElevation(binding.activityBarLayout)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestOpen(file: File) {
|
||||
if (file.isDirectory) {
|
||||
requests.trySend(Request.OpenDirectory(file))
|
||||
} else {
|
||||
requests.trySend(Request.OpenFile(file))
|
||||
}
|
||||
}
|
||||
|
||||
fun requestRename(dialog: Dialog, file: File) {
|
||||
requests.trySend(Request.RenameFile(file))
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
fun requestImport(dialog: Dialog, file: File) {
|
||||
requests.trySend(Request.ImportFile(file))
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
fun requestExport(dialog: Dialog, file: File) {
|
||||
requests.trySend(Request.ExportFile(file))
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
fun requestDelete(dialog: Dialog, file: File) {
|
||||
requests.trySend(Request.DeleteFile(file))
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
fun requestNew() {
|
||||
requests.trySend(Request.ImportFile(null))
|
||||
}
|
||||
|
||||
private fun requestMore(file: File) {
|
||||
val dialog = AppBottomSheetDialog(context)
|
||||
|
||||
val binding = DialogFilesMenuBinding.inflate(context.layoutInflater)
|
||||
|
||||
binding.master = this
|
||||
binding.self = dialog
|
||||
binding.file = file
|
||||
binding.currentInBase = this.binding.currentInBaseDir
|
||||
binding.configurationEditable = this.binding.configurationEditable
|
||||
|
||||
dialog.setContentView(binding.root)
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import com.github.kr328.clash.design.databinding.DesignSettingsCommonBinding
|
||||
import com.github.kr328.clash.design.preference.category
|
||||
import com.github.kr328.clash.design.preference.clickable
|
||||
import com.github.kr328.clash.design.preference.preferenceScreen
|
||||
import com.github.kr328.clash.design.preference.tips
|
||||
import com.github.kr328.clash.design.util.applyFrom
|
||||
import com.github.kr328.clash.design.util.bindAppBarElevation
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import com.github.kr328.clash.design.util.root
|
||||
|
||||
class HelpDesign(
|
||||
context: Context,
|
||||
openLink: (Uri) -> Unit,
|
||||
) : Design<Unit>(context) {
|
||||
private val binding = DesignSettingsCommonBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
init {
|
||||
binding.surface = surface
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
|
||||
|
||||
val screen = preferenceScreen(context) {
|
||||
tips(R.string.tips_help)
|
||||
|
||||
|
||||
|
||||
clickable(
|
||||
title = R.string.github_issues,
|
||||
summary = R.string.github_issues_zeus
|
||||
) {
|
||||
clicked {
|
||||
openLink(Uri.parse("https://github.com/nicolastinkl"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
category(R.string.document)
|
||||
|
||||
|
||||
clickable(
|
||||
title = R.string.clash_wiki,
|
||||
summary = R.string.clash_wiki_url
|
||||
) {
|
||||
clicked {
|
||||
openLink(Uri.parse(context.getString(R.string.clash_wiki_url)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
clickable(
|
||||
title = R.string.clash_meta_wiki,
|
||||
summary = R.string.clash_meta_wiki_url
|
||||
) {
|
||||
clicked {
|
||||
openLink(Uri.parse(context.getString(R.string.clash_meta_wiki_url)))
|
||||
}
|
||||
}
|
||||
|
||||
category(R.string.sources)
|
||||
|
||||
clickable(
|
||||
title = R.string.clash_meta_core,
|
||||
summary = R.string.clash_meta_core_url
|
||||
) {
|
||||
clicked {
|
||||
openLink(Uri.parse(context.getString(R.string.clash_meta_core_url)))
|
||||
}
|
||||
}
|
||||
|
||||
clickable(
|
||||
title = R.string.clash_meta_for_android,
|
||||
summary = R.string.meta_github_url
|
||||
) {
|
||||
clicked {
|
||||
openLink(Uri.parse(context.getString(R.string.meta_github_url)))
|
||||
}
|
||||
} */
|
||||
}
|
||||
|
||||
binding.content.addView(screen.root)
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class ImageSliderAdapter(private val imageList: List<Int>) : RecyclerView.Adapter<ImageSliderAdapter.ImageViewHolder>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageViewHolder {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.image_slider_item, parent, false)
|
||||
return ImageViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ImageViewHolder, position: Int) {
|
||||
holder.imageView.setImageResource(imageList[position])
|
||||
|
||||
with(holder) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return imageList.size
|
||||
}
|
||||
|
||||
class ImageViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val imageView: ImageView = view.findViewById(R.id.slider_image)
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.github.kr328.clash.core.model.LogMessage
|
||||
import com.github.kr328.clash.design.adapter.LogMessageAdapter
|
||||
import com.github.kr328.clash.design.databinding.DesignLogcatBinding
|
||||
import com.github.kr328.clash.design.ui.ToastDuration
|
||||
import com.github.kr328.clash.design.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class LogcatDesign(
|
||||
context: Context,
|
||||
private val streaming: Boolean,
|
||||
) : Design<LogcatDesign.Request>(context) {
|
||||
enum class Request {
|
||||
Close, Delete, Export
|
||||
}
|
||||
|
||||
private val binding = DesignLogcatBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
private val adapter = LogMessageAdapter(context) {
|
||||
launch {
|
||||
val data = ClipData.newPlainText("log_message", it.message)
|
||||
|
||||
context.getSystemService<ClipboardManager>()?.setPrimaryClip(data)
|
||||
|
||||
showToast(R.string.copied, ToastDuration.Short)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun patchMessages(messages: List<LogMessage>, removed: Int, appended: Int) {
|
||||
withContext(Dispatchers.Main) {
|
||||
adapter.messages = messages
|
||||
|
||||
adapter.notifyItemRangeInserted(adapter.messages.size, appended)
|
||||
adapter.notifyItemRangeRemoved(0, removed)
|
||||
|
||||
if (streaming && binding.recyclerList.isTop) {
|
||||
binding.recyclerList.scrollToPosition(messages.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
init {
|
||||
binding.self = this
|
||||
binding.streaming = streaming
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.recyclerList.bindAppBarElevation(binding.activityBarLayout)
|
||||
|
||||
binding.recyclerList.layoutManager = LinearLayoutManager(context).apply {
|
||||
if (streaming) {
|
||||
reverseLayout = true
|
||||
stackFromEnd = true
|
||||
}
|
||||
}
|
||||
binding.recyclerList.adapter = adapter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.github.kr328.clash.design.adapter.LogFileAdapter
|
||||
import com.github.kr328.clash.design.databinding.DesignLogsBinding
|
||||
import com.github.kr328.clash.design.model.LogFile
|
||||
import com.github.kr328.clash.design.util.*
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class LogsDesign(context: Context) : Design<LogsDesign.Request>(context) {
|
||||
sealed class Request {
|
||||
object StartLogcat : Request()
|
||||
object DeleteAll : Request()
|
||||
|
||||
data class OpenFile(val file: LogFile) : Request()
|
||||
}
|
||||
|
||||
private val binding = DesignLogsBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
private val adapter = LogFileAdapter(context) {
|
||||
requests.trySend(Request.OpenFile(it))
|
||||
}
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
suspend fun patchLogs(logs: List<LogFile>) {
|
||||
adapter.patchDataSet(adapter::logs, logs, false, LogFile::fileName)
|
||||
}
|
||||
|
||||
suspend fun requestDeleteAll(): Boolean {
|
||||
return withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine { ctx ->
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.delete_all_logs)
|
||||
.setMessage(R.string.delete_all_logs_warn)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> ctx.resume(true) }
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
.setOnDismissListener { if (!ctx.isCompleted) ctx.resume(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
binding.self = this
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.recyclerList.applyLinearAdapter(context, adapter)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.airbnb.lottie.LottieAnimationView
|
||||
import com.github.kr328.clash.common.util.ticker
|
||||
import com.github.kr328.clash.core.Clash
|
||||
import com.github.kr328.clash.core.model.TunnelState
|
||||
import com.github.kr328.clash.core.util.trafficTotal
|
||||
import com.github.kr328.clash.design.databinding.DesignAboutBinding
|
||||
import com.github.kr328.clash.design.databinding.DesignMainBinding
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import com.github.kr328.clash.design.util.resolveThemedColor
|
||||
import com.github.kr328.clash.design.util.root
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MainDesign(context: Context) : Design<MainDesign.Request>(context) {
|
||||
enum class Request {
|
||||
ToggleStatus,
|
||||
OpenProxy,
|
||||
OpenProfiles,
|
||||
OpenProviders,
|
||||
OpenLogs,
|
||||
OpenSettings,
|
||||
OpenHelp,
|
||||
OpenAbout,
|
||||
OpenSettingsDIY,
|
||||
OpenSettingsKEFU,
|
||||
OpenModeDirect,
|
||||
OpenModeGlobal,
|
||||
OpenModeRule,
|
||||
}
|
||||
|
||||
private val binding = DesignMainBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
suspend fun setProfileName(name: String?) {
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.profileName = name
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
suspend fun setSelectNodeName(name: String?) {
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.selectnodeName = name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setClashRunning(running: Boolean) {
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.clashRunning = running
|
||||
if (running){
|
||||
binding.connectionButton.setAnimation("51a05581.json")
|
||||
|
||||
}else{
|
||||
binding.connectionButton.setAnimation("1d2a0fe5.json")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setForwarded(value: Long) {
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.forwarded = value.trafficTotal()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setMode(mode: TunnelState.Mode) {
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.mode = when (mode) {
|
||||
TunnelState.Mode.Direct -> context.getString(R.string.direct_mode)
|
||||
TunnelState.Mode.Global -> context.getString(R.string.global_mode)
|
||||
TunnelState.Mode.Rule -> context.getString(R.string.rule_mode)
|
||||
else -> context.getString(R.string.rule_mode)
|
||||
}
|
||||
|
||||
when (mode) {
|
||||
TunnelState.Mode.Direct -> binding.modeSelectionbutton1.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#2b9a45"))
|
||||
TunnelState.Mode.Global -> binding.modeSelectionbutton2.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#2b9a45"))
|
||||
TunnelState.Mode.Rule -> binding.modeSelectionbutton3.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#2b9a45"))
|
||||
else -> binding.modeSelectionbutton3.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#2b9a45"))
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setHasProviders(has: Boolean) {
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.hasProviders = has
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showAbout(versionName: String) {
|
||||
withContext(Dispatchers.Main) {
|
||||
val binding = DesignAboutBinding.inflate(context.layoutInflater).apply {
|
||||
this.versionName = versionName
|
||||
}
|
||||
|
||||
AlertDialog.Builder(context)
|
||||
.setView(binding.root)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
binding.self = this
|
||||
|
||||
binding.colorClashStarted = context.resolveThemedColor(R.attr.colorPrimary)
|
||||
binding.colorClashStopped = context.resolveThemedColor(R.attr.colorClashStopped)
|
||||
}
|
||||
|
||||
fun request(request: Request) {
|
||||
requests.trySend(request)
|
||||
}
|
||||
|
||||
suspend fun startAutoScroll(viewPager2: androidx.viewpager2.widget.ViewPager2,nextItem: Int){
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
viewPager2.setCurrentItem(nextItem, true)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startBannsers() {
|
||||
withContext(Dispatchers.Main) {
|
||||
//sart ad bannsers
|
||||
val viewPager2 = binding.adBanner
|
||||
val tabLayout = binding.tabLayout
|
||||
// 创建图片资源列表
|
||||
val imageList = listOf(
|
||||
R.drawable.ad_banner,
|
||||
R.drawable.ad_banner2,
|
||||
R.drawable.ad_banner3
|
||||
)
|
||||
|
||||
|
||||
// 设置适配器
|
||||
val adapter = ImageSliderAdapter(imageList)
|
||||
viewPager2.adapter = adapter
|
||||
// 设置 TabLayoutMediator 来同步 ViewPager2 和 TabLayout
|
||||
TabLayoutMediator(tabLayout, viewPager2) { tab, position ->
|
||||
// Tab 的自定义逻辑可以放在这里
|
||||
}.attach()
|
||||
|
||||
|
||||
binding.modeSelection.addOnButtonCheckedListener { group, checkedId, isChecked ->
|
||||
|
||||
// binding.modeSelectionbutton1.setBackgroundColor(Color.parseColor("#383838"))
|
||||
// binding.modeSelectionbutton2.setBackgroundColor(Color.parseColor("#383838"))
|
||||
// binding.modeSelectionbutton3.setBackgroundColor(Color.parseColor("#383838"))
|
||||
|
||||
|
||||
// Reset all buttons to default background tint
|
||||
binding.modeSelectionbutton1.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#383838"))
|
||||
binding.modeSelectionbutton2.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#383838"))
|
||||
binding.modeSelectionbutton3.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#383838"))
|
||||
|
||||
|
||||
when (checkedId) {
|
||||
R.id.modeSelectionbutton1 -> {
|
||||
request(Request.OpenModeDirect)
|
||||
binding.modeSelectionbutton1.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#2b9a45"))
|
||||
}
|
||||
|
||||
R.id.modeSelectionbutton2 -> {
|
||||
|
||||
request(Request.OpenModeGlobal)
|
||||
binding.modeSelectionbutton2.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#2b9a45"))
|
||||
}
|
||||
|
||||
R.id.modeSelectionbutton3 -> {
|
||||
request(Request.OpenModeRule)
|
||||
binding.modeSelectionbutton3.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#2b9a45"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.github.kr328.clash.core.model.ConfigurationOverride
|
||||
import com.github.kr328.clash.design.databinding.DesignSettingsMetaFeatureBinding
|
||||
import com.github.kr328.clash.design.preference.*
|
||||
import com.github.kr328.clash.design.util.*
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class MetaFeatureSettingsDesign(
|
||||
context: Context,
|
||||
configuration: ConfigurationOverride
|
||||
) : Design<MetaFeatureSettingsDesign.Request>(context) {
|
||||
enum class Request {
|
||||
ResetOverride, ImportGeoIp, ImportGeoSite, ImportCountry, ImportASN
|
||||
}
|
||||
|
||||
private val binding = DesignSettingsMetaFeatureBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
suspend fun requestResetConfirm(): Boolean {
|
||||
return suspendCancellableCoroutine { ctx ->
|
||||
val dialog = MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.reset_override_settings)
|
||||
.setMessage(R.string.reset_override_settings_message)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> ctx.resume(true) }
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
|
||||
dialog.setOnDismissListener {
|
||||
if (!ctx.isCompleted)
|
||||
ctx.resume(false)
|
||||
}
|
||||
|
||||
ctx.invokeOnCancellation {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
binding.self = this
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
|
||||
|
||||
val booleanValues: Array<Boolean?> = arrayOf(
|
||||
null,
|
||||
true,
|
||||
false
|
||||
)
|
||||
val booleanValuesText: Array<Int> = arrayOf(
|
||||
R.string.dont_modify,
|
||||
R.string.enabled,
|
||||
R.string.disabled
|
||||
)
|
||||
|
||||
val screen = preferenceScreen(context) {
|
||||
category(R.string.settings)
|
||||
|
||||
selectableList(
|
||||
value = configuration::unifiedDelay,
|
||||
values = booleanValues,
|
||||
valuesText = booleanValuesText,
|
||||
title = R.string.unified_delay,
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration::geodataMode,
|
||||
values = booleanValues,
|
||||
valuesText = booleanValuesText,
|
||||
title = R.string.geodata_mode,
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration::tcpConcurrent,
|
||||
values = booleanValues,
|
||||
valuesText = booleanValuesText,
|
||||
title = R.string.tcp_concurrent,
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration::findProcessMode,
|
||||
values = arrayOf(
|
||||
null,
|
||||
ConfigurationOverride.FindProcessMode.Off,
|
||||
ConfigurationOverride.FindProcessMode.Strict,
|
||||
ConfigurationOverride.FindProcessMode.Always
|
||||
),
|
||||
valuesText = arrayOf(
|
||||
R.string.dont_modify,
|
||||
R.string.off,
|
||||
R.string.strict,
|
||||
R.string.always,
|
||||
),
|
||||
title = R.string.find_process_mode,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
category(R.string.sniffer_setting)
|
||||
|
||||
val snifferDependencies: MutableList<Preference> = mutableListOf()
|
||||
|
||||
val sniffer = selectableList(
|
||||
value = configuration.sniffer::enable,
|
||||
values = arrayOf(
|
||||
null,
|
||||
true,
|
||||
false
|
||||
),
|
||||
valuesText = arrayOf(
|
||||
R.string.dont_modify,
|
||||
R.string.enabled,
|
||||
R.string.disabled
|
||||
),
|
||||
title = R.string.strategy
|
||||
) {
|
||||
listener = OnChangedListener {
|
||||
if (configuration.sniffer.enable == false) {
|
||||
snifferDependencies.forEach {
|
||||
it.enabled = false
|
||||
}
|
||||
} else {
|
||||
snifferDependencies.forEach {
|
||||
it.enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editableTextList(
|
||||
value = configuration.sniffer::sniffing,
|
||||
adapter = TextAdapter.String,
|
||||
title = R.string.sniffing,
|
||||
placeholder = R.string.dont_modify,
|
||||
configure = snifferDependencies::add,
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration.sniffer::forceDnsMapping,
|
||||
values = booleanValues,
|
||||
valuesText = booleanValuesText,
|
||||
title = R.string.force_dns_mapping,
|
||||
configure = snifferDependencies::add,
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration.sniffer::parsePureIp,
|
||||
values = booleanValues,
|
||||
valuesText = booleanValuesText,
|
||||
title = R.string.parse_pure_ip,
|
||||
configure = snifferDependencies::add,
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration.sniffer::overrideDestination,
|
||||
values = booleanValues,
|
||||
valuesText = booleanValuesText,
|
||||
title = R.string.override_destination,
|
||||
configure = snifferDependencies::add,
|
||||
)
|
||||
|
||||
editableTextList(
|
||||
value = configuration.sniffer::forceDomain,
|
||||
adapter = TextAdapter.String,
|
||||
title = R.string.force_domain,
|
||||
placeholder = R.string.dont_modify,
|
||||
configure = snifferDependencies::add,
|
||||
)
|
||||
|
||||
editableTextList(
|
||||
value = configuration.sniffer::skipDomain,
|
||||
adapter = TextAdapter.String,
|
||||
title = R.string.skip_domain,
|
||||
placeholder = R.string.dont_modify,
|
||||
configure = snifferDependencies::add,
|
||||
)
|
||||
|
||||
editableTextList(
|
||||
value = configuration.sniffer::portWhitelist,
|
||||
adapter = TextAdapter.String,
|
||||
title = R.string.port_whitelist,
|
||||
placeholder = R.string.dont_modify,
|
||||
configure = snifferDependencies::add,
|
||||
)
|
||||
|
||||
sniffer.listener?.onChanged()
|
||||
|
||||
/*
|
||||
category(R.string.geox_url_setting)
|
||||
|
||||
val geoxUrlDependencies: MutableList<Preference> = mutableListOf()
|
||||
|
||||
editableText(
|
||||
value = configuration.geoxurl::geoip,
|
||||
adapter = NullableTextAdapter.String,
|
||||
title = R.string.geox_geoip,
|
||||
placeholder = R.string.dont_modify,
|
||||
empty = R.string.geoip_url,
|
||||
configure = geoxUrlDependencies::add,
|
||||
)
|
||||
|
||||
editableText(
|
||||
value = configuration.geoxurl::mmdb,
|
||||
adapter = NullableTextAdapter.String,
|
||||
title = R.string.geox_mmdb,
|
||||
placeholder = R.string.dont_modify,
|
||||
empty = R.string.mmdb_url,
|
||||
configure = geoxUrlDependencies::add,
|
||||
)
|
||||
|
||||
editableText(
|
||||
value = configuration.geoxurl::geosite,
|
||||
adapter = NullableTextAdapter.String,
|
||||
title = R.string.geox_geosite,
|
||||
placeholder = R.string.dont_modify,
|
||||
empty = R.string.geosite_url,
|
||||
configure = geoxUrlDependencies::add,
|
||||
)
|
||||
*/
|
||||
|
||||
category(R.string.geox_files)
|
||||
|
||||
clickable (
|
||||
title = R.string.import_geoip_file,
|
||||
summary = R.string.press_to_import,
|
||||
){
|
||||
clicked {
|
||||
requests.trySend(Request.ImportGeoIp)
|
||||
}
|
||||
}
|
||||
|
||||
clickable (
|
||||
title = R.string.import_geosite_file,
|
||||
summary = R.string.press_to_import,
|
||||
){
|
||||
clicked {
|
||||
requests.trySend(Request.ImportGeoSite)
|
||||
}
|
||||
}
|
||||
|
||||
clickable (
|
||||
title = R.string.import_country_file,
|
||||
summary = R.string.press_to_import,
|
||||
){
|
||||
clicked {
|
||||
requests.trySend(Request.ImportCountry)
|
||||
}
|
||||
}
|
||||
|
||||
clickable (
|
||||
title = R.string.import_asn_file,
|
||||
summary = R.string.press_to_import,
|
||||
){
|
||||
clicked {
|
||||
requests.trySend(Request.ImportASN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.content.addView(screen.root)
|
||||
}
|
||||
|
||||
fun requestClear() {
|
||||
requests.trySend(Request.ResetOverride)
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import com.github.kr328.clash.common.util.componentName
|
||||
import com.github.kr328.clash.design.databinding.DesignSettingsCommonBinding
|
||||
import com.github.kr328.clash.design.model.Behavior
|
||||
import com.github.kr328.clash.design.preference.*
|
||||
import com.github.kr328.clash.design.store.UiStore
|
||||
import com.github.kr328.clash.design.ui.ToastDuration
|
||||
import com.github.kr328.clash.design.util.applyFrom
|
||||
import com.github.kr328.clash.design.util.bindAppBarElevation
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import com.github.kr328.clash.design.util.root
|
||||
import com.github.kr328.clash.service.model.AccessControlMode
|
||||
import com.github.kr328.clash.service.store.ServiceStore
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NetworkSettingsDesign(
|
||||
context: Context,
|
||||
uiStore: UiStore,
|
||||
srvStore: ServiceStore,
|
||||
running: Boolean,
|
||||
) : Design<NetworkSettingsDesign.Request>(context) {
|
||||
enum class Request {
|
||||
StartAccessControlList
|
||||
}
|
||||
|
||||
private val binding = DesignSettingsCommonBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
init {
|
||||
binding.surface = surface
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
|
||||
|
||||
val screen = preferenceScreen(context) {
|
||||
val vpnDependencies: MutableList<Preference> = mutableListOf()
|
||||
|
||||
val vpn = switch(
|
||||
value = uiStore::enableVpn,
|
||||
icon = R.drawable.ic_baseline_vpn_lock,
|
||||
title = R.string.route_system_traffic,
|
||||
summary = R.string.routing_via_vpn_service
|
||||
) {
|
||||
listener = OnChangedListener {
|
||||
vpnDependencies.forEach {
|
||||
it.enabled = uiStore.enableVpn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
category(R.string.behavior)
|
||||
|
||||
|
||||
category(R.string.vpn_service_options)
|
||||
|
||||
switch(
|
||||
value = srvStore::bypassPrivateNetwork,
|
||||
title = R.string.bypass_private_network,
|
||||
summary = R.string.bypass_private_network_summary,
|
||||
configure = vpnDependencies::add,
|
||||
)
|
||||
|
||||
switch(
|
||||
value = srvStore::dnsHijacking,
|
||||
title = R.string.dns_hijacking,
|
||||
summary = R.string.dns_hijacking_summary,
|
||||
configure = vpnDependencies::add,
|
||||
)
|
||||
|
||||
switch(
|
||||
value = srvStore::allowBypass,
|
||||
title = R.string.allow_bypass,
|
||||
summary = R.string.allow_bypass_summary,
|
||||
configure = vpnDependencies::add,
|
||||
)
|
||||
|
||||
switch(
|
||||
value = srvStore::allowIpv6,
|
||||
title = R.string.allow_ipv6,
|
||||
summary = R.string.allow_ipv6_summary,
|
||||
configure = vpnDependencies::add,
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
switch(
|
||||
value = srvStore::systemProxy,
|
||||
title = R.string.system_proxy,
|
||||
summary = R.string.system_proxy_summary,
|
||||
configure = vpnDependencies::add,
|
||||
)
|
||||
}
|
||||
|
||||
selectableList(
|
||||
value = srvStore::tunStackMode,
|
||||
values = arrayOf(
|
||||
"system",
|
||||
"gvisor",
|
||||
"mixed"
|
||||
),
|
||||
valuesText = arrayOf(
|
||||
R.string.tun_stack_system,
|
||||
R.string.tun_stack_gvisor,
|
||||
R.string.tun_stack_mixed
|
||||
),
|
||||
title = R.string.tun_stack_mode,
|
||||
configure = vpnDependencies::add,
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = srvStore::accessControlMode,
|
||||
values = AccessControlMode.values(),
|
||||
valuesText = arrayOf(
|
||||
R.string.allow_all_apps,
|
||||
R.string.allow_selected_apps,
|
||||
R.string.deny_selected_apps
|
||||
),
|
||||
title = R.string.access_control_mode,
|
||||
configure = vpnDependencies::add,
|
||||
)
|
||||
|
||||
clickable(
|
||||
title = R.string.access_control_packages,
|
||||
summary = R.string.access_control_packages_summary,
|
||||
) {
|
||||
clicked {
|
||||
requests.trySend(Request.StartAccessControlList)
|
||||
}
|
||||
|
||||
vpnDependencies.add(this)
|
||||
}
|
||||
|
||||
if (running) {
|
||||
vpn.enabled = false
|
||||
|
||||
vpnDependencies.forEach {
|
||||
it.enabled = false
|
||||
}
|
||||
} else {
|
||||
vpn.listener?.onChanged()
|
||||
}
|
||||
}
|
||||
|
||||
binding.content.addView(screen.root)
|
||||
|
||||
if (running) {
|
||||
launch {
|
||||
showToast(R.string.options_unavailable, ToastDuration.Indefinite)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.github.kr328.clash.design.adapter.ProfileProviderAdapter
|
||||
import com.github.kr328.clash.design.databinding.DesignNewProfileBinding
|
||||
import com.github.kr328.clash.design.model.ProfileProvider
|
||||
import com.github.kr328.clash.design.util.*
|
||||
|
||||
class NewProfileDesign(context: Context) : Design<NewProfileDesign.Request>(context) {
|
||||
sealed class Request {
|
||||
data class Create(val provider: ProfileProvider) : Request()
|
||||
data class OpenDetail(val provider: ProfileProvider.External) : Request()
|
||||
}
|
||||
|
||||
private val binding = DesignNewProfileBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
private val adapter = ProfileProviderAdapter(context, this::requestCreate, this::requestDetail)
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
suspend fun patchProviders(providers: List<ProfileProvider>) {
|
||||
adapter.apply {
|
||||
patchDataSet(this::providers, providers)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
binding.self = this
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.mainList.recyclerList.also {
|
||||
it.bindAppBarElevation(binding.activityBarLayout)
|
||||
it.applyLinearAdapter(context, adapter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestCreate(provider: ProfileProvider) {
|
||||
requests.trySend(Request.Create(provider))
|
||||
}
|
||||
|
||||
private fun requestDetail(provider: ProfileProvider): Boolean {
|
||||
if (provider !is ProfileProvider.External) return false
|
||||
|
||||
requests.trySend(Request.OpenDetail(provider))
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.github.kr328.clash.core.model.ConfigurationOverride
|
||||
import com.github.kr328.clash.core.model.LogMessage
|
||||
import com.github.kr328.clash.core.model.TunnelState
|
||||
import com.github.kr328.clash.design.databinding.DesignSettingsOverideBinding
|
||||
import com.github.kr328.clash.design.databinding.DialogPreferenceListBinding
|
||||
import com.github.kr328.clash.design.dialog.FullScreenDialog
|
||||
import com.github.kr328.clash.design.model.AppInfo
|
||||
import com.github.kr328.clash.design.preference.*
|
||||
import com.github.kr328.clash.design.util.*
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class OverrideSettingsDesign(
|
||||
context: Context,
|
||||
configuration: ConfigurationOverride
|
||||
) : Design<OverrideSettingsDesign.Request>(context) {
|
||||
enum class Request {
|
||||
ResetOverride
|
||||
}
|
||||
|
||||
private val binding = DesignSettingsOverideBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
suspend fun requestResetConfirm(): Boolean {
|
||||
return suspendCancellableCoroutine { ctx ->
|
||||
val dialog = MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.reset_override_settings)
|
||||
.setMessage(R.string.reset_override_settings_message)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> ctx.resume(true) }
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
|
||||
dialog.setOnDismissListener {
|
||||
if (!ctx.isCompleted)
|
||||
ctx.resume(false)
|
||||
}
|
||||
|
||||
ctx.invokeOnCancellation {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
binding.self = this
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
|
||||
|
||||
val booleanValues: Array<Boolean?> = arrayOf(
|
||||
null,
|
||||
true,
|
||||
false
|
||||
)
|
||||
val booleanValuesText: Array<Int> = arrayOf(
|
||||
R.string.dont_modify,
|
||||
R.string.enabled,
|
||||
R.string.disabled
|
||||
)
|
||||
|
||||
val screen = preferenceScreen(context) {
|
||||
category(R.string.general)
|
||||
|
||||
editableText(
|
||||
value = configuration::httpPort,
|
||||
adapter = NullableTextAdapter.Port,
|
||||
title = R.string.http_port,
|
||||
placeholder = R.string.dont_modify,
|
||||
empty = R.string.disabled,
|
||||
)
|
||||
|
||||
editableText(
|
||||
value = configuration::socksPort,
|
||||
adapter = NullableTextAdapter.Port,
|
||||
title = R.string.socks_port,
|
||||
placeholder = R.string.dont_modify,
|
||||
empty = R.string.disabled,
|
||||
)
|
||||
|
||||
editableText(
|
||||
value = configuration::redirectPort,
|
||||
adapter = NullableTextAdapter.Port,
|
||||
title = R.string.redirect_port,
|
||||
placeholder = R.string.dont_modify,
|
||||
empty = R.string.disabled,
|
||||
)
|
||||
|
||||
editableText(
|
||||
value = configuration::tproxyPort,
|
||||
adapter = NullableTextAdapter.Port,
|
||||
title = R.string.tproxy_port,
|
||||
placeholder = R.string.dont_modify,
|
||||
empty = R.string.disabled,
|
||||
)
|
||||
|
||||
editableText(
|
||||
value = configuration::mixedPort,
|
||||
adapter = NullableTextAdapter.Port,
|
||||
title = R.string.mixed_port,
|
||||
placeholder = R.string.dont_modify,
|
||||
empty = R.string.disabled,
|
||||
)
|
||||
|
||||
editableTextList(
|
||||
value = configuration::authentication,
|
||||
adapter = TextAdapter.String,
|
||||
title = R.string.authentication,
|
||||
placeholder = R.string.dont_modify,
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration::allowLan,
|
||||
values = booleanValues,
|
||||
valuesText = booleanValuesText,
|
||||
title = R.string.allow_lan,
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration::ipv6,
|
||||
values = booleanValues,
|
||||
valuesText = booleanValuesText,
|
||||
title = R.string.ipv6,
|
||||
)
|
||||
|
||||
editableText(
|
||||
value = configuration::bindAddress,
|
||||
adapter = NullableTextAdapter.String,
|
||||
title = R.string.bind_address,
|
||||
placeholder = R.string.dont_modify,
|
||||
empty = R.string.default_
|
||||
)
|
||||
|
||||
editableText(
|
||||
value = configuration::externalController,
|
||||
adapter = NullableTextAdapter.String,
|
||||
title = R.string.external_controller,
|
||||
placeholder = R.string.dont_modify,
|
||||
empty = R.string.default_
|
||||
)
|
||||
|
||||
editableText(
|
||||
value = configuration::externalControllerTLS,
|
||||
adapter = NullableTextAdapter.String,
|
||||
title = R.string.external_controller_tls,
|
||||
placeholder = R.string.dont_modify,
|
||||
empty = R.string.default_
|
||||
)
|
||||
|
||||
editableTextList(
|
||||
value = configuration.externalControllerCors::allowOrigins,
|
||||
adapter = TextAdapter.String,
|
||||
title = R.string.allow_origins,
|
||||
placeholder = R.string.dont_modify,
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration.externalControllerCors::allowPrivateNetwork,
|
||||
values = booleanValues,
|
||||
valuesText = booleanValuesText,
|
||||
title = R.string.allow_private_network,
|
||||
)
|
||||
|
||||
editableText(
|
||||
value = configuration::secret,
|
||||
adapter = NullableTextAdapter.String,
|
||||
title = R.string.secret,
|
||||
placeholder = R.string.dont_modify,
|
||||
empty = R.string.default_
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration::mode,
|
||||
values = arrayOf(
|
||||
null,
|
||||
TunnelState.Mode.Direct,
|
||||
TunnelState.Mode.Global,
|
||||
TunnelState.Mode.Rule
|
||||
),
|
||||
valuesText = arrayOf(
|
||||
R.string.dont_modify,
|
||||
R.string.direct_mode,
|
||||
R.string.global_mode,
|
||||
R.string.rule_mode
|
||||
),
|
||||
title = R.string.mode
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration::logLevel,
|
||||
values = arrayOf(
|
||||
null,
|
||||
LogMessage.Level.Info,
|
||||
LogMessage.Level.Warning,
|
||||
LogMessage.Level.Error,
|
||||
LogMessage.Level.Debug,
|
||||
LogMessage.Level.Silent,
|
||||
),
|
||||
valuesText = arrayOf(
|
||||
R.string.dont_modify,
|
||||
R.string.info,
|
||||
R.string.warning,
|
||||
R.string.error,
|
||||
R.string.debug,
|
||||
R.string.silent,
|
||||
),
|
||||
title = R.string.log_level,
|
||||
)
|
||||
|
||||
editableTextMap(
|
||||
value = configuration::hosts,
|
||||
keyAdapter = TextAdapter.String,
|
||||
valueAdapter = TextAdapter.String,
|
||||
title = R.string.hosts,
|
||||
placeholder = R.string.dont_modify,
|
||||
)
|
||||
|
||||
category(R.string.dns)
|
||||
|
||||
val dnsDependencies: MutableList<Preference> = mutableListOf()
|
||||
|
||||
val dns = selectableList(
|
||||
value = configuration.dns::enable,
|
||||
values = arrayOf(
|
||||
null,
|
||||
true,
|
||||
false
|
||||
),
|
||||
valuesText = arrayOf(
|
||||
R.string.dont_modify,
|
||||
R.string.force_enable,
|
||||
R.string.use_built_in,
|
||||
),
|
||||
title = R.string.strategy
|
||||
) {
|
||||
listener = OnChangedListener {
|
||||
if (configuration.dns.enable == false) {
|
||||
dnsDependencies.forEach {
|
||||
it.enabled = false
|
||||
}
|
||||
} else {
|
||||
dnsDependencies.forEach {
|
||||
it.enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectableList(
|
||||
value = configuration.dns::preferH3,
|
||||
values = booleanValues,
|
||||
valuesText = booleanValuesText,
|
||||
title = R.string.prefer_h3,
|
||||
configure = dnsDependencies::add,
|
||||
)
|
||||
|
||||
editableText(
|
||||
value = configuration.dns::listen,
|
||||
adapter = NullableTextAdapter.String,
|
||||
title = R.string.listen,
|
||||
placeholder = R.string.dont_modify,
|
||||
empty = R.string.disabled,
|
||||
configure = dnsDependencies::add,
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration.app::appendSystemDns,
|
||||
values = booleanValues,
|
||||
valuesText = booleanValuesText,
|
||||
title = R.string.append_system_dns,
|
||||
configure = dnsDependencies::add,
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration.dns::ipv6,
|
||||
values = booleanValues,
|
||||
valuesText = booleanValuesText,
|
||||
title = R.string.ipv6,
|
||||
configure = dnsDependencies::add,
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration.dns::useHosts,
|
||||
values = booleanValues,
|
||||
valuesText = booleanValuesText,
|
||||
title = R.string.use_hosts,
|
||||
configure = dnsDependencies::add,
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration.dns::enhancedMode,
|
||||
values = arrayOf(
|
||||
null,
|
||||
ConfigurationOverride.DnsEnhancedMode.None,
|
||||
ConfigurationOverride.DnsEnhancedMode.FakeIp,
|
||||
ConfigurationOverride.DnsEnhancedMode.Mapping
|
||||
),
|
||||
valuesText = arrayOf(
|
||||
R.string.dont_modify,
|
||||
R.string.disabled,
|
||||
R.string.fakeip,
|
||||
R.string.mapping
|
||||
),
|
||||
title = R.string.enhanced_mode,
|
||||
configure = dnsDependencies::add,
|
||||
)
|
||||
|
||||
editableTextList(
|
||||
value = configuration.dns::nameServer,
|
||||
adapter = TextAdapter.String,
|
||||
title = R.string.name_server,
|
||||
placeholder = R.string.dont_modify,
|
||||
configure = dnsDependencies::add,
|
||||
)
|
||||
|
||||
editableTextList(
|
||||
value = configuration.dns::fallback,
|
||||
adapter = TextAdapter.String,
|
||||
title = R.string.fallback,
|
||||
placeholder = R.string.dont_modify,
|
||||
configure = dnsDependencies::add,
|
||||
)
|
||||
|
||||
editableTextList(
|
||||
value = configuration.dns::defaultServer,
|
||||
adapter = TextAdapter.String,
|
||||
title = R.string.default_name_server,
|
||||
placeholder = R.string.dont_modify,
|
||||
configure = dnsDependencies::add,
|
||||
)
|
||||
|
||||
editableTextList(
|
||||
value = configuration.dns::fakeIpFilter,
|
||||
adapter = TextAdapter.String,
|
||||
title = R.string.fakeip_filter,
|
||||
placeholder = R.string.dont_modify,
|
||||
configure = dnsDependencies::add,
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration.dns::fakeIPFilterMode,
|
||||
values = arrayOf(
|
||||
null,
|
||||
ConfigurationOverride.FilterMode.BlackList,
|
||||
ConfigurationOverride.FilterMode.WhiteList
|
||||
),
|
||||
valuesText = arrayOf(
|
||||
R.string.dont_modify,
|
||||
R.string.blacklist,
|
||||
R.string.whitelist
|
||||
),
|
||||
title = R.string.fakeip_filter_mode,
|
||||
configure = dnsDependencies::add,
|
||||
)
|
||||
|
||||
selectableList(
|
||||
value = configuration.dns.fallbackFilter::geoIp,
|
||||
values = booleanValues,
|
||||
valuesText = booleanValuesText,
|
||||
title = R.string.geoip_fallback,
|
||||
configure = dnsDependencies::add,
|
||||
)
|
||||
|
||||
editableText(
|
||||
value = configuration.dns.fallbackFilter::geoIpCode,
|
||||
adapter = NullableTextAdapter.String,
|
||||
title = R.string.geoip_fallback_code,
|
||||
placeholder = R.string.dont_modify,
|
||||
empty = R.string.raw_cn,
|
||||
configure = dnsDependencies::add,
|
||||
)
|
||||
|
||||
editableTextList(
|
||||
value = configuration.dns.fallbackFilter::domain,
|
||||
adapter = TextAdapter.String,
|
||||
title = R.string.domain_fallback,
|
||||
placeholder = R.string.dont_modify,
|
||||
configure = dnsDependencies::add,
|
||||
)
|
||||
|
||||
editableTextList(
|
||||
value = configuration.dns.fallbackFilter::ipcidr,
|
||||
adapter = TextAdapter.String,
|
||||
title = R.string.ipcidr_fallback,
|
||||
placeholder = R.string.dont_modify,
|
||||
configure = dnsDependencies::add,
|
||||
)
|
||||
|
||||
editableTextMap(
|
||||
value = configuration.dns::nameserverPolicy,
|
||||
keyAdapter = TextAdapter.String,
|
||||
valueAdapter = TextAdapter.String,
|
||||
title = R.string.name_server_policy,
|
||||
placeholder = R.string.dont_modify,
|
||||
configure = dnsDependencies::add,
|
||||
)
|
||||
|
||||
dns.listener?.onChanged()
|
||||
}
|
||||
|
||||
binding.content.addView(screen.root)
|
||||
}
|
||||
|
||||
fun requestClear() {
|
||||
requests.trySend(Request.ResetOverride)
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.lifecycle.LiveData
|
||||
|
||||
object PreferenceManager {
|
||||
private const val PREF_NAME = "app_preferences"
|
||||
|
||||
|
||||
private lateinit var prefs: SharedPreferences
|
||||
|
||||
fun init(context: Context) {
|
||||
prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
var selectnodeName: String
|
||||
get() = prefs.getString("selectnodeName", "") ?: "自动选择"
|
||||
set(value) {
|
||||
prefs.edit().putString("selectnodeName", value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class PreferenceLiveData(private val context: Context,) : LiveData<String>() {
|
||||
private var pref: SharedPreferences? = null
|
||||
private val key: String = "selectnodeName"
|
||||
private val defValue: String = "自动选择"
|
||||
private var listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == this.key) {
|
||||
value = pref?.getString(key, defValue)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
pref = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE)
|
||||
pref?.registerOnSharedPreferenceChangeListener(listener)
|
||||
value = pref?.getString(key, defValue)
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
pref?.unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.AnimationUtils
|
||||
import com.github.kr328.clash.design.adapter.ProfileAdapter
|
||||
import com.github.kr328.clash.design.databinding.DesignProfilesBinding
|
||||
import com.github.kr328.clash.design.databinding.DialogProfilesMenuBinding
|
||||
import com.github.kr328.clash.design.dialog.AppBottomSheetDialog
|
||||
import com.github.kr328.clash.design.ui.ToastDuration
|
||||
import com.github.kr328.clash.design.util.*
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ProfilesDesign(context: Context) : Design<ProfilesDesign.Request>(context) {
|
||||
sealed class Request {
|
||||
object UpdateAll : Request()
|
||||
object Create : Request()
|
||||
data class Active(val profile: Profile) : Request()
|
||||
data class Update(val profile: Profile) : Request()
|
||||
data class Edit(val profile: Profile) : Request()
|
||||
data class Duplicate(val profile: Profile) : Request()
|
||||
data class Delete(val profile: Profile) : Request()
|
||||
}
|
||||
|
||||
private val binding = DesignProfilesBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
private val adapter = ProfileAdapter(context, this::requestActive, this::showMenu)
|
||||
|
||||
private var allUpdating: Boolean
|
||||
get() = adapter.states.allUpdating;
|
||||
set(value) {
|
||||
adapter.states.allUpdating = value
|
||||
}
|
||||
private val rotateAnimation : Animation = AnimationUtils.loadAnimation(context, R.anim.rotate_infinite)
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
suspend fun patchProfiles(profiles: List<Profile>) {
|
||||
adapter.apply {
|
||||
patchDataSet(this::profiles, profiles, id = { it.uuid })
|
||||
}
|
||||
|
||||
val updatable = withContext(Dispatchers.Default) {
|
||||
profiles.any { it.imported && it.type != Profile.Type.File }
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.updateView.visibility = if (updatable) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestSave(profile: Profile) {
|
||||
showToast(R.string.active_unsaved_tips, ToastDuration.Long) {
|
||||
setAction(R.string.edit) {
|
||||
requests.trySend(Request.Edit(profile))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateElapsed() {
|
||||
adapter.updateElapsed()
|
||||
}
|
||||
|
||||
init {
|
||||
binding.self = this
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.mainList.recyclerList.also {
|
||||
it.bindAppBarElevation(binding.activityBarLayout)
|
||||
it.applyLinearAdapter(context, adapter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showMenu(profile: Profile) {
|
||||
val dialog = AppBottomSheetDialog(context)
|
||||
|
||||
val binding = DialogProfilesMenuBinding
|
||||
.inflate(context.layoutInflater, dialog.window?.decorView as ViewGroup?, false)
|
||||
|
||||
binding.master = this
|
||||
binding.self = dialog
|
||||
binding.profile = profile
|
||||
|
||||
dialog.setContentView(binding.root)
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
fun requestUpdateAll() {
|
||||
allUpdating = true;
|
||||
changeUpdateAllButtonStatus()
|
||||
requests.trySend(Request.UpdateAll)
|
||||
}
|
||||
|
||||
fun finishUpdateAll() {
|
||||
allUpdating = false;
|
||||
changeUpdateAllButtonStatus()
|
||||
}
|
||||
|
||||
fun requestCreate() {
|
||||
requests.trySend(Request.Create)
|
||||
}
|
||||
|
||||
private fun requestActive(profile: Profile) {
|
||||
requests.trySend(Request.Active(profile))
|
||||
}
|
||||
|
||||
fun requestUpdate(dialog: Dialog, profile: Profile) {
|
||||
requests.trySend(Request.Update(profile))
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
fun requestEdit(dialog: Dialog, profile: Profile) {
|
||||
requests.trySend(Request.Edit(profile))
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
fun requestDuplicate(dialog: Dialog, profile: Profile) {
|
||||
requests.trySend(Request.Duplicate(profile))
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
fun requestDelete(dialog: Dialog, profile: Profile) {
|
||||
requests.trySend(Request.Delete(profile))
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
private fun changeUpdateAllButtonStatus() {
|
||||
if (allUpdating) {
|
||||
binding.updateView.startAnimation(rotateAnimation)
|
||||
} else {
|
||||
binding.updateView.clearAnimation()
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.github.kr328.clash.core.model.FetchStatus
|
||||
import com.github.kr328.clash.design.databinding.DesignPropertiesBinding
|
||||
import com.github.kr328.clash.design.dialog.ModelProgressBarConfigure
|
||||
import com.github.kr328.clash.design.dialog.requestModelTextInput
|
||||
import com.github.kr328.clash.design.dialog.withModelProgressBar
|
||||
import com.github.kr328.clash.design.util.*
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class PropertiesDesign(context: Context) : Design<PropertiesDesign.Request>(context) {
|
||||
sealed class Request {
|
||||
object Commit : Request()
|
||||
object BrowseFiles : Request()
|
||||
}
|
||||
|
||||
private val binding = DesignPropertiesBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
var profile: Profile
|
||||
get() = binding.profile!!
|
||||
set(value) {
|
||||
binding.profile = value
|
||||
}
|
||||
|
||||
val progressing: Boolean
|
||||
get() = binding.processing
|
||||
|
||||
suspend fun withProcessing(executeTask: suspend (suspend (FetchStatus) -> Unit) -> Unit) {
|
||||
try {
|
||||
binding.processing = true
|
||||
|
||||
context.withModelProgressBar {
|
||||
configure {
|
||||
isIndeterminate = true
|
||||
text = context.getString(R.string.initializing)
|
||||
}
|
||||
|
||||
executeTask {
|
||||
configure {
|
||||
applyFrom(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
binding.processing = false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun requestExitWithoutSaving(): Boolean {
|
||||
return withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine { ctx ->
|
||||
val dialog = MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.exit_without_save)
|
||||
.setMessage(R.string.exit_without_save_warning)
|
||||
.setCancelable(true)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> ctx.resume(true) }
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.setOnDismissListener { if (!ctx.isCompleted) ctx.resume(false) }
|
||||
.show()
|
||||
|
||||
ctx.invokeOnCancellation { dialog.dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
binding.self = this
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.tips.text = context.getHtml(R.string.tips_properties)
|
||||
|
||||
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
|
||||
}
|
||||
|
||||
fun inputName() {
|
||||
launch {
|
||||
val name = context.requestModelTextInput(
|
||||
initial = profile.name,
|
||||
title = context.getText(R.string.name),
|
||||
hint = context.getText(R.string.properties),
|
||||
error = context.getText(R.string.should_not_be_blank),
|
||||
validator = ValidatorNotBlank
|
||||
)
|
||||
|
||||
if (name != profile.name) {
|
||||
profile = profile.copy(name = name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun inputUrl() {
|
||||
if (profile.type == Profile.Type.External)
|
||||
return
|
||||
|
||||
launch {
|
||||
val url = context.requestModelTextInput(
|
||||
initial = profile.source,
|
||||
title = context.getText(R.string.url),
|
||||
hint = context.getText(R.string.profile_url),
|
||||
error = context.getText(R.string.accept_http_content),
|
||||
validator = ValidatorHttpUrl
|
||||
)
|
||||
|
||||
if (url != profile.source) {
|
||||
profile = profile.copy(source = url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun inputInterval() {
|
||||
launch {
|
||||
var minutes = TimeUnit.MILLISECONDS.toMinutes(profile.interval)
|
||||
|
||||
minutes = context.requestModelTextInput(
|
||||
initial = if (minutes == 0L) "" else minutes.toString(),
|
||||
title = context.getText(R.string.auto_update),
|
||||
hint = context.getText(R.string.auto_update_minutes),
|
||||
error = context.getText(R.string.at_least_15_minutes),
|
||||
validator = ValidatorAutoUpdateInterval
|
||||
).toLongOrNull() ?: 0
|
||||
|
||||
val interval = TimeUnit.MINUTES.toMillis(minutes)
|
||||
|
||||
if (interval != profile.interval) {
|
||||
profile = profile.copy(interval = interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestCommit() {
|
||||
requests.trySend(Request.Commit)
|
||||
}
|
||||
|
||||
fun requestBrowseFiles() {
|
||||
requests.trySend(Request.BrowseFiles)
|
||||
}
|
||||
|
||||
private fun ModelProgressBarConfigure.applyFrom(status: FetchStatus) {
|
||||
when (status.action) {
|
||||
FetchStatus.Action.FetchConfiguration -> {
|
||||
text = context.getString(R.string.format_fetching_configuration, status.args[0])
|
||||
isIndeterminate = true
|
||||
}
|
||||
FetchStatus.Action.FetchProviders -> {
|
||||
text = context.getString(R.string.format_fetching_provider, status.args[0])
|
||||
isIndeterminate = false
|
||||
max = status.max
|
||||
progress = status.progress
|
||||
}
|
||||
FetchStatus.Action.Verifying -> {
|
||||
text = context.getString(R.string.verifying)
|
||||
isIndeterminate = false
|
||||
max = status.max
|
||||
progress = status.progress
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.github.kr328.clash.core.model.Provider
|
||||
import com.github.kr328.clash.design.adapter.ProviderAdapter
|
||||
import com.github.kr328.clash.design.databinding.DesignProvidersBinding
|
||||
import com.github.kr328.clash.design.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ProvidersDesign(
|
||||
context: Context,
|
||||
providers: List<Provider>,
|
||||
) : Design<ProvidersDesign.Request>(context) {
|
||||
sealed class Request {
|
||||
data class Update(val index: Int, val provider: Provider) : Request()
|
||||
}
|
||||
|
||||
private val binding = DesignProvidersBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
private val adapter = ProviderAdapter(context, providers) { index, provider ->
|
||||
requests.trySend(Request.Update(index, provider))
|
||||
}
|
||||
|
||||
fun updateElapsed() {
|
||||
adapter.updateElapsed()
|
||||
}
|
||||
|
||||
suspend fun notifyUpdated(index: Int) {
|
||||
withContext(Dispatchers.Main) {
|
||||
adapter.notifyUpdated(index)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun notifyChanged(index: Int) {
|
||||
withContext(Dispatchers.Main) {
|
||||
adapter.notifyChanged(index)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
binding.self = this
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.mainList.recyclerList.bindAppBarElevation(binding.activityBarLayout)
|
||||
binding.mainList.recyclerList.applyLinearAdapter(context, adapter)
|
||||
}
|
||||
|
||||
fun requestUpdateAll() {
|
||||
adapter.states.filter { !it.updating }.forEachIndexed { index, state ->
|
||||
state.updating = true
|
||||
|
||||
requests.trySend(Request.Update(index, state.provider))
|
||||
}
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.github.kr328.clash.core.model.Proxy
|
||||
import com.github.kr328.clash.core.model.TunnelState
|
||||
import com.github.kr328.clash.design.adapter.ProxyAdapter
|
||||
import com.github.kr328.clash.design.adapter.ProxyPageAdapter
|
||||
import com.github.kr328.clash.design.component.ProxyMenu
|
||||
import com.github.kr328.clash.design.component.ProxyViewConfig
|
||||
import com.github.kr328.clash.design.databinding.DesignProxyBinding
|
||||
import com.github.kr328.clash.design.model.ProxyState
|
||||
import com.github.kr328.clash.design.store.UiStore
|
||||
import com.github.kr328.clash.design.util.applyFrom
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import com.github.kr328.clash.design.util.resolveThemedColor
|
||||
import com.github.kr328.clash.design.util.root
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ProxyDesign(
|
||||
context: Context,
|
||||
overrideMode: TunnelState.Mode?,
|
||||
groupNames: List<String>,
|
||||
uiStore: UiStore,
|
||||
) : Design<ProxyDesign.Request>(context) {
|
||||
sealed class Request {
|
||||
object ReloadAll : Request()
|
||||
object ReLaunch : Request()
|
||||
|
||||
data class PatchMode(val mode: TunnelState.Mode?) : Request()
|
||||
data class Reload(val index: Int) : Request()
|
||||
data class Select(val index: Int, val name: String) : Request()
|
||||
data class UrlTest(val index: Int) : Request()
|
||||
}
|
||||
|
||||
private val binding = DesignProxyBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
|
||||
private var config = ProxyViewConfig(context, uiStore.proxyLine)
|
||||
|
||||
private val menu: ProxyMenu by lazy {
|
||||
ProxyMenu(context, binding.menuView, overrideMode, uiStore, requests) {
|
||||
config.proxyLine = uiStore.proxyLine
|
||||
}
|
||||
}
|
||||
|
||||
private val adapter: ProxyPageAdapter
|
||||
get() = binding.pagesView.adapter!! as ProxyPageAdapter
|
||||
|
||||
private var horizontalScrolling = false
|
||||
private val verticalBottomScrolled: Boolean
|
||||
get() = adapter.states[binding.pagesView.currentItem].bottom
|
||||
private var urlTesting: Boolean
|
||||
get() = adapter.states[binding.pagesView.currentItem].urlTesting
|
||||
set(value) {
|
||||
adapter.states[binding.pagesView.currentItem].urlTesting = value
|
||||
}
|
||||
|
||||
override val root: View = binding.root
|
||||
|
||||
suspend fun updateGroup(
|
||||
position: Int,
|
||||
proxies: List<Proxy>,
|
||||
selectable: Boolean,
|
||||
parent: ProxyState,
|
||||
links: Map<String, ProxyState>
|
||||
) {
|
||||
adapter.updateAdapter(position, proxies, selectable, parent, links)
|
||||
|
||||
adapter.states[position].urlTesting = false
|
||||
|
||||
updateUrlTestButtonStatus()
|
||||
}
|
||||
|
||||
suspend fun requestRedrawVisible() {
|
||||
withContext(Dispatchers.Main) {
|
||||
adapter.requestRedrawVisible()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showModeSwitchTips() {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, R.string.mode_switch_tips, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
binding.self = this
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.menuView.setOnClickListener {
|
||||
menu.show()
|
||||
}
|
||||
|
||||
if (groupNames.isEmpty() ) {
|
||||
binding.emptyView.visibility = View.VISIBLE
|
||||
|
||||
binding.urlTestView.visibility = View.GONE
|
||||
binding.tabLayoutView.visibility = View.GONE
|
||||
binding.elevationView.visibility = View.GONE
|
||||
binding.pagesView.visibility = View.GONE
|
||||
binding.urlTestFloatView.visibility = View.GONE
|
||||
} else {
|
||||
binding.urlTestFloatView.supportImageTintList = ColorStateList.valueOf(
|
||||
context.resolveThemedColor(R.attr.colorOnPrimary)
|
||||
)
|
||||
|
||||
binding.pagesView.apply {
|
||||
adapter = ProxyPageAdapter(
|
||||
surface,
|
||||
config,
|
||||
List(groupNames.size) { index ->
|
||||
ProxyAdapter(config) { name ->
|
||||
requests.trySend(Request.Select(index, name))
|
||||
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
||||
if (it == currentItem)
|
||||
updateUrlTestButtonStatus()
|
||||
}
|
||||
|
||||
registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageScrollStateChanged(state: Int) {
|
||||
horizontalScrolling = state != ViewPager2.SCROLL_STATE_IDLE
|
||||
|
||||
updateUrlTestButtonStatus()
|
||||
}
|
||||
|
||||
override fun onPageSelected(position: Int) {
|
||||
uiStore.proxyLastGroup = groupNames[position]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
TabLayoutMediator(binding.tabLayoutView, binding.pagesView) { tab, index ->
|
||||
tab.text = groupNames[index]
|
||||
}.attach()
|
||||
|
||||
val initialPosition = groupNames.indexOf(uiStore.proxyLastGroup)
|
||||
|
||||
binding.pagesView.post {
|
||||
if (initialPosition > 0)
|
||||
binding.pagesView.setCurrentItem(initialPosition, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestUrlTesting() {
|
||||
urlTesting = true
|
||||
|
||||
requests.trySend(Request.UrlTest(binding.pagesView.currentItem))
|
||||
|
||||
updateUrlTestButtonStatus()
|
||||
}
|
||||
|
||||
private fun updateUrlTestButtonStatus() {
|
||||
if (verticalBottomScrolled || horizontalScrolling || urlTesting) {
|
||||
binding.urlTestFloatView.hide()
|
||||
} else {
|
||||
binding.urlTestFloatView.show()
|
||||
}
|
||||
|
||||
if (urlTesting) {
|
||||
binding.urlTestView.visibility = View.GONE
|
||||
binding.urlTestProgressView.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.urlTestView.visibility = View.VISIBLE
|
||||
binding.urlTestProgressView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.github.kr328.clash.design
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.github.kr328.clash.design.databinding.DesignSettingsBinding
|
||||
import com.github.kr328.clash.design.util.applyFrom
|
||||
import com.github.kr328.clash.design.util.bindAppBarElevation
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import com.github.kr328.clash.design.util.root
|
||||
|
||||
class SettingsDesign(context: Context) : Design<SettingsDesign.Request>(context) {
|
||||
enum class Request {
|
||||
StartApp, StartNetwork, StartOverride, StartMetaFeature,
|
||||
}
|
||||
|
||||
private val binding = DesignSettingsBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
|
||||
override val root: View
|
||||
get() = binding.root
|
||||
|
||||
init {
|
||||
binding.self = this
|
||||
|
||||
binding.activityBarLayout.applyFrom(context)
|
||||
|
||||
binding.scrollRoot.bindAppBarElevation(binding.activityBarLayout)
|
||||
}
|
||||
|
||||
fun request(request: Request) {
|
||||
requests.trySend(request)
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.github.kr328.clash.design.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.design.databinding.AdapterAppBinding
|
||||
import com.github.kr328.clash.design.model.AppInfo
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import com.github.kr328.clash.design.util.root
|
||||
|
||||
class AppAdapter(
|
||||
private val context: Context,
|
||||
private val selected: MutableSet<String>,
|
||||
) : RecyclerView.Adapter<AppAdapter.Holder>() {
|
||||
class Holder(val binding: AdapterAppBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
var apps: List<AppInfo> = emptyList()
|
||||
|
||||
fun rebindAll() {
|
||||
notifyItemRangeChanged(0, itemCount)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
return Holder(
|
||||
AdapterAppBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val current = apps[position]
|
||||
|
||||
holder.binding.app = current
|
||||
holder.binding.selected = current.packageName in selected
|
||||
holder.binding.root.setOnClickListener {
|
||||
if (holder.binding.selected) {
|
||||
selected.remove(current.packageName)
|
||||
holder.binding.selected = false
|
||||
} else {
|
||||
selected.add(current.packageName)
|
||||
holder.binding.selected = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return apps.size
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.github.kr328.clash.design.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.design.databinding.AdapterEditableTextListBinding
|
||||
import com.github.kr328.clash.design.preference.TextAdapter
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
|
||||
class EditableTextListAdapter<T>(
|
||||
private val context: Context,
|
||||
val values: MutableList<T>,
|
||||
private val adapter: TextAdapter<T>,
|
||||
) : RecyclerView.Adapter<EditableTextListAdapter.Holder>() {
|
||||
class Holder(val binding: AdapterEditableTextListBinding) :
|
||||
RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
fun addElement(text: String) {
|
||||
val value = adapter.to(text)
|
||||
|
||||
notifyItemInserted(values.size)
|
||||
values.add(value)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
return Holder(
|
||||
AdapterEditableTextListBinding
|
||||
.inflate(context.layoutInflater, parent, false)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val current = values[position]
|
||||
|
||||
holder.binding.textView.text = adapter.from(current)
|
||||
holder.binding.deleteView.setOnClickListener {
|
||||
val index = values.indexOf(current)
|
||||
|
||||
if (index >= 0) {
|
||||
values.removeAt(index)
|
||||
notifyItemRemoved(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return values.size
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.github.kr328.clash.design.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.design.databinding.AdapterEditableTextMapBinding
|
||||
import com.github.kr328.clash.design.preference.TextAdapter
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
|
||||
class EditableTextMapAdapter<K, V>(
|
||||
private val context: Context,
|
||||
val values: MutableList<Pair<K, V>>,
|
||||
private val keyAdapter: TextAdapter<K>,
|
||||
private val valueAdapter: TextAdapter<V>,
|
||||
) : RecyclerView.Adapter<EditableTextMapAdapter.Holder>() {
|
||||
class Holder(val binding: AdapterEditableTextMapBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
fun addElement(key: String, value: String) {
|
||||
val keyValue = keyAdapter.to(key)
|
||||
val valueValue = valueAdapter.to(value)
|
||||
|
||||
notifyItemInserted(values.size)
|
||||
values.add(keyValue to valueValue)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
return Holder(
|
||||
AdapterEditableTextMapBinding
|
||||
.inflate(context.layoutInflater, parent, false)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val current = values[position]
|
||||
|
||||
holder.binding.keyView.text = keyAdapter.from(current.first)
|
||||
holder.binding.valueView.text = valueAdapter.from(current.second)
|
||||
holder.binding.deleteView.setOnClickListener {
|
||||
val index = values.indexOf(current)
|
||||
|
||||
if (index >= 0) {
|
||||
values.removeAt(index)
|
||||
notifyItemRemoved(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return values.size
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.github.kr328.clash.design.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.design.databinding.AdapterFileBinding
|
||||
import com.github.kr328.clash.design.model.File
|
||||
import com.github.kr328.clash.design.ui.ObservableCurrentTime
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
|
||||
class FileAdapter(
|
||||
private val context: Context,
|
||||
private val open: (File) -> Unit,
|
||||
private val more: (File) -> Unit,
|
||||
) : RecyclerView.Adapter<FileAdapter.Holder>() {
|
||||
class Holder(val binding: AdapterFileBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
private val currentTime = ObservableCurrentTime()
|
||||
|
||||
var files: List<File> = emptyList()
|
||||
|
||||
fun updateElapsed() {
|
||||
currentTime.update()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
return Holder(
|
||||
AdapterFileBinding
|
||||
.inflate(context.layoutInflater, parent, false)
|
||||
.also { it.currentTime = currentTime }
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val current = files[position]
|
||||
|
||||
holder.binding.apply {
|
||||
file = current
|
||||
|
||||
setOpen {
|
||||
open(current)
|
||||
}
|
||||
|
||||
setMore {
|
||||
more(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return files.size
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.github.kr328.clash.design.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.design.model.LogFile
|
||||
import com.github.kr328.clash.design.util.format
|
||||
import com.github.kr328.clash.design.view.ActionLabel
|
||||
|
||||
class LogFileAdapter(
|
||||
private val context: Context,
|
||||
private val open: (LogFile) -> Unit,
|
||||
) : RecyclerView.Adapter<LogFileAdapter.Holder>() {
|
||||
class Holder(val label: ActionLabel) : RecyclerView.ViewHolder(label)
|
||||
|
||||
var logs: List<LogFile> = emptyList()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
return Holder(ActionLabel(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
})
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val current = logs[position]
|
||||
|
||||
holder.label.text = current.fileName
|
||||
holder.label.subtext = current.date.format(context)
|
||||
holder.label.setOnClickListener {
|
||||
open(current)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return logs.size
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.github.kr328.clash.design.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.core.model.LogMessage
|
||||
import com.github.kr328.clash.design.databinding.AdapterLogMessageBinding
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
|
||||
class LogMessageAdapter(
|
||||
private val context: Context,
|
||||
private val copy: (LogMessage) -> Unit,
|
||||
) :
|
||||
RecyclerView.Adapter<LogMessageAdapter.Holder>() {
|
||||
class Holder(val binding: AdapterLogMessageBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
var messages: List<LogMessage> = emptyList()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
return Holder(
|
||||
AdapterLogMessageBinding
|
||||
.inflate(context.layoutInflater, parent, false)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val current = messages[position]
|
||||
|
||||
holder.binding.message = current
|
||||
holder.binding.root.setOnLongClickListener {
|
||||
copy(current)
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return messages.size
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.github.kr328.clash.design.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.TextView
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import com.github.kr328.clash.design.util.resolveThemedColor
|
||||
|
||||
class PopupListAdapter(
|
||||
private val context: Context,
|
||||
private val texts: List<CharSequence>,
|
||||
private val selected: Int,
|
||||
) : BaseAdapter() {
|
||||
private val colorPrimary = context.resolveThemedColor(R.attr.colorPrimary)
|
||||
private val colorOnPrimary = context.resolveThemedColor(R.attr.colorOnPrimary)
|
||||
private val colorControlNormal = context.resolveThemedColor(R.attr.colorControlNormal)
|
||||
|
||||
override fun getCount(): Int {
|
||||
return texts.size
|
||||
}
|
||||
|
||||
override fun getItem(position: Int): Any {
|
||||
return texts[position]
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return texts[position].hashCode().toLong()
|
||||
}
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
|
||||
val view = convertView ?: context.layoutInflater
|
||||
.inflate(android.R.layout.simple_list_item_1, parent, false)
|
||||
|
||||
val text: TextView = view.findViewById(android.R.id.text1)
|
||||
|
||||
text.text = texts[position]
|
||||
|
||||
if (position == selected) {
|
||||
text.setBackgroundColor(
|
||||
Color.argb(
|
||||
200,
|
||||
Color.red(colorPrimary),
|
||||
Color.green(colorPrimary),
|
||||
Color.blue(colorPrimary)
|
||||
)
|
||||
)
|
||||
text.setTextColor(colorOnPrimary)
|
||||
} else {
|
||||
text.setBackgroundColor(Color.TRANSPARENT)
|
||||
text.setTextColor(colorControlNormal)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.github.kr328.clash.design.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.design.databinding.AdapterProfileBinding
|
||||
import com.github.kr328.clash.design.model.ProfilePageState
|
||||
import com.github.kr328.clash.design.model.ProxyPageState
|
||||
import com.github.kr328.clash.design.ui.ObservableCurrentTime
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
|
||||
class ProfileAdapter(
|
||||
private val context: Context,
|
||||
private val onClicked: (Profile) -> Unit,
|
||||
private val onMenuClicked: (Profile) -> Unit,
|
||||
) : RecyclerView.Adapter<ProfileAdapter.Holder>() {
|
||||
class Holder(val binding: AdapterProfileBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
private val currentTime = ObservableCurrentTime()
|
||||
|
||||
var profiles: List<Profile> = emptyList()
|
||||
val states = ProfilePageState()
|
||||
|
||||
fun updateElapsed() {
|
||||
currentTime.update()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
return Holder(
|
||||
AdapterProfileBinding
|
||||
.inflate(context.layoutInflater, parent, false)
|
||||
.also { it.currentTime = currentTime }
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val current = profiles[position]
|
||||
val binding = holder.binding
|
||||
|
||||
if (current === binding.profile)
|
||||
return
|
||||
|
||||
binding.profile = current
|
||||
binding.setClicked {
|
||||
onClicked(current)
|
||||
}
|
||||
binding.setMenu {
|
||||
onMenuClicked(current)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return profiles.size
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.github.kr328.clash.design.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.design.databinding.AdapterProfileProviderBinding
|
||||
import com.github.kr328.clash.design.model.ProfileProvider
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
|
||||
class ProfileProviderAdapter(
|
||||
private val context: Context,
|
||||
private val select: (ProfileProvider) -> Unit,
|
||||
private val detail: (ProfileProvider) -> Boolean,
|
||||
) : RecyclerView.Adapter<ProfileProviderAdapter.Holder>() {
|
||||
class Holder(val binding: AdapterProfileProviderBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
var providers: List<ProfileProvider> = emptyList()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
return Holder(
|
||||
AdapterProfileProviderBinding.inflate(
|
||||
context.layoutInflater,
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val current = providers[position]
|
||||
val binding = holder.binding
|
||||
|
||||
binding.provider = current
|
||||
|
||||
binding.root.apply {
|
||||
setOnClickListener {
|
||||
select(current)
|
||||
}
|
||||
setOnLongClickListener {
|
||||
detail(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return providers.size
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.github.kr328.clash.design.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.core.model.Provider
|
||||
import com.github.kr328.clash.design.databinding.AdapterProviderBinding
|
||||
import com.github.kr328.clash.design.model.ProviderState
|
||||
import com.github.kr328.clash.design.ui.ObservableCurrentTime
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
|
||||
class ProviderAdapter(
|
||||
private val context: Context,
|
||||
providers: List<Provider>,
|
||||
private val requestUpdate: (Int, Provider) -> Unit,
|
||||
) : RecyclerView.Adapter<ProviderAdapter.Holder>() {
|
||||
class Holder(val binding: AdapterProviderBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
private val currentTime = ObservableCurrentTime()
|
||||
|
||||
val states = providers.map { ProviderState(it, it.updatedAt, false) }
|
||||
|
||||
fun updateElapsed() {
|
||||
currentTime.update()
|
||||
}
|
||||
|
||||
fun notifyUpdated(index: Int) {
|
||||
states[index].apply {
|
||||
updating = false
|
||||
}
|
||||
|
||||
notifyItemChanged(index)
|
||||
}
|
||||
|
||||
fun notifyChanged(index: Int) {
|
||||
states[index].apply {
|
||||
updating = false
|
||||
updatedAt = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
notifyItemChanged(index)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
return Holder(
|
||||
AdapterProviderBinding
|
||||
.inflate(context.layoutInflater, parent, false)
|
||||
.also { it.currentTime = currentTime }
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val state = states[position]
|
||||
|
||||
holder.binding.provider = state.provider
|
||||
holder.binding.state = state
|
||||
holder.binding.update = View.OnClickListener {
|
||||
state.updating = true
|
||||
|
||||
requestUpdate(position, state.provider)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return states.size
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package com.github.kr328.clash.design.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.util.Log
|
||||
import com.github.kr328.clash.design.component.ProxyView
|
||||
import com.github.kr328.clash.design.component.ProxyViewConfig
|
||||
/*
|
||||
class ProxyAdapter(
|
||||
private val config: ProxyViewConfig,
|
||||
private val clicked: (String) -> Unit,
|
||||
) : RecyclerView.Adapter<ProxyAdapter.Holder>() {
|
||||
class Holder(val view: ProxyView) : RecyclerView.ViewHolder(view)
|
||||
|
||||
var selectable: Boolean = false
|
||||
var states: List<ProxyViewState> = emptyList()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
|
||||
return Holder(ProxyView(config.context, config))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val current = states[position]
|
||||
|
||||
holder.view.apply {
|
||||
state = current
|
||||
|
||||
setOnClickListener {
|
||||
clicked(current.proxy.name)
|
||||
}
|
||||
|
||||
val isSelector = selectable
|
||||
|
||||
isFocusable = isSelector
|
||||
isClickable = isSelector
|
||||
|
||||
current.update(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return states.size
|
||||
}
|
||||
}
|
||||
*/
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.component.ProxyViewState
|
||||
import com.github.kr328.clash.design.databinding.DesignProxyBinding
|
||||
|
||||
class ProxyAdapter(
|
||||
private val config: ProxyViewConfig,
|
||||
private val clicked: (String) -> Unit,
|
||||
) : RecyclerView.Adapter<ProxyAdapter.Holder>() {
|
||||
|
||||
// class Holder(val binding: DesignProxyBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
class Holder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val vpnnodemainid:View = view.findViewById(R.id.vpnnodemainid)
|
||||
val proxyName: TextView = view.findViewById(R.id.proxy_name)
|
||||
val proxyLatency: TextView = view.findViewById(R.id.proxy_latency)
|
||||
val proxySubName:TextView = view.findViewById(R.id.proxy_subtitle)
|
||||
val selectnodeselectImage:ImageView = view.findViewById(R.id.nodeselectImage)
|
||||
|
||||
|
||||
}
|
||||
|
||||
var selectable: Boolean = false
|
||||
var states: List<ProxyViewState> = emptyList()
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProxyAdapter.Holder {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.proxy_view_item, parent, false)
|
||||
|
||||
return Holder(view)
|
||||
}
|
||||
|
||||
var selectableView: Holder? = null
|
||||
var selectableData: ProxyViewState? = null
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) {
|
||||
val current = states[position]
|
||||
holder.apply {
|
||||
|
||||
proxyName.text = current.proxy.name
|
||||
proxySubName.text = current.proxy.subtitle
|
||||
|
||||
if (current.proxy.delay > 10000){
|
||||
proxyLatency.text = "超时"
|
||||
proxyLatency.setTextColor(Color.parseColor("#e73f31"))
|
||||
}else if (current.proxy.delay > 500 && current.proxy.delay < 10000){
|
||||
proxyLatency.text = "${current.proxy.delay}ms"
|
||||
proxyLatency.setTextColor(Color.YELLOW)
|
||||
}else if (current.proxy.delay < 500 && current.proxy.delay > 300){
|
||||
proxyLatency.text = "${current.proxy.delay}ms"
|
||||
proxyLatency.setTextColor(Color.parseColor("#fab610"))
|
||||
}else{
|
||||
proxyLatency.text = "${current.proxy.delay}ms"
|
||||
proxyLatency.setTextColor(Color.parseColor("#2a9843"))
|
||||
}
|
||||
// 示例:延迟时间
|
||||
|
||||
vpnnodemainid.setOnClickListener {
|
||||
|
||||
if (selectableView != null) {
|
||||
if(selectableView == holder){
|
||||
//do nothing
|
||||
|
||||
}else{
|
||||
//之前的设置为未选择
|
||||
selectableData?.selected = false
|
||||
selectableView?.selectnodeselectImage?.setImageResource( android.R.color.transparent)
|
||||
|
||||
current.selected = true
|
||||
|
||||
selectableView = holder
|
||||
selectableData = current
|
||||
clicked(current.proxy.name)
|
||||
current.update(true)
|
||||
selectnodeselectImage.setImageResource((R.drawable.checkmarkcirclefill))
|
||||
|
||||
|
||||
}
|
||||
}else
|
||||
{
|
||||
|
||||
selectableView?.selectnodeselectImage?.setImageResource( android.R.color.transparent)
|
||||
|
||||
current.selected = true
|
||||
|
||||
selectableView = holder
|
||||
selectableData = current
|
||||
clicked(current.proxy.name)
|
||||
current.update(true)
|
||||
selectnodeselectImage.setImageResource((R.drawable.checkmarkcirclefill))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
current.update(true)
|
||||
|
||||
if (current.selected){
|
||||
selectableView = holder
|
||||
selectableData = current
|
||||
}
|
||||
|
||||
holder.selectnodeselectImage.setImageResource(if (current.selected) R.drawable.checkmarkcirclefill else android.R.color.transparent)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return states.size
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package com.github.kr328.clash.design.adapter
|
||||
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.core.model.Proxy
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.component.ProxyPageFactory
|
||||
import com.github.kr328.clash.design.component.ProxyViewConfig
|
||||
import com.github.kr328.clash.design.component.ProxyViewState
|
||||
import com.github.kr328.clash.design.model.ProxyPageState
|
||||
import com.github.kr328.clash.design.model.ProxyState
|
||||
import com.github.kr328.clash.design.ui.Surface
|
||||
import com.github.kr328.clash.design.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ProxyPageAdapter(
|
||||
private val surface: Surface,
|
||||
private val config: ProxyViewConfig,
|
||||
private val adapters: List<ProxyAdapter>,
|
||||
private val stateChanged: (Int) -> Unit,
|
||||
) : RecyclerView.Adapter<ProxyPageFactory.Holder>() { //<ProxyPageAdapter.VPNViewHolder>(){ //
|
||||
private val factory = ProxyPageFactory(config)
|
||||
private var parent: RecyclerView? = null
|
||||
|
||||
val states = List(adapters.size) { ProxyPageState() }
|
||||
|
||||
suspend fun updateAdapter(
|
||||
position: Int,
|
||||
proxies: List<Proxy>,
|
||||
selectable: Boolean,
|
||||
parent: ProxyState,
|
||||
links: Map<String, ProxyState>
|
||||
) {
|
||||
val states = withContext(Dispatchers.Default) {
|
||||
proxies.map {
|
||||
val link = if (it.type.group) links[it.name] else null
|
||||
|
||||
ProxyViewState(config, it, parent, link)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
adapters[position].apply {
|
||||
this.selectable = selectable
|
||||
this.swapDataSet(this::states, states, false)
|
||||
}
|
||||
|
||||
requestRedrawVisible()
|
||||
}
|
||||
}
|
||||
|
||||
fun requestRedrawVisible() {
|
||||
factory.fromRoot(parent?.firstVisibleView ?: return)
|
||||
.recyclerView.invalidateChildren()
|
||||
}
|
||||
/*
|
||||
// ViewHolder class to hold and recycle views for RecyclerView items
|
||||
inner class VPNViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val flagImageView: ImageView = itemView.findViewById(R.id.flagImageView)
|
||||
val vpnInfoTextView: TextView = itemView.findViewById(R.id.vpnInfoTextView)
|
||||
val speedTextView: TextView = itemView.findViewById(R.id.speedTextView)
|
||||
}
|
||||
|
||||
// This function inflates the item layout and returns the ViewHolder
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VPNViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_vpn, parent, false)
|
||||
return VPNViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VPNViewHolder, position: Int) {
|
||||
val adapter = adapters[position]
|
||||
|
||||
states[position].bottom = false
|
||||
|
||||
Log.i("onBindViewHolder"," ${ adapter.states.size}")
|
||||
|
||||
// holder.flagImageView.setImageResource(vpn.countryFlag)
|
||||
holder.vpnInfoTextView.text = "xxx"
|
||||
holder.speedTextView.text = "xx"
|
||||
}
|
||||
*/
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProxyPageFactory.Holder {
|
||||
val holder = factory.newInstance()
|
||||
|
||||
val toolbarHeight = config.context.getPixels(R.dimen.toolbar_height)
|
||||
val tabHeight = config.context.getPixels(R.dimen.tab_layout_height)
|
||||
|
||||
holder.recyclerView.bindInsets(surface, toolbarHeight + tabHeight)
|
||||
holder.recyclerView.addScrolledToBottomObserver { view, bottom ->
|
||||
val position = view.position
|
||||
val state = states[position]
|
||||
|
||||
if (state.bottom != bottom) {
|
||||
state.bottom = bottom
|
||||
|
||||
stateChanged(position)
|
||||
}
|
||||
}
|
||||
|
||||
return holder
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ProxyPageFactory.Holder, position: Int) {
|
||||
val adapter = adapters[position]
|
||||
|
||||
states[position].bottom = false
|
||||
|
||||
holder.recyclerView.apply {
|
||||
this.position = position
|
||||
this.swapAdapter(adapter, false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return adapters.size
|
||||
}
|
||||
|
||||
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
||||
this.parent = recyclerView
|
||||
|
||||
recyclerView.isFocusable = false
|
||||
}
|
||||
|
||||
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
|
||||
this.parent = null
|
||||
}
|
||||
|
||||
|
||||
private var RecyclerView.position: Int
|
||||
get() {
|
||||
return tag as? Int ?: -1
|
||||
}
|
||||
set(value) {
|
||||
tag = value
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package com.github.kr328.clash.design.component
|
||||
|
||||
import android.content.Context
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import com.github.kr328.clash.design.AccessControlDesign.Request
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.model.AppInfoSort
|
||||
import com.github.kr328.clash.design.store.UiStore
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
|
||||
class AccessControlMenu(
|
||||
context: Context,
|
||||
menuView: View,
|
||||
private val uiStore: UiStore,
|
||||
private val requests: Channel<Request>,
|
||||
) : PopupMenu.OnMenuItemClickListener {
|
||||
private val menu = PopupMenu(context, menuView)
|
||||
|
||||
fun show() {
|
||||
menu.show()
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
if (item.isCheckable)
|
||||
item.isChecked = !item.isChecked
|
||||
|
||||
when (item.itemId) {
|
||||
R.id.select_all ->
|
||||
requests.trySend(Request.SelectAll)
|
||||
R.id.select_none ->
|
||||
requests.trySend(Request.SelectNone)
|
||||
R.id.select_invert ->
|
||||
requests.trySend(Request.SelectInvert)
|
||||
R.id.system_apps -> {
|
||||
uiStore.accessControlSystemApp = !item.isChecked
|
||||
|
||||
requests.trySend(Request.ReloadApps)
|
||||
}
|
||||
R.id.name -> {
|
||||
uiStore.accessControlSort = AppInfoSort.Label
|
||||
|
||||
requests.trySend(Request.ReloadApps)
|
||||
}
|
||||
R.id.package_name -> {
|
||||
uiStore.accessControlSort = AppInfoSort.PackageName
|
||||
|
||||
requests.trySend(Request.ReloadApps)
|
||||
}
|
||||
R.id.install_time -> {
|
||||
uiStore.accessControlSort = AppInfoSort.InstallTime
|
||||
|
||||
requests.trySend(Request.ReloadApps)
|
||||
}
|
||||
R.id.update_time -> {
|
||||
uiStore.accessControlSort = AppInfoSort.UpdateTime
|
||||
|
||||
requests.trySend(Request.ReloadApps)
|
||||
}
|
||||
R.id.reverse -> {
|
||||
uiStore.accessControlReverse = item.isChecked
|
||||
|
||||
requests.trySend(Request.ReloadApps)
|
||||
}
|
||||
R.id.import_from_clipboard -> {
|
||||
requests.trySend(Request.Import)
|
||||
}
|
||||
R.id.export_to_clipboard -> {
|
||||
requests.trySend(Request.Export)
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
init {
|
||||
menu.menuInflater.inflate(R.menu.menu_access_control, menu.menu)
|
||||
|
||||
when (uiStore.accessControlSort) {
|
||||
AppInfoSort.Label ->
|
||||
menu.menu.findItem(R.id.name).isChecked = true
|
||||
AppInfoSort.PackageName ->
|
||||
menu.menu.findItem(R.id.package_name).isChecked = true
|
||||
AppInfoSort.InstallTime ->
|
||||
menu.menu.findItem(R.id.install_time).isChecked = true
|
||||
AppInfoSort.UpdateTime ->
|
||||
menu.menu.findItem(R.id.update_time).isChecked = true
|
||||
}
|
||||
|
||||
menu.menu.findItem(R.id.system_apps).isChecked = !uiStore.accessControlSystemApp
|
||||
menu.menu.findItem(R.id.reverse).isChecked = uiStore.accessControlReverse
|
||||
|
||||
menu.setOnMenuItemClickListener(this)
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.github.kr328.clash.design.component
|
||||
|
||||
import android.content.Context
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import com.github.kr328.clash.core.model.ProxySort
|
||||
import com.github.kr328.clash.core.model.TunnelState
|
||||
import com.github.kr328.clash.design.ProxyDesign
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.store.UiStore
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
|
||||
class ProxyMenu(
|
||||
context: Context,
|
||||
menuView: View,
|
||||
mode: TunnelState.Mode?,
|
||||
private val uiStore: UiStore,
|
||||
private val requests: Channel<ProxyDesign.Request>,
|
||||
private val updateConfig: () -> Unit,
|
||||
) : PopupMenu.OnMenuItemClickListener {
|
||||
private val menu = PopupMenu(context, menuView)
|
||||
|
||||
fun show() {
|
||||
menu.show()
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
item.isChecked = !item.isChecked
|
||||
|
||||
when (item.itemId) {
|
||||
R.id.not_selectable -> {
|
||||
uiStore.proxyExcludeNotSelectable = item.isChecked
|
||||
|
||||
requests.trySend(ProxyDesign.Request.ReLaunch)
|
||||
}
|
||||
R.id.single -> {
|
||||
uiStore.proxyLine = 1
|
||||
|
||||
updateConfig()
|
||||
|
||||
requests.trySend(ProxyDesign.Request.ReloadAll)
|
||||
}
|
||||
R.id.doubles -> {
|
||||
uiStore.proxyLine = 1
|
||||
|
||||
updateConfig()
|
||||
|
||||
requests.trySend(ProxyDesign.Request.ReloadAll)
|
||||
}
|
||||
R.id.multiple -> {
|
||||
uiStore.proxyLine = 3
|
||||
|
||||
updateConfig()
|
||||
|
||||
requests.trySend(ProxyDesign.Request.ReloadAll)
|
||||
}
|
||||
R.id.default_ -> {
|
||||
uiStore.proxySort = ProxySort.Default
|
||||
|
||||
requests.trySend(ProxyDesign.Request.ReloadAll)
|
||||
}
|
||||
R.id.name -> {
|
||||
uiStore.proxySort = ProxySort.Title
|
||||
|
||||
requests.trySend(ProxyDesign.Request.ReloadAll)
|
||||
}
|
||||
R.id.delay -> {
|
||||
uiStore.proxySort = ProxySort.Delay
|
||||
|
||||
requests.trySend(ProxyDesign.Request.ReloadAll)
|
||||
}
|
||||
R.id.dont_modify -> {
|
||||
requests.trySend(ProxyDesign.Request.PatchMode(null))
|
||||
}
|
||||
R.id.direct_mode -> {
|
||||
requests.trySend(ProxyDesign.Request.PatchMode(TunnelState.Mode.Direct))
|
||||
}
|
||||
R.id.global_mode -> {
|
||||
requests.trySend(ProxyDesign.Request.PatchMode(TunnelState.Mode.Global))
|
||||
}
|
||||
R.id.rule_mode -> {
|
||||
requests.trySend(ProxyDesign.Request.PatchMode(TunnelState.Mode.Rule))
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
init {
|
||||
menu.menuInflater.inflate(R.menu.menu_proxy, menu.menu)
|
||||
|
||||
menu.menu.apply {
|
||||
findItem(R.id.not_selectable).isChecked = uiStore.proxyExcludeNotSelectable
|
||||
|
||||
when (uiStore.proxyLine){
|
||||
1 -> findItem(R.id.single).isChecked = true
|
||||
2 -> findItem(R.id.doubles).isChecked = true
|
||||
3 -> findItem(R.id.multiple).isChecked = true
|
||||
}
|
||||
|
||||
when (uiStore.proxySort) {
|
||||
ProxySort.Default -> findItem(R.id.default_).isChecked = true
|
||||
ProxySort.Title -> findItem(R.id.name).isChecked = true
|
||||
ProxySort.Delay -> findItem(R.id.delay).isChecked = true
|
||||
}
|
||||
|
||||
when (mode) {
|
||||
null -> findItem(R.id.dont_modify).isChecked = true
|
||||
TunnelState.Mode.Direct -> findItem(R.id.direct_mode).isChecked = true
|
||||
TunnelState.Mode.Global -> findItem(R.id.global_mode).isChecked = true
|
||||
TunnelState.Mode.Rule -> findItem(R.id.rule_mode).isChecked = true
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
menu.setOnMenuItemClickListener(this)
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.github.kr328.clash.design.component
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.design.view.VerticalScrollableHost
|
||||
|
||||
class ProxyPageFactory(private val config: ProxyViewConfig) {
|
||||
class Holder(
|
||||
val recyclerView: RecyclerView,
|
||||
val root: View,
|
||||
) : RecyclerView.ViewHolder(root)
|
||||
|
||||
private val childrenPool = RecyclerView.RecycledViewPool()
|
||||
|
||||
fun newInstance(): Holder {
|
||||
val root = VerticalScrollableHost(config.context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
|
||||
val recyclerView = RecyclerView(config.context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
|
||||
root.addView(recyclerView)
|
||||
|
||||
recyclerView.apply {
|
||||
layoutManager = GridLayoutManager(config.context, 6).apply {
|
||||
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
|
||||
override fun getSpanSize(position: Int): Int {
|
||||
var grids:Int = 0
|
||||
when(config.proxyLine){
|
||||
2 -> grids = 3
|
||||
3 -> grids = 2
|
||||
}
|
||||
return if (config.proxyLine==1) 6 else grids
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setRecycledViewPool(childrenPool)
|
||||
|
||||
clipToPadding = false
|
||||
}
|
||||
|
||||
return Holder(recyclerView, root).apply {
|
||||
root.tag = this
|
||||
}
|
||||
}
|
||||
|
||||
fun fromRoot(root: View): Holder {
|
||||
return root.tag!! as Holder
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
package com.github.kr328.clash.design.component
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.view.View
|
||||
import com.github.kr328.clash.common.compat.getDrawableCompat
|
||||
import com.github.kr328.clash.design.store.UiStore
|
||||
|
||||
class ProxyView(
|
||||
context: Context,
|
||||
config: ProxyViewConfig,
|
||||
) : View(context) {
|
||||
|
||||
init {
|
||||
background = context.getDrawableCompat(config.clickableBackground)
|
||||
}
|
||||
|
||||
var state: ProxyViewState? = null
|
||||
constructor(context: Context) : this(context, ProxyViewConfig(context, 2))
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val state = state ?: return super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
|
||||
val width = when (MeasureSpec.getMode(widthMeasureSpec)) {
|
||||
MeasureSpec.UNSPECIFIED ->
|
||||
resources.displayMetrics.widthPixels
|
||||
MeasureSpec.AT_MOST, MeasureSpec.EXACTLY ->
|
||||
MeasureSpec.getSize(widthMeasureSpec)
|
||||
else ->
|
||||
throw IllegalArgumentException("invalid measure spec")
|
||||
}
|
||||
|
||||
state.paint.apply {
|
||||
reset()
|
||||
|
||||
textSize = state.config.textSize
|
||||
|
||||
getTextBounds("Stub!", 0, 1, state.rect)
|
||||
}
|
||||
|
||||
val textHeight = state.rect.height()
|
||||
val exceptHeight = (state.config.layoutPadding * 2 +
|
||||
state.config.contentPadding * 2 +
|
||||
textHeight * 2 +
|
||||
state.config.textMargin).toInt()
|
||||
|
||||
val height = when (MeasureSpec.getMode(heightMeasureSpec)) {
|
||||
MeasureSpec.UNSPECIFIED ->
|
||||
exceptHeight
|
||||
MeasureSpec.AT_MOST, MeasureSpec.EXACTLY ->
|
||||
exceptHeight.coerceAtMost(MeasureSpec.getSize(heightMeasureSpec))
|
||||
else ->
|
||||
throw IllegalArgumentException("invalid measure spec")
|
||||
}
|
||||
|
||||
setMeasuredDimension(width, height)
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
val state = state ?: return super.draw(canvas)
|
||||
|
||||
if (state.update(false))
|
||||
postInvalidate()
|
||||
|
||||
val width = width.toFloat()
|
||||
val height = height.toFloat()
|
||||
|
||||
val paint = state.paint
|
||||
|
||||
paint.reset()
|
||||
|
||||
paint.color = state.background
|
||||
paint.style = Paint.Style.FILL
|
||||
|
||||
// draw background
|
||||
canvas.apply {
|
||||
if (state.config.proxyLine==1) {
|
||||
drawRect(0f, 0f, width, height, paint)
|
||||
} else {
|
||||
val path = state.path
|
||||
|
||||
path.reset()
|
||||
|
||||
path.addRoundRect(
|
||||
state.config.layoutPadding,
|
||||
state.config.layoutPadding,
|
||||
width - state.config.layoutPadding,
|
||||
height - state.config.layoutPadding,
|
||||
state.config.cardRadius,
|
||||
state.config.cardRadius,
|
||||
Path.Direction.CW,
|
||||
)
|
||||
|
||||
paint.setShadowLayer(
|
||||
state.config.cardRadius,
|
||||
state.config.cardOffset,
|
||||
state.config.cardOffset,
|
||||
state.config.shadow
|
||||
)
|
||||
|
||||
drawPath(path, paint)
|
||||
|
||||
clipPath(path)
|
||||
}
|
||||
}
|
||||
|
||||
super.draw(canvas)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
val state = state ?: return
|
||||
|
||||
val paint = state.paint
|
||||
|
||||
val width = width.toFloat()
|
||||
val height = height.toFloat()
|
||||
|
||||
paint.textSize = state.config.textSize
|
||||
|
||||
// measure delay text bounds
|
||||
val delayCount = paint.breakText(
|
||||
state.delayText,
|
||||
false,
|
||||
(width - state.config.layoutPadding * 2 - state.config.contentPadding * 2)
|
||||
.coerceAtLeast(0f),
|
||||
null
|
||||
)
|
||||
|
||||
state.paint.getTextBounds(state.delayText, 0, delayCount, state.rect)
|
||||
|
||||
val delayWidth = state.rect.width()
|
||||
|
||||
val mainTextWidth = (width -
|
||||
state.config.layoutPadding * 2 -
|
||||
state.config.contentPadding * 2 -
|
||||
delayWidth -
|
||||
state.config.textMargin * 2
|
||||
)
|
||||
.coerceAtLeast(0f)
|
||||
|
||||
// measure title text bounds
|
||||
val titleCount = paint.breakText(
|
||||
state.title,
|
||||
false,
|
||||
mainTextWidth,
|
||||
null,
|
||||
)
|
||||
|
||||
// measure subtitle text bounds
|
||||
val subtitleCount = paint.breakText(
|
||||
state.subtitle,
|
||||
false,
|
||||
mainTextWidth,
|
||||
null,
|
||||
)
|
||||
|
||||
// text draw measure
|
||||
val textOffset = (paint.descent() + paint.ascent()) / 2
|
||||
|
||||
paint.reset()
|
||||
|
||||
paint.textSize = state.config.textSize
|
||||
paint.isAntiAlias = true
|
||||
paint.color = state.controls
|
||||
|
||||
// draw delay
|
||||
canvas.apply {
|
||||
val x = width - state.config.layoutPadding - state.config.contentPadding - delayWidth
|
||||
val y = height / 2f - textOffset
|
||||
|
||||
drawText(state.delayText, 0, delayCount, x, y, paint)
|
||||
}
|
||||
|
||||
// draw title
|
||||
canvas.apply {
|
||||
val x = state.config.layoutPadding + state.config.contentPadding
|
||||
val y = state.config.layoutPadding +
|
||||
(height - state.config.layoutPadding * 2) / 3f - textOffset
|
||||
|
||||
drawText(state.title, 0, titleCount, x, y, paint)
|
||||
}
|
||||
|
||||
// draw subtitle
|
||||
canvas.apply {
|
||||
val x = state.config.layoutPadding + state.config.contentPadding
|
||||
val y = state.config.layoutPadding +
|
||||
(height - state.config.layoutPadding * 2) / 3f * 2 - textOffset
|
||||
|
||||
drawText(state.subtitle, 0, subtitleCount, x, y, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.github.kr328.clash.design.component
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.util.getPixels
|
||||
import com.github.kr328.clash.design.util.resolveThemedColor
|
||||
import com.github.kr328.clash.design.util.resolveThemedResourceId
|
||||
|
||||
class ProxyViewConfig(val context: Context, var proxyLine: Int) {
|
||||
private val colorSurface = context.resolveThemedColor(R.attr.colorSurface)
|
||||
|
||||
val clickableBackground =
|
||||
context.resolveThemedResourceId(android.R.attr.selectableItemBackground)
|
||||
|
||||
val selectedControl = context.resolveThemedColor(R.attr.colorOnPrimary)
|
||||
val selectedBackground = context.resolveThemedColor(R.attr.colorPrimary)
|
||||
|
||||
val unselectedControl = context.resolveThemedColor(R.attr.colorOnSurface)
|
||||
val unselectedBackground: Int
|
||||
get() = if (proxyLine==1) Color.TRANSPARENT else colorSurface
|
||||
|
||||
val layoutPadding = context.getPixels(R.dimen.proxy_layout_padding).toFloat()
|
||||
val contentPadding
|
||||
get() = if (proxyLine==2) context.getPixels(R.dimen.proxy_content_padding).toFloat() else context.getPixels(R.dimen.proxy_content_padding_grid3).toFloat()
|
||||
val textMargin
|
||||
get() = if (proxyLine==2) context.getPixels(R.dimen.proxy_text_margin).toFloat() else context.getPixels(R.dimen.proxy_text_margin_grid3).toFloat()
|
||||
val textSize
|
||||
get() = if (proxyLine==2) context.getPixels(R.dimen.proxy_text_size).toFloat() else context.getPixels(R.dimen.proxy_text_size_grid3).toFloat()
|
||||
|
||||
val shadow = Color.argb(
|
||||
0x15,
|
||||
Color.red(Color.DKGRAY),
|
||||
Color.green(Color.DKGRAY),
|
||||
Color.blue(Color.DKGRAY),
|
||||
)
|
||||
|
||||
val cardRadius = context.getPixels(R.dimen.proxy_card_radius).toFloat()
|
||||
var cardOffset = context.getPixels(R.dimen.proxy_card_offset).toFloat()
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.github.kr328.clash.design.component
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.Rect
|
||||
import com.github.kr328.clash.core.model.Proxy
|
||||
import com.github.kr328.clash.design.model.ProxyState
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.max
|
||||
|
||||
class ProxyViewState(
|
||||
val config: ProxyViewConfig,
|
||||
val proxy: Proxy,
|
||||
private val parent: ProxyState,
|
||||
private val link: ProxyState?
|
||||
) {
|
||||
val paint = Paint()
|
||||
val rect = Rect()
|
||||
val path = Path()
|
||||
|
||||
var title: String = ""
|
||||
var subtitle: String = ""
|
||||
var delayText: String = ""
|
||||
var background: Int = config.unselectedBackground
|
||||
var controls: Int = config.unselectedControl
|
||||
|
||||
private var delay: Int = 0
|
||||
var selected: Boolean = false
|
||||
private var parentNow: String = ""
|
||||
private var linkNow: String? = null
|
||||
|
||||
private var lastFrameTime = System.currentTimeMillis()
|
||||
|
||||
fun update(snap: Boolean): Boolean {
|
||||
val frameTime = System.currentTimeMillis()
|
||||
var invalidate = false
|
||||
|
||||
if (proxy.type.group) {
|
||||
title = proxy.name
|
||||
|
||||
if (link == null) {
|
||||
subtitle = proxy.type.name
|
||||
} else {
|
||||
if (linkNow !== link.now) {
|
||||
linkNow = link.now
|
||||
|
||||
subtitle = "%s(%s)".format(
|
||||
proxy.type.name,
|
||||
link.now.ifEmpty { "*" }
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
title = proxy.title
|
||||
subtitle = proxy.subtitle
|
||||
}
|
||||
|
||||
if (delay != proxy.delay) {
|
||||
delay = proxy.delay
|
||||
delayText = if (proxy.delay in 0..Short.MAX_VALUE) proxy.delay.toString() else ""
|
||||
}
|
||||
|
||||
if (parentNow !== parent.now) {
|
||||
parentNow = parent.now
|
||||
selected = proxy.name == parent.now
|
||||
}
|
||||
|
||||
controls = if (selected) config.selectedControl else config.unselectedControl
|
||||
|
||||
if (snap) {
|
||||
background = if (selected) config.selectedBackground else config.unselectedBackground
|
||||
} else {
|
||||
val target = if (selected) config.selectedBackground else config.unselectedBackground
|
||||
|
||||
if (background != target) {
|
||||
val sa = Color.alpha(background)
|
||||
val sr = Color.red(background)
|
||||
val sg = Color.green(background)
|
||||
val sb = Color.blue(background)
|
||||
|
||||
val ta = Color.alpha(target)
|
||||
val tr = Color.red(target)
|
||||
val tg = Color.green(target)
|
||||
val tb = Color.blue(target)
|
||||
|
||||
val da = ta - sa
|
||||
val dr = tr - sr
|
||||
val dg = tg - sg
|
||||
val db = tb - sb
|
||||
|
||||
val max = max(
|
||||
da.absoluteValue,
|
||||
max(
|
||||
dr.absoluteValue,
|
||||
max(
|
||||
dg.absoluteValue,
|
||||
db.absoluteValue
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val frameOffset = frameTime - lastFrameTime
|
||||
|
||||
val colorOffset = (frameOffset / max.toFloat().coerceAtLeast(0.001f))
|
||||
.coerceIn(0.0f, 1.0f)
|
||||
|
||||
background = if (colorOffset > 0.999f) {
|
||||
target
|
||||
} else {
|
||||
Color.argb(
|
||||
(sa + da * colorOffset).toInt(),
|
||||
(sr + dr * colorOffset).toInt(),
|
||||
(sg + dg * colorOffset).toInt(),
|
||||
(sb + db * colorOffset).toInt()
|
||||
)
|
||||
}
|
||||
|
||||
invalidate = true
|
||||
}
|
||||
}
|
||||
|
||||
lastFrameTime = frameTime
|
||||
|
||||
return invalidate
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.github.kr328.clash.design.dialog
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.ViewCompat
|
||||
import com.github.kr328.clash.common.compat.isAllowForceDarkCompat
|
||||
import com.github.kr328.clash.common.compat.isSystemBarsTranslucentCompat
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.ui.Insets
|
||||
import com.github.kr328.clash.design.ui.Surface
|
||||
import com.github.kr328.clash.design.util.getPixels
|
||||
import com.github.kr328.clash.design.util.resolveThemedResourceId
|
||||
import com.github.kr328.clash.design.util.setOnInsertsChangedListener
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
|
||||
class AppBottomSheetDialog(context: Context) : BottomSheetDialog(context) {
|
||||
private var insets: Insets = Insets.EMPTY
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setCancelable(true)
|
||||
|
||||
window!!.apply {
|
||||
isSystemBarsTranslucentCompat = true
|
||||
isAllowForceDarkCompat = false
|
||||
}
|
||||
|
||||
findViewById<ViewGroup>(com.google.android.material.R.id.container)?.apply {
|
||||
fitsSystemWindows = false
|
||||
}
|
||||
|
||||
findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)?.apply {
|
||||
setOnInsertsChangedListener {
|
||||
if (insets != it) {
|
||||
insets = it
|
||||
|
||||
(layoutParams as CoordinatorLayout.LayoutParams).also { params ->
|
||||
if (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_LTR) {
|
||||
params.setMargins(it.start, 0, it.end, 0)
|
||||
} else {
|
||||
params.setMargins(it.end, 0, it.start, 0)
|
||||
}
|
||||
|
||||
val top = context.getPixels(R.dimen.bottom_sheet_background_padding_top)
|
||||
val height = context.getPixels(R.dimen.bottom_sheet_header_height)
|
||||
|
||||
setPaddingRelative(
|
||||
0,
|
||||
top * 2 + height,
|
||||
0,
|
||||
it.bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOnShowListener {
|
||||
behavior.halfExpandedRatio = 0.99f
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FullScreenDialog(
|
||||
context: Context
|
||||
) : Dialog(context, context.resolveThemedResourceId(R.attr.fullScreenDialogTheme)) {
|
||||
val surface = Surface()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
window!!.apply {
|
||||
isSystemBarsTranslucentCompat = true
|
||||
|
||||
setLayout(
|
||||
WindowManager.LayoutParams.MATCH_PARENT,
|
||||
WindowManager.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
decorView.setOnInsertsChangedListener {
|
||||
if (surface.insets != it)
|
||||
surface.insets = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package com.github.kr328.clash.design.dialog
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.databinding.DialogTextFieldBinding
|
||||
import com.github.kr328.clash.design.util.*
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
suspend fun Context.requestModelTextInput(
|
||||
initial: String,
|
||||
title: CharSequence,
|
||||
hint: CharSequence? = null,
|
||||
error: CharSequence? = null,
|
||||
validator: Validator = ValidatorAcceptAll,
|
||||
): String {
|
||||
return this.requestModelTextInput(initial, title, null, hint, error, validator)!!
|
||||
}
|
||||
|
||||
suspend fun Context.requestModelTextInput(
|
||||
initial: String?,
|
||||
title: CharSequence,
|
||||
reset: CharSequence?,
|
||||
hint: CharSequence? = null,
|
||||
error: CharSequence? = null,
|
||||
validator: Validator = ValidatorAcceptAll,
|
||||
): String? {
|
||||
return suspendCancellableCoroutine {
|
||||
val binding = DialogTextFieldBinding
|
||||
.inflate(layoutInflater, this.root, false)
|
||||
|
||||
val builder = MaterialAlertDialogBuilder(this)
|
||||
.setTitle(title)
|
||||
.setView(binding.root)
|
||||
.setCancelable(true)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
val text = binding.textField.text?.toString() ?: ""
|
||||
|
||||
if (validator(text))
|
||||
it.resume(text)
|
||||
else
|
||||
it.resume(initial)
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.setOnDismissListener { _ ->
|
||||
if (!it.isCompleted)
|
||||
it.resume(initial)
|
||||
}
|
||||
|
||||
if (reset != null) {
|
||||
builder.setNeutralButton(reset) { _, _ ->
|
||||
it.resume(null)
|
||||
}
|
||||
}
|
||||
|
||||
val dialog = builder.create()
|
||||
|
||||
it.invokeOnCancellation {
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
dialog.setOnShowListener {
|
||||
if (hint != null)
|
||||
binding.textLayout.hint = hint
|
||||
|
||||
binding.textField.apply {
|
||||
binding.textLayout.isErrorEnabled = error != null
|
||||
|
||||
doOnTextChanged { text, _, _, _ ->
|
||||
if (!validator(text?.toString() ?: "")) {
|
||||
if (error != null)
|
||||
binding.textLayout.error = error
|
||||
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false
|
||||
} else {
|
||||
if (error != null)
|
||||
binding.textLayout.error = null
|
||||
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
setText(initial)
|
||||
|
||||
setSelection(0, initial?.length ?: 0)
|
||||
|
||||
requestTextInput()
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.github.kr328.clash.design.dialog
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.design.databinding.DialogFetchStatusBinding
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
interface ModelProgressBarConfigure {
|
||||
var isIndeterminate: Boolean
|
||||
var text: String?
|
||||
var progress: Int
|
||||
var max: Int
|
||||
}
|
||||
|
||||
interface ModelProgressBarScope {
|
||||
suspend fun configure(block: suspend ModelProgressBarConfigure.() -> Unit)
|
||||
}
|
||||
|
||||
suspend fun Context.withModelProgressBar(block: suspend ModelProgressBarScope.() -> Unit) {
|
||||
val view = DialogFetchStatusBinding.inflate(this.layoutInflater)
|
||||
val dialog = MaterialAlertDialogBuilder(this)
|
||||
.setCancelable(false)
|
||||
.setView(view.root)
|
||||
.show()
|
||||
|
||||
val configureImpl = object : ModelProgressBarConfigure {
|
||||
override var isIndeterminate: Boolean
|
||||
get() = view.progressIndicator.isIndeterminate
|
||||
set(value) {
|
||||
view.progressIndicator.isIndeterminate = value
|
||||
}
|
||||
override var text: String?
|
||||
get() = view.text.text?.toString()
|
||||
set(value) {
|
||||
view.text.text = value
|
||||
}
|
||||
override var progress: Int
|
||||
get() = view.progressIndicator.progress
|
||||
set(value) {
|
||||
view.progressIndicator.setProgressCompat(value, true)
|
||||
}
|
||||
override var max: Int
|
||||
get() = view.progressIndicator.max
|
||||
set(value) {
|
||||
view.progressIndicator.max = value
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
val scopeImpl = object : ModelProgressBarScope {
|
||||
override suspend fun configure(block: suspend ModelProgressBarConfigure.() -> Unit) {
|
||||
withContext(Dispatchers.Main) {
|
||||
configureImpl.block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
scopeImpl.block()
|
||||
} finally {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.github.kr328.clash.design.model
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
|
||||
data class AppInfo(
|
||||
val packageName: String,
|
||||
val label: String,
|
||||
val icon: Drawable,
|
||||
val installTime: Long,
|
||||
val updateDate: Long,
|
||||
)
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.github.kr328.clash.design.model
|
||||
|
||||
enum class AppInfoSort(comparator: Comparator<AppInfo>) : Comparator<AppInfo> by comparator {
|
||||
Label(compareBy(AppInfo::label)),
|
||||
PackageName(compareBy(AppInfo::packageName)),
|
||||
InstallTime(compareBy(AppInfo::installTime)),
|
||||
UpdateTime(compareBy(AppInfo::updateDate)),
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.github.kr328.clash.design.model
|
||||
|
||||
interface Behavior {
|
||||
var autoRestart: Boolean
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.github.kr328.clash.design.model
|
||||
|
||||
enum class DarkMode {
|
||||
Auto, ForceLight, ForceDark
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.github.kr328.clash.design.model
|
||||
|
||||
data class File(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val size: Long,
|
||||
val lastModified: Long,
|
||||
val isDirectory: Boolean
|
||||
)
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.github.kr328.clash.design.model
|
||||
|
||||
import java.util.*
|
||||
|
||||
data class LogFile(val fileName: String, val date: Date) {
|
||||
companion object {
|
||||
private val REGEX_FILE = Regex("clash-(\\d+).log")
|
||||
private const val FORMAT_FILE_NAME = "clash-%d.log"
|
||||
|
||||
fun parseFromFileName(fileName: String): LogFile? {
|
||||
return REGEX_FILE.matchEntire(fileName)?.run {
|
||||
LogFile(fileName, Date(groupValues[1].toLong()))
|
||||
}
|
||||
}
|
||||
|
||||
fun generate(): LogFile {
|
||||
val current = Date()
|
||||
val fileName = FORMAT_FILE_NAME.format(current.time)
|
||||
|
||||
return LogFile(fileName, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.github.kr328.clash.design.model
|
||||
|
||||
class ProfilePageState {
|
||||
var allUpdating = false
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.github.kr328.clash.design.model
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Drawable
|
||||
import com.github.kr328.clash.common.compat.getDrawableCompat
|
||||
import com.github.kr328.clash.design.R
|
||||
|
||||
sealed class ProfileProvider {
|
||||
class File(private val context: Context) : ProfileProvider() {
|
||||
override val name: String
|
||||
get() = context.getString(R.string.file)
|
||||
override val summary: String
|
||||
get() = context.getString(R.string.import_from_file)
|
||||
override val icon: Drawable?
|
||||
get() = context.getDrawableCompat(R.drawable.ic_baseline_attach_file)
|
||||
}
|
||||
|
||||
class Url(private val context: Context) : ProfileProvider() {
|
||||
override val name: String
|
||||
get() = context.getString(R.string.url)
|
||||
override val summary: String
|
||||
get() = context.getString(R.string.import_from_url)
|
||||
override val icon: Drawable?
|
||||
get() = context.getDrawableCompat(R.drawable.ic_baseline_cloud_download)
|
||||
}
|
||||
|
||||
class External(
|
||||
override val name: String,
|
||||
override val summary: String,
|
||||
override val icon: Drawable?,
|
||||
val intent: Intent,
|
||||
) : ProfileProvider()
|
||||
|
||||
abstract val name: String
|
||||
abstract val summary: String
|
||||
abstract val icon: Drawable?
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.github.kr328.clash.design.model
|
||||
|
||||
import androidx.databinding.BaseObservable
|
||||
import androidx.databinding.Bindable
|
||||
import com.github.kr328.clash.core.model.Provider
|
||||
import com.github.kr328.clash.design.BR
|
||||
|
||||
class ProviderState(
|
||||
val provider: Provider,
|
||||
updatedAt: Long,
|
||||
updating: Boolean,
|
||||
) : BaseObservable() {
|
||||
var updatedAt: Long = updatedAt
|
||||
@Bindable get
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
notifyPropertyChanged(BR.updatedAt)
|
||||
}
|
||||
|
||||
var updating: Boolean = updating
|
||||
@Bindable get
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
notifyPropertyChanged(BR.updating)
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.github.kr328.clash.design.model
|
||||
|
||||
class ProxyPageState {
|
||||
var bottom = false
|
||||
var urlTesting = false
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package com.github.kr328.clash.design.model
|
||||
|
||||
data class ProxyState(var now: String)
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.github.kr328.clash.design.preference
|
||||
|
||||
import android.view.View
|
||||
import androidx.annotation.StringRes
|
||||
import com.github.kr328.clash.design.databinding.PreferenceCategoryBinding
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
|
||||
fun PreferenceScreen.category(
|
||||
@StringRes text: Int,
|
||||
) {
|
||||
val binding = PreferenceCategoryBinding
|
||||
.inflate(context.layoutInflater, root, false)
|
||||
|
||||
binding.textView.text = context.getString(text)
|
||||
|
||||
addElement(object : Preference {
|
||||
override val view: View
|
||||
get() = binding.root
|
||||
override var enabled: Boolean
|
||||
get() = binding.root.isEnabled
|
||||
set(value) {
|
||||
binding.root.isEnabled = value
|
||||
}
|
||||
})
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.github.kr328.clash.design.preference
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import com.github.kr328.clash.common.compat.getDrawableCompat
|
||||
import com.github.kr328.clash.design.databinding.PreferenceClickableBinding
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
|
||||
interface ClickablePreference : Preference {
|
||||
var title: CharSequence
|
||||
|
||||
var icon: Drawable?
|
||||
var summary: CharSequence?
|
||||
|
||||
fun clicked(clicked: () -> Unit)
|
||||
}
|
||||
|
||||
fun PreferenceScreen.clickable(
|
||||
@StringRes title: Int,
|
||||
@DrawableRes icon: Int? = null,
|
||||
@StringRes summary: Int? = null,
|
||||
configure: ClickablePreference.() -> Unit = {}
|
||||
): ClickablePreference {
|
||||
val binding = PreferenceClickableBinding
|
||||
.inflate(context.layoutInflater, root, false)
|
||||
|
||||
val impl = object : ClickablePreference {
|
||||
override var icon: Drawable?
|
||||
get() = binding.iconView.background
|
||||
set(value) {
|
||||
binding.iconView.background = value
|
||||
}
|
||||
override var title: CharSequence
|
||||
get() = binding.titleView.text
|
||||
set(value) {
|
||||
binding.titleView.text = value
|
||||
}
|
||||
override var summary: CharSequence?
|
||||
get() = binding.summaryView.text
|
||||
set(value) {
|
||||
binding.summaryView.text = value
|
||||
binding.summaryView.visibility = if (value == null) View.GONE else View.VISIBLE
|
||||
}
|
||||
override val view: View
|
||||
get() = binding.root
|
||||
|
||||
override fun clicked(clicked: () -> Unit) {
|
||||
binding.root.setOnClickListener {
|
||||
clicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl.title = context.getText(title)
|
||||
|
||||
if (icon != null) {
|
||||
impl.icon = context.getDrawableCompat(icon)
|
||||
}
|
||||
|
||||
if (summary != null) {
|
||||
impl.summary = context.getText(summary)
|
||||
} else {
|
||||
impl.summary = null
|
||||
}
|
||||
|
||||
impl.configure()
|
||||
|
||||
addElement(impl)
|
||||
|
||||
return impl
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package com.github.kr328.clash.design.preference
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.dialog.requestModelTextInput
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.reflect.KMutableProperty0
|
||||
|
||||
interface EditableTextPreference : ClickablePreference {
|
||||
var placeholder: CharSequence?
|
||||
var empty: CharSequence?
|
||||
var text: String?
|
||||
}
|
||||
|
||||
fun <T> PreferenceScreen.editableText(
|
||||
value: KMutableProperty0<T>,
|
||||
adapter: NullableTextAdapter<T>,
|
||||
@StringRes title: Int,
|
||||
@DrawableRes icon: Int? = null,
|
||||
@StringRes placeholder: Int? = null,
|
||||
@StringRes empty: Int? = null,
|
||||
configure: EditableTextPreference.() -> Unit = {},
|
||||
): EditableTextPreference {
|
||||
val impl = object : EditableTextPreference, ClickablePreference by clickable(title, icon) {
|
||||
override var placeholder: CharSequence? = null
|
||||
override var empty: CharSequence? = null
|
||||
override var text: String? = null
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
when {
|
||||
value == null -> {
|
||||
this.summary = this.placeholder
|
||||
}
|
||||
value.isEmpty() -> {
|
||||
this.summary = this.empty
|
||||
}
|
||||
else -> {
|
||||
this.summary = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (placeholder != null) {
|
||||
impl.placeholder = context.getText(placeholder)
|
||||
}
|
||||
|
||||
if (empty != null) {
|
||||
impl.empty = context.getText(empty)
|
||||
}
|
||||
|
||||
impl.configure()
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
impl.text = withContext(Dispatchers.IO) {
|
||||
adapter.from(value.get())
|
||||
}
|
||||
|
||||
impl.clicked {
|
||||
this@editableText.launch(Dispatchers.Main) {
|
||||
val text = context.requestModelTextInput(
|
||||
initial = impl.text,
|
||||
title = impl.title,
|
||||
reset = context.getText(R.string.reset),
|
||||
hint = impl.title,
|
||||
)
|
||||
|
||||
val newValue = withContext(Dispatchers.IO) {
|
||||
adapter.to(text).apply(value::set)
|
||||
}
|
||||
|
||||
impl.text = adapter.from(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return impl
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package com.github.kr328.clash.design.preference
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.adapter.EditableTextListAdapter
|
||||
import com.github.kr328.clash.design.dialog.requestModelTextInput
|
||||
import kotlinx.coroutines.*
|
||||
import kotlin.reflect.KMutableProperty0
|
||||
|
||||
interface EditableTextListPreference<T> : ClickablePreference {
|
||||
var placeholder: CharSequence?
|
||||
|
||||
var list: List<T>?
|
||||
}
|
||||
|
||||
fun <T> PreferenceScreen.editableTextList(
|
||||
value: KMutableProperty0<List<T>?>,
|
||||
adapter: TextAdapter<T>,
|
||||
@StringRes title: Int,
|
||||
@DrawableRes icon: Int? = null,
|
||||
@StringRes placeholder: Int? = null,
|
||||
configure: EditableTextListPreference<T>.() -> Unit = {},
|
||||
): EditableTextListPreference<T> {
|
||||
val impl =
|
||||
object : EditableTextListPreference<T>, ClickablePreference by clickable(title, icon) {
|
||||
override var list: List<T>? = null
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
when {
|
||||
value == null -> {
|
||||
this.summary = this.placeholder
|
||||
}
|
||||
value.isEmpty() -> {
|
||||
this.summary = context.getString(R.string.empty)
|
||||
}
|
||||
else -> {
|
||||
this.summary = context.getString(R.string.format_elements, value.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
override var placeholder: CharSequence? = null
|
||||
}
|
||||
|
||||
if (placeholder != null) {
|
||||
impl.placeholder = context.getText(placeholder)
|
||||
}
|
||||
|
||||
impl.configure()
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
val v = withContext(Dispatchers.IO) {
|
||||
value.get()
|
||||
}
|
||||
|
||||
impl.list = v
|
||||
|
||||
impl.clicked {
|
||||
this@editableTextList.launch(Dispatchers.Main) {
|
||||
val newList = requestEditTextList(
|
||||
impl.list,
|
||||
context,
|
||||
adapter,
|
||||
impl.title
|
||||
)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
value.set(newList)
|
||||
}
|
||||
|
||||
impl.list = newList
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return impl
|
||||
}
|
||||
|
||||
private suspend fun <T> requestEditTextList(
|
||||
initialValue: List<T>?,
|
||||
context: Context,
|
||||
adapter: TextAdapter<T>,
|
||||
title: CharSequence,
|
||||
): List<T>? {
|
||||
val recyclerAdapter = EditableTextListAdapter(
|
||||
context,
|
||||
initialValue?.toMutableList() ?: mutableListOf(),
|
||||
adapter
|
||||
)
|
||||
|
||||
val result = requestEditableListOverlay(context, recyclerAdapter, title) {
|
||||
val text = context.requestModelTextInput(
|
||||
initial = "",
|
||||
title = title,
|
||||
hint = title
|
||||
)
|
||||
|
||||
if (text.isNotBlank()) {
|
||||
recyclerAdapter.addElement(text)
|
||||
}
|
||||
}
|
||||
|
||||
return when (result) {
|
||||
EditableListOverlayResult.Cancel -> initialValue
|
||||
EditableListOverlayResult.Apply -> recyclerAdapter.values
|
||||
EditableListOverlayResult.Reset -> null
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package com.github.kr328.clash.design.preference
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.adapter.EditableTextMapAdapter
|
||||
import com.github.kr328.clash.design.databinding.DialogEditableMapTextFieldBinding
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import com.github.kr328.clash.design.util.root
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.reflect.KMutableProperty0
|
||||
|
||||
interface EditableTextMapPreference<K, V> : ClickablePreference {
|
||||
var placeholder: CharSequence?
|
||||
var map: Map<K, V>?
|
||||
}
|
||||
|
||||
fun <K, V> PreferenceScreen.editableTextMap(
|
||||
value: KMutableProperty0<Map<K, V>?>,
|
||||
keyAdapter: TextAdapter<K>,
|
||||
valueAdapter: TextAdapter<V>,
|
||||
@StringRes title: Int,
|
||||
@DrawableRes icon: Int? = null,
|
||||
@StringRes placeholder: Int? = null,
|
||||
configure: EditableTextMapPreference<K, V>.() -> Unit = {}
|
||||
): EditableTextMapPreference<K, V> {
|
||||
val impl =
|
||||
object : EditableTextMapPreference<K, V>, ClickablePreference by clickable(title, icon) {
|
||||
override var placeholder: CharSequence? = null
|
||||
override var map: Map<K, V>? = null
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
when {
|
||||
value == null -> {
|
||||
this.summary = this.placeholder
|
||||
}
|
||||
value.isEmpty() -> {
|
||||
this.summary = context.getString(R.string.empty)
|
||||
}
|
||||
else -> {
|
||||
this.summary = context.getString(R.string.format_elements, value.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (placeholder != null) {
|
||||
impl.placeholder = context.getText(placeholder)
|
||||
}
|
||||
|
||||
impl.configure()
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
val v = withContext(Dispatchers.IO) {
|
||||
value.get()
|
||||
}
|
||||
|
||||
impl.map = v
|
||||
|
||||
impl.clicked {
|
||||
this@editableTextMap.launch(Dispatchers.Main) {
|
||||
val newMap = requestEditTextMap(
|
||||
impl.map,
|
||||
context,
|
||||
keyAdapter,
|
||||
valueAdapter,
|
||||
impl.title
|
||||
)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
value.set(newMap)
|
||||
}
|
||||
|
||||
impl.map = newMap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return impl
|
||||
}
|
||||
|
||||
private suspend fun <K, V> requestEditTextMap(
|
||||
initialValue: Map<K, V>?,
|
||||
context: Context,
|
||||
keyAdapter: TextAdapter<K>,
|
||||
valueAdapter: TextAdapter<V>,
|
||||
title: CharSequence
|
||||
): Map<K, V>? {
|
||||
val editableValue = withContext(Dispatchers.Default) {
|
||||
initialValue?.map { it.key to it.value }?.toMutableList() ?: mutableListOf()
|
||||
}
|
||||
|
||||
val recyclerAdapter = EditableTextMapAdapter(
|
||||
context,
|
||||
editableValue,
|
||||
keyAdapter,
|
||||
valueAdapter,
|
||||
)
|
||||
|
||||
val result = requestEditableListOverlay(context, recyclerAdapter, title) {
|
||||
val newItem = requestModelInputEntry(context, title)
|
||||
|
||||
if (newItem != null) {
|
||||
recyclerAdapter.addElement(newItem.first, newItem.second)
|
||||
}
|
||||
}
|
||||
|
||||
return when (result) {
|
||||
EditableListOverlayResult.Cancel -> initialValue
|
||||
EditableListOverlayResult.Apply -> recyclerAdapter.values.toMap()
|
||||
EditableListOverlayResult.Reset -> null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestModelInputEntry(
|
||||
context: Context,
|
||||
title: CharSequence
|
||||
): Pair<String, String>? {
|
||||
return suspendCancellableCoroutine { ctx ->
|
||||
val binding = DialogEditableMapTextFieldBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(context)
|
||||
.setTitle(title)
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
val k = binding.keyView.text?.toString()?.trim() ?: ""
|
||||
val v = binding.valueView.text?.toString()?.trim() ?: ""
|
||||
|
||||
if (k.isNotEmpty() && v.isNotEmpty()) {
|
||||
ctx.resume(k to v)
|
||||
}
|
||||
}
|
||||
.setView(binding.root)
|
||||
.create()
|
||||
|
||||
dialog.setOnCancelListener {
|
||||
if (!ctx.isCompleted) {
|
||||
ctx.resume(null)
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.github.kr328.clash.design.preference
|
||||
|
||||
import android.content.Context
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.design.databinding.DialogPreferenceListBinding
|
||||
import com.github.kr328.clash.design.dialog.FullScreenDialog
|
||||
import com.github.kr328.clash.design.util.applyLinearAdapter
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import com.github.kr328.clash.design.util.root
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
internal enum class EditableListOverlayResult {
|
||||
Cancel, Apply, Reset
|
||||
}
|
||||
|
||||
internal suspend fun requestEditableListOverlay(
|
||||
context: Context,
|
||||
adapter: RecyclerView.Adapter<*>,
|
||||
title: CharSequence,
|
||||
addNewItem: suspend () -> Unit
|
||||
): EditableListOverlayResult {
|
||||
return coroutineScope {
|
||||
suspendCancellableCoroutine { ctx ->
|
||||
val dialog = FullScreenDialog(context)
|
||||
val binding = DialogPreferenceListBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
|
||||
binding.surface = dialog.surface
|
||||
binding.mainList.applyLinearAdapter(context, adapter)
|
||||
binding.titleView.text = title
|
||||
|
||||
binding.newView.setOnClickListener {
|
||||
launch {
|
||||
addNewItem()
|
||||
}
|
||||
}
|
||||
|
||||
binding.resetView.setOnClickListener {
|
||||
ctx.resume(EditableListOverlayResult.Reset)
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
binding.cancelView.setOnClickListener {
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
binding.okView.setOnClickListener {
|
||||
ctx.resume(EditableListOverlayResult.Apply)
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
dialog.setContentView(binding.root)
|
||||
|
||||
dialog.setOnDismissListener {
|
||||
if (!ctx.isCompleted) {
|
||||
ctx.resume(EditableListOverlayResult.Cancel)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.invokeOnCancellation {
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.github.kr328.clash.design.preference
|
||||
|
||||
import android.view.View
|
||||
|
||||
fun interface OnChangedListener {
|
||||
fun onChanged()
|
||||
}
|
||||
|
||||
interface Preference {
|
||||
val view: View
|
||||
|
||||
var enabled: Boolean
|
||||
get() = view.isEnabled
|
||||
set(value) {
|
||||
view.isEnabled = value
|
||||
view.isClickable = value
|
||||
view.isFocusable = value
|
||||
view.alpha = if (value) 1.0f else 0.33f
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.github.kr328.clash.design.preference
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.LinearLayout.LayoutParams
|
||||
import android.widget.LinearLayout.LayoutParams.MATCH_PARENT
|
||||
import android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
interface PreferenceScreen : CoroutineScope {
|
||||
val context: Context
|
||||
val root: ViewGroup
|
||||
}
|
||||
|
||||
fun CoroutineScope.preferenceScreen(
|
||||
context: Context,
|
||||
configure: PreferenceScreen.() -> Unit
|
||||
): PreferenceScreen {
|
||||
val root = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
}
|
||||
|
||||
val impl = object : PreferenceScreen, CoroutineScope by this {
|
||||
override val context: Context
|
||||
get() = context
|
||||
override val root: ViewGroup
|
||||
get() = root
|
||||
}
|
||||
|
||||
impl.configure()
|
||||
|
||||
return impl
|
||||
}
|
||||
|
||||
fun PreferenceScreen.addElement(preference: Preference) {
|
||||
root.addView(preference.view, LayoutParams(MATCH_PARENT, WRAP_CONTENT))
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package com.github.kr328.clash.design.preference
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.widget.ListPopupWindow
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.adapter.PopupListAdapter
|
||||
import com.github.kr328.clash.design.util.getPixels
|
||||
import com.github.kr328.clash.design.util.measureWidth
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.reflect.KMutableProperty0
|
||||
|
||||
interface SelectableListPreference<T> : ClickablePreference {
|
||||
var selected: Int
|
||||
|
||||
var listener: OnChangedListener?
|
||||
}
|
||||
|
||||
fun <T> PreferenceScreen.selectableList(
|
||||
value: KMutableProperty0<T>,
|
||||
values: Array<T>,
|
||||
valuesText: Array<Int>,
|
||||
@StringRes title: Int,
|
||||
@DrawableRes icon: Int? = null,
|
||||
configure: SelectableListPreference<T>.() -> Unit = {},
|
||||
): SelectableListPreference<T> {
|
||||
val impl = object : SelectableListPreference<T>, ClickablePreference by clickable(title, icon) {
|
||||
override var selected: Int = 0
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
this.summary = context.getText(valuesText[value])
|
||||
}
|
||||
override var listener: OnChangedListener? = null
|
||||
}
|
||||
|
||||
impl.configure()
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
val initial = withContext(Dispatchers.IO) {
|
||||
value.get()
|
||||
}
|
||||
|
||||
impl.selected = values.indexOf(initial)
|
||||
|
||||
impl.clicked {
|
||||
popupSelectMenu(impl, value, valuesText.map { context.getText(it) }, values)
|
||||
}
|
||||
}
|
||||
|
||||
return impl
|
||||
}
|
||||
|
||||
private fun <T> PreferenceScreen.popupSelectMenu(
|
||||
impl: SelectableListPreference<T>,
|
||||
value: KMutableProperty0<T>,
|
||||
valuesText: List<CharSequence>,
|
||||
values: Array<T>,
|
||||
) {
|
||||
ListPopupWindow(context).apply {
|
||||
val adapter = PopupListAdapter(
|
||||
context,
|
||||
valuesText,
|
||||
impl.selected,
|
||||
)
|
||||
|
||||
setAdapter(adapter)
|
||||
|
||||
anchorView = impl.view
|
||||
|
||||
width = adapter.measureWidth(context)
|
||||
.coerceAtLeast(context.getPixels(R.dimen.dialog_menu_min_width))
|
||||
|
||||
isModal = true
|
||||
|
||||
horizontalOffset = context.getPixels(R.dimen.item_header_component_size) +
|
||||
context.getPixels(R.dimen.item_header_margin) * 2
|
||||
|
||||
setOnItemClickListener { _, _, position, _ ->
|
||||
dismiss()
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
withContext(Dispatchers.IO) {
|
||||
value.set(values[position])
|
||||
}
|
||||
|
||||
impl.selected = position
|
||||
impl.listener?.onChanged()
|
||||
}
|
||||
}
|
||||
|
||||
show()
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.github.kr328.clash.design.preference
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import com.github.kr328.clash.common.compat.getDrawableCompat
|
||||
import com.github.kr328.clash.design.databinding.PreferenceSwitchBinding
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.reflect.KMutableProperty0
|
||||
|
||||
interface SwitchPreference : Preference {
|
||||
var icon: Drawable?
|
||||
var title: CharSequence?
|
||||
var summary: CharSequence?
|
||||
var listener: OnChangedListener?
|
||||
}
|
||||
|
||||
fun PreferenceScreen.switch(
|
||||
value: KMutableProperty0<Boolean>,
|
||||
@DrawableRes icon: Int? = null,
|
||||
@StringRes title: Int? = null,
|
||||
@StringRes summary: Int? = null,
|
||||
configure: SwitchPreference.() -> Unit = {},
|
||||
): SwitchPreference {
|
||||
val binding = PreferenceSwitchBinding
|
||||
.inflate(context.layoutInflater, root, false)
|
||||
|
||||
val impl = object : SwitchPreference {
|
||||
override val view: View
|
||||
get() = binding.root
|
||||
override var icon: Drawable?
|
||||
get() = binding.iconView.background
|
||||
set(value) {
|
||||
binding.iconView.background = value
|
||||
}
|
||||
override var title: CharSequence?
|
||||
get() = binding.titleView.text
|
||||
set(value) {
|
||||
binding.titleView.text = value
|
||||
}
|
||||
override var summary: CharSequence?
|
||||
get() = binding.summaryView.text
|
||||
set(value) {
|
||||
binding.summaryView.text = value
|
||||
}
|
||||
override var listener: OnChangedListener? = null
|
||||
override var enabled: Boolean
|
||||
get() = binding.root.isEnabled
|
||||
set(value) {
|
||||
binding.root.isEnabled = value
|
||||
binding.root.isFocusable = value
|
||||
binding.root.isClickable = value
|
||||
binding.root.alpha = if (value) 1.0f else 0.33f
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (icon != null) {
|
||||
impl.icon = context.getDrawableCompat(icon)
|
||||
}
|
||||
|
||||
if (title != null) {
|
||||
impl.title = context.getString(title)
|
||||
}
|
||||
|
||||
if (summary != null) {
|
||||
impl.summary = context.getString(summary)
|
||||
}
|
||||
|
||||
impl.configure()
|
||||
|
||||
addElement(impl)
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
val initialValue = withContext(Dispatchers.IO) {
|
||||
value.get()
|
||||
}
|
||||
|
||||
binding.switchView.apply {
|
||||
isChecked = initialValue
|
||||
|
||||
binding.root.setOnClickListener {
|
||||
isChecked = !isChecked
|
||||
|
||||
this@switch.launch(Dispatchers.Main) {
|
||||
withContext(Dispatchers.IO) {
|
||||
value.set(isChecked)
|
||||
}
|
||||
|
||||
impl.listener?.onChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return impl
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.github.kr328.clash.design.preference
|
||||
|
||||
import android.view.View
|
||||
import androidx.annotation.StringRes
|
||||
import com.github.kr328.clash.design.databinding.PreferenceTipsBinding
|
||||
import com.github.kr328.clash.design.util.getHtml
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import com.github.kr328.clash.design.util.root
|
||||
|
||||
interface TipsPreference : Preference {
|
||||
var text: CharSequence?
|
||||
}
|
||||
|
||||
fun PreferenceScreen.tips(
|
||||
@StringRes text: Int,
|
||||
configure: TipsPreference.() -> Unit = {},
|
||||
): TipsPreference {
|
||||
val binding = PreferenceTipsBinding
|
||||
.inflate(context.layoutInflater, context.root, false)
|
||||
val impl = object : TipsPreference {
|
||||
override var text: CharSequence?
|
||||
get() = binding.tips.text
|
||||
set(value) {
|
||||
binding.tips.text = value
|
||||
}
|
||||
override val view: View
|
||||
get() = binding.root
|
||||
override var enabled: Boolean
|
||||
get() = binding.root.isEnabled
|
||||
set(value) {
|
||||
binding.root.isEnabled = value
|
||||
}
|
||||
}
|
||||
|
||||
binding.tips.text = context.getHtml(text)
|
||||
|
||||
impl.configure()
|
||||
|
||||
addElement(impl)
|
||||
|
||||
return impl
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.github.kr328.clash.design.preference
|
||||
|
||||
interface NullableTextAdapter<T> {
|
||||
fun from(value: T): String?
|
||||
fun to(text: String?): T
|
||||
|
||||
companion object {
|
||||
val Port = object : NullableTextAdapter<Int?> {
|
||||
override fun from(value: Int?): String? {
|
||||
if (value == null) return null
|
||||
|
||||
return if (value > 0) value.toString() else ""
|
||||
}
|
||||
|
||||
override fun to(text: String?): Int? {
|
||||
if (text == null) return null
|
||||
|
||||
return text.toIntOrNull() ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
val String = object : NullableTextAdapter<String?> {
|
||||
override fun from(value: String?): String? {
|
||||
return value
|
||||
}
|
||||
|
||||
override fun to(text: String?): String? {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface TextAdapter<T> {
|
||||
fun from(value: T): String
|
||||
fun to(text: String): T
|
||||
|
||||
companion object {
|
||||
val String = object : TextAdapter<String> {
|
||||
override fun from(value: String): String {
|
||||
return value
|
||||
}
|
||||
|
||||
override fun to(text: String): String {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.github.kr328.clash.design.store
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.common.store.Store
|
||||
import com.github.kr328.clash.common.store.asStoreProvider
|
||||
import com.github.kr328.clash.core.model.ProxySort
|
||||
import com.github.kr328.clash.design.model.AppInfoSort
|
||||
import com.github.kr328.clash.design.model.DarkMode
|
||||
|
||||
class UiStore(context: Context) {
|
||||
private val store = Store(
|
||||
context
|
||||
.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE)
|
||||
.asStoreProvider()
|
||||
)
|
||||
|
||||
var enableVpn: Boolean by store.boolean(
|
||||
key = "enable_vpn",
|
||||
defaultValue = true
|
||||
)
|
||||
|
||||
var darkMode: DarkMode by store.enum(
|
||||
key = "dark_mode",
|
||||
defaultValue = DarkMode.Auto,
|
||||
values = DarkMode.values()
|
||||
)
|
||||
|
||||
var proxyExcludeNotSelectable by store.boolean(
|
||||
key = "proxy_exclude_not_selectable",
|
||||
defaultValue = false,
|
||||
)
|
||||
|
||||
var proxyLine: Int by store.int(
|
||||
key = "proxy_line",
|
||||
defaultValue = 1
|
||||
)
|
||||
|
||||
var proxySort: ProxySort by store.enum(
|
||||
key = "proxy_sort",
|
||||
defaultValue = ProxySort.Default,
|
||||
values = ProxySort.values()
|
||||
)
|
||||
|
||||
var proxyLastGroup: String by store.string(
|
||||
key = "proxy_last_group",
|
||||
defaultValue = ""
|
||||
)
|
||||
|
||||
var accessControlSort: AppInfoSort by store.enum(
|
||||
key = "access_control_sort",
|
||||
defaultValue = AppInfoSort.Label,
|
||||
values = AppInfoSort.values(),
|
||||
)
|
||||
|
||||
var accessControlReverse: Boolean by store.boolean(
|
||||
key = "access_control_reverse",
|
||||
defaultValue = false
|
||||
)
|
||||
|
||||
var accessControlSystemApp: Boolean by store.boolean(
|
||||
key = "access_control_system_app",
|
||||
defaultValue = false,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val PREFERENCE_NAME = "ui"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.github.kr328.clash.design.ui
|
||||
|
||||
enum class DayNight {
|
||||
Day, Night
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.github.kr328.clash.design.ui
|
||||
|
||||
data class Insets(val start: Int, val top: Int, val end: Int, val bottom: Int) {
|
||||
companion object {
|
||||
val EMPTY = Insets(0, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.github.kr328.clash.design.ui
|
||||
|
||||
import androidx.databinding.BaseObservable
|
||||
import androidx.databinding.Bindable
|
||||
import androidx.databinding.library.baseAdapters.BR
|
||||
|
||||
class ObservableCurrentTime : BaseObservable() {
|
||||
var value: Long = System.currentTimeMillis()
|
||||
@Bindable get
|
||||
private set(value) {
|
||||
field = value
|
||||
|
||||
notifyPropertyChanged(BR.value)
|
||||
}
|
||||
|
||||
fun update() {
|
||||
value = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.github.kr328.clash.design.ui
|
||||
|
||||
import androidx.databinding.BaseObservable
|
||||
import androidx.databinding.Bindable
|
||||
import com.github.kr328.clash.design.BR
|
||||
|
||||
class Surface : BaseObservable() {
|
||||
var insets: Insets = Insets.EMPTY
|
||||
@Bindable get
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
notifyPropertyChanged(BR.insets)
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.github.kr328.clash.design.ui
|
||||
|
||||
enum class ToastDuration {
|
||||
Short, Long, Indefinite
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.view.ActivityBarLayout
|
||||
|
||||
fun ActivityBarLayout.applyFrom(context: Context) {
|
||||
if (context is Activity) {
|
||||
findViewById<ImageView>(R.id.activity_bar_close_view)?.apply {
|
||||
setOnClickListener {
|
||||
context.onBackPressed()
|
||||
}
|
||||
}
|
||||
findViewById<TextView>(R.id.activity_bar_title_view)?.apply {
|
||||
text = context.title
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import com.github.kr328.clash.common.compat.foreground
|
||||
import com.github.kr328.clash.design.model.AppInfo
|
||||
|
||||
fun PackageInfo.toAppInfo(pm: PackageManager): AppInfo {
|
||||
return AppInfo(
|
||||
packageName = packageName,
|
||||
icon = applicationInfo.loadIcon(pm).foreground(),
|
||||
label = applicationInfo.loadLabel(pm).toString(),
|
||||
installTime = firstInstallTime,
|
||||
updateDate = lastUpdateTime,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import android.view.View
|
||||
import androidx.databinding.BindingAdapter
|
||||
|
||||
@BindingAdapter("android:minHeight")
|
||||
fun bindMinHeight(view: View, value: Float) {
|
||||
view.minimumHeight = value.toInt()
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.text.Spanned
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.DimenRes
|
||||
import androidx.annotation.StringRes
|
||||
import com.github.kr328.clash.common.compat.fromHtmlCompat
|
||||
|
||||
val Context.layoutInflater: LayoutInflater
|
||||
get() = LayoutInflater.from(this)
|
||||
|
||||
val Context.root: ViewGroup?
|
||||
get() {
|
||||
return when (this) {
|
||||
is Activity -> {
|
||||
findViewById(android.R.id.content)
|
||||
}
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.getPixels(@DimenRes resId: Int): Int {
|
||||
return resources.getDimensionPixelSize(resId)
|
||||
}
|
||||
|
||||
fun Context.getHtml(@StringRes resId: Int): Spanned {
|
||||
return fromHtmlCompat(getString(resId))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
|
||||
fun <T> List<T>.diffWith(
|
||||
newList: List<T>,
|
||||
detectMove: Boolean = false,
|
||||
id: (T) -> Any? = { it }
|
||||
): DiffUtil.DiffResult {
|
||||
val oldList = this
|
||||
|
||||
return DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
return id(oldList[oldItemPosition]) == id(newList[newItemPosition])
|
||||
}
|
||||
|
||||
override fun getOldListSize(): Int {
|
||||
return oldList.size
|
||||
}
|
||||
|
||||
override fun getNewListSize(): Int {
|
||||
return newList.size
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
return oldList[oldItemPosition] == newList[newItemPosition]
|
||||
}
|
||||
}, detectMove)
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.view.ActivityBarLayout
|
||||
import com.github.kr328.clash.design.view.ObservableScrollView
|
||||
|
||||
private class AppBarElevationController(
|
||||
private val activityBar: ActivityBarLayout
|
||||
) {
|
||||
private var animator: ValueAnimator? = null
|
||||
|
||||
var elevated: Boolean = false
|
||||
set(value) {
|
||||
if (field == value)
|
||||
return
|
||||
|
||||
field = value
|
||||
|
||||
animator?.end()
|
||||
|
||||
animator = if (value) {
|
||||
ValueAnimator.ofFloat(
|
||||
activityBar.elevation,
|
||||
activityBar.context.getPixels(R.dimen.toolbar_elevation).toFloat()
|
||||
)
|
||||
} else {
|
||||
ValueAnimator.ofFloat(
|
||||
activityBar.elevation,
|
||||
0f
|
||||
)
|
||||
}.apply {
|
||||
addUpdateListener {
|
||||
activityBar.elevation = it.animatedValue as Float
|
||||
}
|
||||
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun RecyclerView.bindAppBarElevation(activityBar: ActivityBarLayout) {
|
||||
addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
private val controller = AppBarElevationController(activityBar)
|
||||
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
controller.elevated = !recyclerView.isTop
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun ObservableScrollView.bindAppBarElevation(activityBar: ActivityBarLayout) {
|
||||
val controller = AppBarElevationController(activityBar)
|
||||
|
||||
addOnScrollChangedListener { view, _, _, _, _ ->
|
||||
controller.elevated = !view.isTop
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.common.compat.preferredLocale
|
||||
import com.github.kr328.clash.core.model.Provider
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.service.model.Profile
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
private const val DATE_DATE_ONLY = "yyyy-MM-dd"
|
||||
private const val DATE_TIME_ONLY = "HH:mm:ss.SSS"
|
||||
private const val DATE_ALL = "$DATE_DATE_ONLY $DATE_TIME_ONLY"
|
||||
|
||||
fun Profile.Type.toString(context: Context): String {
|
||||
return when (this) {
|
||||
Profile.Type.File -> context.getString(R.string.file)
|
||||
Profile.Type.Url -> context.getString(R.string.url)
|
||||
Profile.Type.External -> context.getString(R.string.external)
|
||||
}
|
||||
}
|
||||
|
||||
fun Provider.type(context: Context): String {
|
||||
val type = when (type) {
|
||||
Provider.Type.Proxy -> context.getString(R.string.proxy)
|
||||
Provider.Type.Rule -> context.getString(R.string.rule)
|
||||
}
|
||||
|
||||
val vehicle = when (vehicleType) {
|
||||
Provider.VehicleType.HTTP -> context.getString(R.string.http)
|
||||
Provider.VehicleType.File -> context.getString(R.string.file)
|
||||
Provider.VehicleType.Compatible -> context.getString(R.string.compatible)
|
||||
}
|
||||
|
||||
return context.getString(R.string.format_provider_type, type, vehicle)
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun Date.format(
|
||||
context: Context,
|
||||
includeDate: Boolean = true,
|
||||
includeTime: Boolean = true,
|
||||
): String {
|
||||
val locale = context.resources.configuration.preferredLocale
|
||||
|
||||
return when {
|
||||
includeDate && includeTime ->
|
||||
SimpleDateFormat(DATE_ALL, locale).format(this)
|
||||
includeDate ->
|
||||
SimpleDateFormat(DATE_DATE_ONLY, locale).format(this)
|
||||
includeTime ->
|
||||
SimpleDateFormat(DATE_TIME_ONLY, locale).format(this)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
fun Long.toBytesString(): String {
|
||||
return when {
|
||||
this > 1024.0 * 1024 * 1024 * 1024 * 1024 * 1024 ->
|
||||
String.format("%.2f EiB", (this.toDouble() / 1024 / 1024 / 1024 / 1024 / 1024 / 1024))
|
||||
this > 1024.0 * 1024 * 1024 * 1024 * 1024 ->
|
||||
String.format("%.2f PiB", (this.toDouble() / 1024 / 1024 / 1024 / 1024 / 1024))
|
||||
this > 1024.0 * 1024 * 1024 * 1024 ->
|
||||
String.format("%.2f TiB", (this.toDouble() / 1024 / 1024 / 1024 / 1024))
|
||||
this > 1024 * 1024 * 1024 ->
|
||||
String.format("%.2f GiB", (this.toDouble() / 1024 / 1024 / 1024))
|
||||
this > 1024 * 1024 ->
|
||||
String.format("%.2f MiB", (this.toDouble() / 1024 / 1024))
|
||||
this > 1024 ->
|
||||
String.format("%.2f KiB", (this.toDouble() / 1024))
|
||||
else ->
|
||||
"$this Bytes"
|
||||
}
|
||||
}
|
||||
|
||||
fun Double.toProgress(): Int {
|
||||
return this.toInt()
|
||||
}
|
||||
fun Long.toDateStr(): String {
|
||||
val simpleDateFormat =SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
|
||||
return simpleDateFormat.format(Date(this))
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.github.kr328.clash.design.ui.Insets
|
||||
|
||||
fun View.setOnInsertsChangedListener(adaptLandscape: Boolean = true, listener: (Insets) -> Unit) {
|
||||
setOnApplyWindowInsetsListener { v, ins ->
|
||||
val compat = WindowInsetsCompat.toWindowInsetsCompat(ins)
|
||||
val insets = compat.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
|
||||
val rInsets = if (ViewCompat.getLayoutDirection(v) == ViewCompat.LAYOUT_DIRECTION_LTR) {
|
||||
Insets(
|
||||
insets.left,
|
||||
insets.top,
|
||||
insets.right,
|
||||
insets.bottom,
|
||||
)
|
||||
} else {
|
||||
Insets(
|
||||
insets.right,
|
||||
insets.top,
|
||||
insets.left,
|
||||
insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
listener(if (adaptLandscape) rInsets.landscape(v.context) else rInsets)
|
||||
|
||||
compat.toWindowInsets()!!
|
||||
|
||||
|
||||
}
|
||||
|
||||
requestApplyInsets()
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.design.R
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
fun Long.elapsedIntervalString(context: Context): String {
|
||||
val day = TimeUnit.MILLISECONDS.toDays(this)
|
||||
val hour = TimeUnit.MILLISECONDS.toHours(this)
|
||||
val minute = TimeUnit.MILLISECONDS.toMinutes(this)
|
||||
|
||||
return when {
|
||||
day > 0 -> context.getString(R.string.format_days_ago, day)
|
||||
hour > 0 -> context.getString(R.string.format_hours_ago, hour)
|
||||
minute > 0 -> context.getString(R.string.format_minutes_ago, minute)
|
||||
else -> context.getString(R.string.recently)
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import android.content.Context
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.ui.Insets
|
||||
|
||||
fun Insets.landscape(context: Context): Insets {
|
||||
val displayMetrics = context.resources.displayMetrics
|
||||
val minWidth = context.getPixels(R.dimen.surface_landscape_min_width)
|
||||
|
||||
val width = displayMetrics.widthPixels
|
||||
val height = displayMetrics.heightPixels
|
||||
|
||||
return if (width > height && width > minWidth) {
|
||||
val expectedWidth = width.coerceAtMost(height.coerceAtLeast(minWidth))
|
||||
|
||||
val padding = (width - expectedWidth).coerceAtLeast(start + end) / 2
|
||||
|
||||
copy(start = padding.coerceAtLeast(start), end = padding.coerceAtLeast(end))
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.view.View.MeasureSpec
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ListAdapter
|
||||
|
||||
fun ListAdapter.measureWidth(context: Context): Int {
|
||||
val parent = FrameLayout(context)
|
||||
|
||||
val widthMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
|
||||
val heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
|
||||
|
||||
var itemView: View? = null
|
||||
var maxWidth = 0
|
||||
var itemType = 0
|
||||
|
||||
for (i in 0 until count) {
|
||||
val positionType = getItemViewType(i)
|
||||
if (positionType != itemType) {
|
||||
itemType = positionType
|
||||
itemView = null
|
||||
}
|
||||
|
||||
itemView = getView(i, itemView, parent)
|
||||
itemView.measure(widthMeasureSpec, heightMeasureSpec)
|
||||
|
||||
val itemWidth: Int = itemView.measuredWidth
|
||||
|
||||
if (itemWidth > maxWidth) {
|
||||
maxWidth = itemWidth
|
||||
}
|
||||
}
|
||||
|
||||
return maxWidth
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.core.view.children
|
||||
import androidx.databinding.Observable
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.kr328.clash.design.BR
|
||||
import com.github.kr328.clash.design.ui.Surface
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.reflect.KMutableProperty0
|
||||
|
||||
fun RecyclerView.applyLinearAdapter(context: Context, adapter: RecyclerView.Adapter<*>) {
|
||||
this.layoutManager = LinearLayoutManager(context)
|
||||
this.adapter = adapter
|
||||
}
|
||||
|
||||
suspend fun <H : RecyclerView.ViewHolder, T> RecyclerView.Adapter<H>.swapDataSet(
|
||||
property: KMutableProperty0<List<T>>,
|
||||
newDataset: List<T>,
|
||||
compareEquals: Boolean = true
|
||||
) {
|
||||
val ignore = withContext(Dispatchers.Default) {
|
||||
compareEquals && property.get() == newDataset
|
||||
}
|
||||
|
||||
if (ignore) return
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (property.get().size == newDataset.size) {
|
||||
property.set(newDataset)
|
||||
|
||||
notifyItemRangeChanged(0, newDataset.size)
|
||||
} else {
|
||||
notifyItemRangeRemoved(0, property.get().size)
|
||||
|
||||
property.set(newDataset)
|
||||
|
||||
notifyItemRangeInserted(0, newDataset.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <H : RecyclerView.ViewHolder, T> RecyclerView.Adapter<H>.patchDataSet(
|
||||
property: KMutableProperty0<List<T>>,
|
||||
newDataset: List<T>,
|
||||
detectMove: Boolean = false,
|
||||
id: (T) -> Any? = { it }
|
||||
) {
|
||||
val result = withContext(Dispatchers.Default) {
|
||||
property.get().diffWith(newDataset, detectMove, id)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
property.set(newDataset)
|
||||
result.dispatchUpdatesTo(this@patchDataSet)
|
||||
}
|
||||
}
|
||||
|
||||
fun RecyclerView.invalidateChildren() {
|
||||
children.forEach {
|
||||
it.postInvalidate()
|
||||
}
|
||||
}
|
||||
|
||||
fun RecyclerView.bindInsets(surface: Surface, top: Int = 0, bottom: Int = 0) {
|
||||
fun applyInsets() {
|
||||
val t = surface.insets.top + top
|
||||
val b = surface.insets.bottom + bottom
|
||||
|
||||
setPaddingRelative(0, t, 0, b)
|
||||
}
|
||||
|
||||
surface.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
|
||||
override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
|
||||
if (propertyId == BR.insets) {
|
||||
applyInsets()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
applyInsets()
|
||||
}
|
||||
|
||||
fun RecyclerView.addScrolledToBottomObserver(listener: (RecyclerView, Boolean) -> Unit) {
|
||||
val observer = object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
||||
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
|
||||
listener(this@addScrolledToBottomObserver, recyclerView.isBottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addOnScrollListener(observer)
|
||||
}
|
||||
|
||||
val RecyclerView.firstVisibleView: View?
|
||||
get() {
|
||||
return when (val mgr = layoutManager) {
|
||||
is LinearLayoutManager ->
|
||||
mgr.findViewByPosition(mgr.findFirstVisibleItemPosition())
|
||||
else ->
|
||||
throw UnsupportedOperationException("unsupported manager: $mgr")
|
||||
}
|
||||
}
|
||||
|
||||
val RecyclerView.isTop: Boolean
|
||||
get() = computeHorizontalScrollOffset() == 0 && computeVerticalScrollOffset() == 0
|
||||
|
||||
val RecyclerView.isBottom: Boolean
|
||||
get() {
|
||||
return when (val mgr = layoutManager) {
|
||||
is GridLayoutManager -> {
|
||||
mgr.findFirstVisibleItemPosition() != 0 &&
|
||||
mgr.findLastVisibleItemPosition() == adapter!!.itemCount - 1
|
||||
}
|
||||
else -> {
|
||||
throw UnsupportedOperationException("unsupported layout manager")
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import com.github.kr328.clash.design.view.ObservableScrollView
|
||||
|
||||
val ObservableScrollView.isTop: Boolean
|
||||
get() = scrollX == 0 && scrollY == 0
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.StyleRes
|
||||
import com.github.kr328.clash.common.compat.getDrawableCompat
|
||||
import com.github.kr328.clash.design.R
|
||||
|
||||
interface ClickableScope {
|
||||
fun focusable(defaultValue: Boolean): Boolean
|
||||
fun clickable(defaultValue: Boolean): Boolean
|
||||
fun background(): Drawable?
|
||||
fun foreground(): Drawable?
|
||||
}
|
||||
|
||||
val Context.selectableItemBackground: Drawable?
|
||||
get() {
|
||||
return getDrawableCompat(resolveThemedResourceId(android.R.attr.selectableItemBackground))
|
||||
}
|
||||
|
||||
fun Context.resolveClickableAttrs(
|
||||
attributeSet: AttributeSet?,
|
||||
@AttrRes defaultAttrRes: Int = 0,
|
||||
@StyleRes defaultStyleRes: Int = 0,
|
||||
block: ClickableScope.() -> Unit,
|
||||
) {
|
||||
theme.obtainStyledAttributes(
|
||||
attributeSet,
|
||||
R.styleable.Clickable,
|
||||
defaultAttrRes,
|
||||
defaultStyleRes
|
||||
).apply {
|
||||
val impl = object : ClickableScope {
|
||||
override fun focusable(defaultValue: Boolean): Boolean {
|
||||
return getBoolean(R.styleable.Clickable_android_focusable, defaultValue)
|
||||
}
|
||||
|
||||
override fun clickable(defaultValue: Boolean): Boolean {
|
||||
return getBoolean(R.styleable.Clickable_android_clickable, defaultValue)
|
||||
}
|
||||
|
||||
override fun background(): Drawable? {
|
||||
return getDrawable(R.styleable.Clickable_android_background)
|
||||
}
|
||||
|
||||
override fun foreground(): Drawable? {
|
||||
return getDrawable(R.styleable.Clickable_android_focusable)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl.apply(block)
|
||||
|
||||
recycle()
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.resolveThemedColor(@AttrRes resId: Int): Int {
|
||||
return TypedValue().apply {
|
||||
theme.resolveAttribute(resId, this, true)
|
||||
}.data
|
||||
}
|
||||
|
||||
fun Context.resolveThemedBoolean(@AttrRes resId: Int): Boolean {
|
||||
return TypedValue().apply {
|
||||
theme.resolveAttribute(resId, this, true)
|
||||
}.data != 0
|
||||
}
|
||||
|
||||
fun Context.resolveThemedResourceId(@AttrRes resId: Int): Int {
|
||||
return TypedValue().apply {
|
||||
theme.resolveAttribute(resId, this, true)
|
||||
}.resourceId
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import com.github.kr328.clash.design.Design
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.ui.ToastDuration
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
suspend fun Design<*>.showExceptionToast(message: CharSequence) {
|
||||
showToast(message, ToastDuration.Long) {
|
||||
setAction(R.string.detail) {
|
||||
MaterialAlertDialogBuilder(it.context)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(message)
|
||||
.setCancelable(true)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Design<*>.showExceptionToast(exception: Exception) {
|
||||
showExceptionToast(exception.message ?: "Unknown")
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import com.github.kr328.clash.common.util.PatternFileName
|
||||
|
||||
typealias Validator = (String) -> Boolean
|
||||
|
||||
val ValidatorAcceptAll: Validator = {
|
||||
true
|
||||
}
|
||||
|
||||
val ValidatorFileName: Validator = {
|
||||
PatternFileName.matches(it) && it.isNotBlank()
|
||||
}
|
||||
|
||||
val ValidatorNotBlank: Validator = {
|
||||
it.isNotBlank()
|
||||
}
|
||||
|
||||
val ValidatorHttpUrl: Validator = {
|
||||
it.startsWith("https://", ignoreCase = true) || it.startsWith("http://", ignoreCase = true)
|
||||
}
|
||||
|
||||
val ValidatorAutoUpdateInterval: Validator = {
|
||||
it.isEmpty() || (it.toLongOrNull() ?: 0) >= 15
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.github.kr328.clash.design.util
|
||||
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.core.content.getSystemService
|
||||
|
||||
fun View.requestTextInput() {
|
||||
post {
|
||||
requestFocus()
|
||||
|
||||
postDelayed({
|
||||
context.getSystemService<InputMethodManager>()
|
||||
?.showSoftInput(this, 0)
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.github.kr328.clash.design.view
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.StyleRes
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.databinding.ComponentActionLabelBinding
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
|
||||
class ActionLabel @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attributeSet: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
@StyleRes defStyleRes: Int = 0
|
||||
) : FrameLayout(context, attributeSet, defStyleAttr, defStyleRes) {
|
||||
private val binding = ComponentActionLabelBinding
|
||||
.inflate(context.layoutInflater, this, true)
|
||||
|
||||
var icon: Drawable?
|
||||
get() = binding.iconView.background
|
||||
set(value) {
|
||||
binding.iconView.background = value
|
||||
}
|
||||
|
||||
var text: CharSequence?
|
||||
get() = binding.textView.text
|
||||
set(value) {
|
||||
binding.textView.text = value
|
||||
}
|
||||
|
||||
var subtext: CharSequence?
|
||||
get() = binding.subtextView.text
|
||||
set(value) {
|
||||
binding.subtextView.text = value
|
||||
binding.subtextView.visibility = if (value == null) View.GONE else View.VISIBLE
|
||||
}
|
||||
|
||||
override fun setOnClickListener(l: OnClickListener?) {
|
||||
binding.root.setOnClickListener(l)
|
||||
}
|
||||
|
||||
init {
|
||||
context.theme.obtainStyledAttributes(
|
||||
attributeSet,
|
||||
R.styleable.ActionLabel,
|
||||
defStyleAttr,
|
||||
defStyleRes
|
||||
).apply {
|
||||
try {
|
||||
icon = getDrawable(R.styleable.ActionLabel_icon)
|
||||
text = getString(R.styleable.ActionLabel_text)
|
||||
subtext = getString(R.styleable.ActionLabel_subtext)
|
||||
} finally {
|
||||
recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.github.kr328.clash.design.view
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.StyleRes
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.databinding.ComponentActionTextFieldBinding
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
|
||||
class ActionTextField @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attributeSet: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
@StyleRes defStyleRes: Int = 0
|
||||
) : FrameLayout(context, attributeSet, defStyleAttr, defStyleRes) {
|
||||
private val binding = ComponentActionTextFieldBinding
|
||||
.inflate(context.layoutInflater, this, true)
|
||||
|
||||
var icon: Drawable?
|
||||
get() = binding.iconView.background
|
||||
set(value) {
|
||||
binding.iconView.background = value
|
||||
}
|
||||
|
||||
var title: CharSequence?
|
||||
get() = binding.titleView.text
|
||||
set(value) {
|
||||
binding.titleView.text = value
|
||||
}
|
||||
|
||||
var text: CharSequence?
|
||||
get() = binding.textView.text
|
||||
set(value) {
|
||||
if (isEnabled)
|
||||
binding.textView.text = value
|
||||
else
|
||||
binding.textView.text = context.getText(R.string.unavailable)
|
||||
}
|
||||
|
||||
var placeholder: CharSequence?
|
||||
get() = binding.textView.hint
|
||||
set(value) {
|
||||
binding.textView.hint = value
|
||||
}
|
||||
|
||||
override fun setEnabled(enabled: Boolean) {
|
||||
super.setEnabled(enabled)
|
||||
|
||||
if (enabled) {
|
||||
binding.root.alpha = 1.0f
|
||||
binding.actionView.isFocusable = true
|
||||
binding.actionView.isClickable = true
|
||||
} else {
|
||||
binding.root.alpha = 0.33f
|
||||
binding.actionView.isFocusable = false
|
||||
binding.actionView.isClickable = false
|
||||
}
|
||||
|
||||
text = text
|
||||
}
|
||||
|
||||
override fun setOnClickListener(l: OnClickListener?) {
|
||||
binding.actionView.setOnClickListener(l)
|
||||
}
|
||||
|
||||
init {
|
||||
context.theme.obtainStyledAttributes(
|
||||
attributeSet,
|
||||
R.styleable.ActionTextField,
|
||||
defStyleAttr,
|
||||
defStyleRes
|
||||
).apply {
|
||||
try {
|
||||
isEnabled = getBoolean(R.styleable.ActionTextField_enabled, true)
|
||||
icon = getDrawable(R.styleable.ActionTextField_icon)
|
||||
title = getString(R.styleable.ActionTextField_title)
|
||||
text = getString(R.styleable.ActionTextField_text)
|
||||
placeholder = getString(R.styleable.ActionTextField_placeholder)
|
||||
} finally {
|
||||
recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.github.kr328.clash.design.view
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.StyleRes
|
||||
import com.github.kr328.clash.design.util.resolveThemedColor
|
||||
|
||||
class ActivityBarLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attributeSet: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
@StyleRes defStyleRes: Int = 0
|
||||
) : FrameLayout(context, attributeSet, defStyleAttr, defStyleRes) {
|
||||
init {
|
||||
alpha = 0.96f
|
||||
|
||||
setBackgroundColor(context.resolveThemedColor(android.R.attr.windowBackground))
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
|
||||
super.dispatchTouchEvent(ev)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.github.kr328.clash.design.view
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class AppRecyclerView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attributeSet: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0
|
||||
) : RecyclerView(context, attributeSet, defStyleAttr) {
|
||||
init {
|
||||
isFocusable = false
|
||||
}
|
||||
|
||||
override fun onDraw(c: Canvas?) {
|
||||
super.onDraw(c)
|
||||
}
|
||||
|
||||
override fun dispatchDraw(canvas: Canvas?) {
|
||||
super.dispatchDraw(canvas)
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.github.kr328.clash.design.view
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.AttrRes
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.databinding.ComponentLargeActionLabelBinding
|
||||
import com.github.kr328.clash.design.util.*
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
|
||||
class LargeActionCard @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attributeSet: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0
|
||||
) : MaterialCardView(context, attributeSet, defStyleAttr) {
|
||||
private val binding = ComponentLargeActionLabelBinding
|
||||
.inflate(context.layoutInflater, this, true)
|
||||
|
||||
var text: CharSequence?
|
||||
get() = binding.textView.text
|
||||
set(value) {
|
||||
binding.textView.text = value
|
||||
}
|
||||
|
||||
var subtext: CharSequence?
|
||||
get() = binding.subtextView.text
|
||||
set(value) {
|
||||
binding.subtextView.text = value
|
||||
}
|
||||
|
||||
var icon: Drawable?
|
||||
get() = binding.iconView.background
|
||||
set(value) {
|
||||
binding.iconView.background = value
|
||||
}
|
||||
|
||||
init {
|
||||
context.resolveClickableAttrs(attributeSet, defStyleAttr) {
|
||||
isFocusable = focusable(true)
|
||||
isClickable = clickable(true)
|
||||
foreground = foreground() ?: context.selectableItemBackground
|
||||
}
|
||||
|
||||
context.theme.obtainStyledAttributes(
|
||||
attributeSet,
|
||||
R.styleable.LargeActionCard,
|
||||
defStyleAttr,
|
||||
0
|
||||
).apply {
|
||||
try {
|
||||
icon = getDrawable(R.styleable.LargeActionCard_icon)
|
||||
text = getString(R.styleable.LargeActionCard_text)
|
||||
subtext = getString(R.styleable.LargeActionCard_subtext)
|
||||
} finally {
|
||||
recycle()
|
||||
}
|
||||
}
|
||||
|
||||
minimumHeight = context.getPixels(R.dimen.large_action_card_min_height)
|
||||
radius = context.getPixels(R.dimen.large_action_card_radius).toFloat()
|
||||
elevation = context.getPixels(R.dimen.large_action_card_elevation).toFloat()
|
||||
setCardBackgroundColor(context.resolveThemedColor(R.attr.colorSurface))
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.github.kr328.clash.design.view
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.StyleRes
|
||||
import com.github.kr328.clash.design.R
|
||||
import com.github.kr328.clash.design.databinding.ComponentLargeActionLabelBinding
|
||||
import com.github.kr328.clash.design.util.layoutInflater
|
||||
import com.github.kr328.clash.design.util.resolveClickableAttrs
|
||||
import com.github.kr328.clash.design.util.selectableItemBackground
|
||||
|
||||
class LargeActionLabel @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attributeSet: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
@StyleRes defStyleRes: Int = 0
|
||||
) : FrameLayout(context, attributeSet, defStyleAttr, defStyleRes) {
|
||||
private val binding = ComponentLargeActionLabelBinding
|
||||
.inflate(context.layoutInflater, this, true)
|
||||
|
||||
var icon: Drawable?
|
||||
get() = binding.iconView.background
|
||||
set(value) {
|
||||
binding.iconView.background = value
|
||||
}
|
||||
|
||||
var text: CharSequence?
|
||||
get() = binding.textView.text
|
||||
set(value) {
|
||||
binding.textView.text = value
|
||||
}
|
||||
|
||||
var subtext: CharSequence?
|
||||
get() = binding.subtextView.text
|
||||
set(value) {
|
||||
binding.subtextView.text = value
|
||||
|
||||
if (value == null) {
|
||||
binding.subtextView.visibility = View.GONE
|
||||
} else {
|
||||
binding.subtextView.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
context.resolveClickableAttrs(
|
||||
attributeSet,
|
||||
defStyleAttr,
|
||||
defStyleRes
|
||||
) {
|
||||
isFocusable = focusable(true)
|
||||
isClickable = clickable(true)
|
||||
background = background() ?: context.selectableItemBackground
|
||||
}
|
||||
|
||||
context.theme.obtainStyledAttributes(
|
||||
attributeSet,
|
||||
R.styleable.LargeActionLabel,
|
||||
defStyleAttr,
|
||||
defStyleRes
|
||||
).apply {
|
||||
try {
|
||||
icon = getDrawable(R.styleable.LargeActionLabel_icon)
|
||||
text = getString(R.styleable.LargeActionLabel_text)
|
||||
subtext = getString(R.styleable.LargeActionLabel_subtext)
|
||||
} finally {
|
||||
recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.github.kr328.clash.design.view
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.ScrollView
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.StyleRes
|
||||
|
||||
class ObservableScrollView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attributeSet: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
@StyleRes defStyleRes: Int = 0
|
||||
) : ScrollView(context, attributeSet, defStyleAttr, defStyleRes) {
|
||||
fun interface OnScrollChangedListener {
|
||||
fun onChanged(scrollView: ObservableScrollView, x: Int, y: Int, oldl: Int, oldt: Int)
|
||||
}
|
||||
|
||||
private val scrollChangedListeners: MutableSet<OnScrollChangedListener> = mutableSetOf()
|
||||
|
||||
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
|
||||
super.onScrollChanged(l, t, oldl, oldt)
|
||||
|
||||
scrollChangedListeners.forEach {
|
||||
it.onChanged(this, l, t, oldl, oldt)
|
||||
}
|
||||
}
|
||||
|
||||
fun addOnScrollChangedListener(listener: OnScrollChangedListener) {
|
||||
scrollChangedListeners.add(listener)
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.github.kr328.clash.design.view
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.widget.FrameLayout
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.tan
|
||||
|
||||
class VerticalScrollableHost @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attributeSet: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
defStyleRes: Int = 0
|
||||
) : FrameLayout(context, attributeSet, defStyleAttr, defStyleRes) {
|
||||
private var initialX = 0f
|
||||
private var initialY = 0f
|
||||
|
||||
private val degree = tan(Math.toRadians(15.0))
|
||||
|
||||
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
|
||||
val parentView = parent ?: return super.onInterceptTouchEvent(ev)
|
||||
|
||||
if (ev.action == MotionEvent.ACTION_DOWN) {
|
||||
initialX = ev.x
|
||||
initialY = ev.y
|
||||
parentView.requestDisallowInterceptTouchEvent(true)
|
||||
} else if (ev.action == MotionEvent.ACTION_MOVE) {
|
||||
val dx = ev.x - initialX
|
||||
val dy = ev.y - initialY
|
||||
|
||||
val t = dy.absoluteValue / dx.absoluteValue
|
||||
|
||||
if (t < degree) {
|
||||
parentView.requestDisallowInterceptTouchEvent(false)
|
||||
}
|
||||
}
|
||||
|
||||
return super.onInterceptTouchEvent(ev)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user