init
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
package com.sw.inbound.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
|
||||
/**
|
||||
* Context 工具类
|
||||
*/
|
||||
object ContextUtils {
|
||||
|
||||
// ========== Composable 内获取 ==========
|
||||
|
||||
/**
|
||||
* 获取当前 Composable 的 Activity Context
|
||||
* 只能在 @Composable 函数中调用
|
||||
*/
|
||||
@Composable
|
||||
fun getActivityContext(): Context {
|
||||
return LocalContext.current
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 Composable 的 View
|
||||
*/
|
||||
@Composable
|
||||
fun getLocalView(): View {
|
||||
return LocalView.current
|
||||
}
|
||||
|
||||
// ========== 非 Composable 环境获取 ==========
|
||||
|
||||
/**
|
||||
* 通过静态 Application 引用获取
|
||||
* 需要在 Application 类中初始化
|
||||
*/
|
||||
private var _applicationContext: Context? = null
|
||||
|
||||
fun initAppContext(context: Context) {
|
||||
_applicationContext = context.applicationContext
|
||||
}
|
||||
|
||||
fun getAppContext(): Context {
|
||||
return _applicationContext ?: throw IllegalStateException(
|
||||
"Application context not initialized. Call initAppContext() first."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.sw.inbound.utils
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Process
|
||||
import com.sw.inbound.MainActivity
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
/**
|
||||
* 崩溃处理
|
||||
*/
|
||||
class CrashHandler private constructor(private val context: Context) :
|
||||
Thread.UncaughtExceptionHandler {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CrashHandler"
|
||||
private const val CRASH_REPORTS_DIR = "crash_reports"
|
||||
private const val LOG_LINES = 500 // 收集最近500行日志
|
||||
|
||||
@Volatile
|
||||
private var instance: CrashHandler? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
if (instance == null) {
|
||||
synchronized(CrashHandler::class.java) {
|
||||
if (instance == null) {
|
||||
instance = CrashHandler(context.applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCrashReportFiles(context: Context): Array<File> {
|
||||
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
|
||||
return if (crashDir.exists() && crashDir.isDirectory) {
|
||||
crashDir.listFiles { _, name -> name.endsWith(".log") } ?: emptyArray()
|
||||
} else {
|
||||
emptyArray()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCrashReports(context: Context) {
|
||||
getCrashReportFiles(context).forEach { it.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
private val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
|
||||
init {
|
||||
Thread.setDefaultUncaughtExceptionHandler(this)
|
||||
}
|
||||
|
||||
override fun uncaughtException(thread: Thread, ex: Throwable) {
|
||||
handleException(thread, ex)
|
||||
|
||||
// 如果系统提供了默认的异常处理器,则交给系统去结束程序
|
||||
// 否则自己结束程序
|
||||
defaultHandler?.uncaughtException(thread, ex) ?: run {
|
||||
Process.killProcess(Process.myPid())
|
||||
exitProcess(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动重启app
|
||||
*/
|
||||
private fun restartApp() {
|
||||
// 延迟1秒后重启应用
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, 0, intent,
|
||||
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + 100, pendingIntent)
|
||||
|
||||
Process.killProcess(Process.myPid())
|
||||
exitProcess(1)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
private fun handleException(thread: Thread, ex: Throwable) {
|
||||
// 收集设备信息和异常信息
|
||||
val crashInfo = collectCrashInfo(thread, ex)
|
||||
|
||||
// 保存日志文件
|
||||
saveCrashInfoToFile(crashInfo)
|
||||
|
||||
// 这里可以添加其他处理逻辑,比如上传到服务器等
|
||||
}
|
||||
|
||||
private fun collectCrashInfo(thread: Thread, ex: Throwable): String {
|
||||
return buildString {
|
||||
// 收集设备信息
|
||||
collectDeviceInfo(this)
|
||||
|
||||
// 收集应用日志
|
||||
append("\n\n").append(collectLogs())
|
||||
|
||||
// 收集线程和异常信息
|
||||
append("\n\n========== Thread & Exception Info ==========\n")
|
||||
append("Thread: ${thread.name}\n")
|
||||
append("Stack Trace:\n")
|
||||
|
||||
val sw = StringWriter()
|
||||
val pw = PrintWriter(sw)
|
||||
ex.printStackTrace(pw)
|
||||
var cause: Throwable? = ex.cause
|
||||
while (cause != null) {
|
||||
cause.printStackTrace(pw)
|
||||
cause = cause.cause
|
||||
}
|
||||
pw.close()
|
||||
append(sw.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectDeviceInfo(sb: StringBuilder) {
|
||||
sb.append("========== Device Info ==========\n")
|
||||
try {
|
||||
// 应用信息
|
||||
val pm = context.packageManager
|
||||
val pi = pm.getPackageInfo(context.packageName, 0)
|
||||
|
||||
sb.append("App Version: ${pi.versionName}_${pi.versionCode}\n")
|
||||
|
||||
// Android 版本信息
|
||||
sb.append("OS Version: ${Build.VERSION.RELEASE}_${Build.VERSION.SDK_INT}\n")
|
||||
|
||||
// 设备信息
|
||||
sb.append("Vendor: ${Build.MANUFACTURER}\n")
|
||||
sb.append("Model: ${Build.MODEL}\n")
|
||||
sb.append("CPU ABI: ${Build.SUPPORTED_ABIS[0]}\n")
|
||||
|
||||
// 其他信息
|
||||
sb.append("Locale: ${Locale.getDefault()}\n")
|
||||
sb.append(
|
||||
"Current Time: ${
|
||||
SimpleDateFormat(
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
Locale.getDefault()
|
||||
).format(Date())
|
||||
}\n"
|
||||
)
|
||||
|
||||
// 内存信息
|
||||
val memoryInfo = ActivityManager.MemoryInfo()
|
||||
val activityManager =
|
||||
context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
activityManager.getMemoryInfo(memoryInfo)
|
||||
sb.append("Available Memory: ${memoryInfo.availMem / (1024 * 1024)}MB\n")
|
||||
sb.append("Total Memory: ${memoryInfo.totalMem / (1024 * 1024)}MB\n")
|
||||
sb.append("Low Memory: ${memoryInfo.lowMemory}\n")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error while collecting device info")
|
||||
sb.append("Error while collecting device info: ${e.message}\n")
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectLogs(): String {
|
||||
return buildString {
|
||||
append("========== Application Logs ==========\n")
|
||||
try {
|
||||
val process = Runtime.getRuntime().exec("logcat -d -v threadtime")
|
||||
val reader = process.inputStream.bufferedReader()
|
||||
|
||||
val logLines = reader.readLines()
|
||||
val start = maxOf(0, logLines.size - LOG_LINES)
|
||||
|
||||
logLines.subList(start, logLines.size).forEach {
|
||||
append(it).append("\n")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e, "Error collecting logs")
|
||||
append("Error collecting logs: ${e.message}\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveCrashInfoToFile(crashInfo: String) {
|
||||
try {
|
||||
val time = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault()).format(Date())
|
||||
val fileName = "crash_$time.log"
|
||||
|
||||
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
|
||||
if (!crashDir.exists() && !crashDir.mkdirs()) {
|
||||
Timber.tag(TAG).e("Failed to create crash report directory")
|
||||
return
|
||||
}
|
||||
|
||||
val crashFile = File(crashDir, fileName)
|
||||
FileOutputStream(crashFile).use { it.write(crashInfo.toByteArray()) }
|
||||
|
||||
Timber.tag(TAG).d("Crash info saved to: ${crashFile.absolutePath}")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Error saving crash info to file")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理旧的崩溃日志
|
||||
*/
|
||||
fun cleanupOldCrashReports(maxAgeDays: Int = 7) {
|
||||
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
|
||||
if (!crashDir.exists() || !crashDir.isDirectory) return
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val maxAgeMillis = maxAgeDays * 24 * 60 * 60 * 1000L
|
||||
|
||||
crashDir.listFiles()?.forEach { file ->
|
||||
if (file.lastModified() < now - maxAgeMillis) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.sw.inbound.utils
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 时间格式化工具类
|
||||
*/
|
||||
object DateTimeUtils {
|
||||
|
||||
/**
|
||||
* 获取完整中文日期格式(示例:2025年6月11日 星期三)
|
||||
*/
|
||||
fun getChineseDateString(date: Date = Date()): String {
|
||||
return SimpleDateFormat("yyyy年M月d日 EEEE", Locale.CHINA).format(date)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取带时间的完整中文格式(示例:2025年6月11日 星期三 14:30)
|
||||
*/
|
||||
fun getChineseDateTimeString(date: Date = Date()): String {
|
||||
return SimpleDateFormat("yyyy年M月d日 EEEE HH:mm:ss", Locale.CHINA).format(date)
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时时间流(每秒更新)
|
||||
*/
|
||||
fun realTimeChineseDateFlow() = flow {
|
||||
while (true) {
|
||||
emit(getChineseDateString())
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.sw.inbound.utils
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import java.io.File
|
||||
|
||||
object FileUtils {
|
||||
/**
|
||||
* 通过Uri删除文件
|
||||
* @param context 上下文
|
||||
* @param uri 文件Uri
|
||||
* @return Boolean 是否删除成功
|
||||
*/
|
||||
fun deleteFileWithUri(context: Context, uri: Uri): Boolean {
|
||||
return when {
|
||||
// 1. 处理 content:// 类型的Uri (MediaStore)
|
||||
uri.scheme.equals("content", ignoreCase = true) -> {
|
||||
deleteContentUriFile(context, uri)
|
||||
}
|
||||
// 2. 处理 file:// 类型的Uri
|
||||
uri.scheme.equals("file", ignoreCase = true) -> {
|
||||
deleteFileUriFile(uri)
|
||||
}
|
||||
// 3. 其他情况尝试直接解析路径
|
||||
else -> {
|
||||
deleteFileFromPath(uri.path ?: return false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除Content Uri文件
|
||||
private fun deleteContentUriFile(context: Context, uri: Uri): Boolean {
|
||||
return try {
|
||||
context.contentResolver.delete(uri, null, null) > 0
|
||||
} catch (e: SecurityException) {
|
||||
// Android 10+需要特殊处理
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
deleteMediaStoreFile(context, uri)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Android 10+删除MediaStore文件
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun deleteMediaStoreFile(context: Context, uri: Uri): Boolean {
|
||||
val contentResolver = context.contentResolver
|
||||
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
||||
|
||||
return try {
|
||||
contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val id =
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
|
||||
val contentUri = ContentUris.withAppendedId(uri, id)
|
||||
contentResolver.delete(contentUri, null, null) > 0
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除File Uri文件
|
||||
private fun deleteFileUriFile(uri: Uri): Boolean {
|
||||
return try {
|
||||
File(uri.path ?: return false).delete()
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// 直接通过路径删除文件
|
||||
private fun deleteFileFromPath(path: String): Boolean {
|
||||
return try {
|
||||
File(path).delete()
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.sw.inbound.utils
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.lang.reflect.Type
|
||||
|
||||
object GsonUtils {
|
||||
|
||||
// 默认的 Gson 实例
|
||||
private val defaultGson: Gson by lazy {
|
||||
GsonBuilder()
|
||||
.setDateFormat("yyyy-MM-dd HH:mm:ss") // 设置日期格式
|
||||
// .disableHtmlEscaping() // 禁止转义HTML标签
|
||||
.create()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认配置的 Gson 实例
|
||||
*/
|
||||
fun getGson(): Gson = defaultGson
|
||||
|
||||
/**
|
||||
* 将对象转换为 JSON 字符串
|
||||
* @param obj 要转换的对象
|
||||
* @return JSON 字符串
|
||||
*/
|
||||
fun toJson(obj: Any?): String {
|
||||
return if (obj == null) "" else defaultGson.toJson(obj)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 字符串转换为对象
|
||||
* @param json JSON 字符串
|
||||
* @param clazz 目标类
|
||||
* @return 转换后的对象
|
||||
*/
|
||||
fun <T> fromJson(json: String?, clazz: Class<T>): T? {
|
||||
if (json.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
defaultGson.fromJson(json, clazz)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 字符串转换为对象 (支持泛型)
|
||||
* @param json JSON 字符串
|
||||
* @param type 类型令牌,用于获取泛型类型
|
||||
* @return 转换后的对象
|
||||
*/
|
||||
fun <T> fromJson(json: String?, type: Type): T? {
|
||||
if (json.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
defaultGson.fromJson(json, type)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 字符串转换为 List 对象
|
||||
* @param json JSON 字符串
|
||||
* @param clazz List 中的元素类型
|
||||
* @return 转换后的 List 对象
|
||||
*/
|
||||
fun <T> fromJsonList(json: String?, clazz: Class<T>): List<T>? {
|
||||
if (json.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
val type = TypeToken.getParameterized(List::class.java, clazz).type
|
||||
defaultGson.fromJson<List<T>>(json, type)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 字符串转换为 Map 对象
|
||||
* @param json JSON 字符串
|
||||
* @param keyClazz Map 的 key 类型
|
||||
* @param valueClazz Map 的 value 类型
|
||||
* @return 转换后的 Map 对象
|
||||
*/
|
||||
fun <K, V> fromJsonMap(
|
||||
json: String?,
|
||||
keyClazz: Class<K>,
|
||||
valueClazz: Class<V>
|
||||
): Map<K, V>? {
|
||||
if (json.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
val type = TypeToken.getParameterized(Map::class.java, keyClazz, valueClazz).type
|
||||
defaultGson.fromJson<Map<K, V>>(json, type)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将对象转换为另一种类型的对象
|
||||
* @param obj 源对象
|
||||
* @param clazz 目标类型
|
||||
* @return 转换后的对象
|
||||
*/
|
||||
fun <T> convert(obj: Any?, clazz: Class<T>): T? {
|
||||
if (obj == null) {
|
||||
return null
|
||||
}
|
||||
return fromJson(toJson(obj), clazz)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.sw.inbound.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* file:///data/user/0/com.sw.inbound/cache/IMG_17515262353556856678814444882273.jpg
|
||||
*/
|
||||
object ImageUtils {
|
||||
// 从Uri获取File
|
||||
private fun getFileFromUri(context: Context, uri: Uri): File? {
|
||||
return when (uri.scheme) {
|
||||
"file" -> File(uri.path ?: return null)
|
||||
"content" -> {
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri) ?: return null
|
||||
val cacheDir = context.cacheDir
|
||||
val file = File.createTempFile(
|
||||
"upload_${System.currentTimeMillis()}",
|
||||
".jpg",
|
||||
cacheDir
|
||||
)
|
||||
file.outputStream().use { output ->
|
||||
inputStream.copyTo(output)
|
||||
}
|
||||
file
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun genRequestPart(context: Context, imageUri: Uri): MultipartBody.Part? {
|
||||
Timber.d("genRequestPart imageUri = $imageUri")
|
||||
// 1. 从Uri获取文件
|
||||
val file = getFileFromUri(context, imageUri)
|
||||
if (file == null) {
|
||||
Timber.e("getFileFromUri file is null")
|
||||
return null
|
||||
}
|
||||
// 2. 创建请求体
|
||||
val requestFile = RequestBody.create(
|
||||
"application/octet-stream".toMediaTypeOrNull(),
|
||||
file
|
||||
)
|
||||
val imagePart = MultipartBody.Part.createFormData(
|
||||
"file",
|
||||
file.name,
|
||||
requestFile
|
||||
)
|
||||
return imagePart
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package com.sw.inbound.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Jetpack Compose 交互工具集
|
||||
* 包含快速点击过滤、防抖、节流、双击检测、长按检测等功能
|
||||
*/
|
||||
object InteractionUtils {
|
||||
|
||||
// ======================== 点击过滤 ========================
|
||||
|
||||
/**
|
||||
* 快速点击过滤器
|
||||
* @param minInterval 最小点击间隔时间(毫秒),默认500ms
|
||||
*/
|
||||
class ClickFilter(private val minInterval: Long = 500L) {
|
||||
private var lastClickTime: Long = 0
|
||||
|
||||
/**
|
||||
* 处理点击事件
|
||||
* @return Boolean 是否允许此次点击(true=允许,false=拦截)
|
||||
*/
|
||||
fun processClick(): Boolean {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
return if (currentTime - lastClickTime > minInterval) {
|
||||
lastClickTime = currentTime
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理点击事件(带回调)
|
||||
*/
|
||||
fun processClick(block: () -> Unit) {
|
||||
if (processClick()) {
|
||||
block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记住点击过滤器
|
||||
*/
|
||||
@Composable
|
||||
fun rememberClickFilter(minInterval: Long = 500L): ClickFilter {
|
||||
return remember { ClickFilter(minInterval) }
|
||||
}
|
||||
|
||||
// ======================== 防抖处理 ========================
|
||||
|
||||
/**
|
||||
* 防抖处理器
|
||||
*/
|
||||
class Debouncer(
|
||||
private val delayMillis: Long = 300L,
|
||||
private val coroutineScope: CoroutineScope
|
||||
) {
|
||||
private var debounceJob: Job? = null
|
||||
|
||||
/**
|
||||
* 执行防抖操作
|
||||
*/
|
||||
fun <T> debounce(value: T, action: (T) -> Unit) {
|
||||
debounceJob?.cancel()
|
||||
debounceJob = coroutineScope.launch {
|
||||
delay(delayMillis)
|
||||
action(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记住防抖处理器
|
||||
*/
|
||||
@Composable
|
||||
fun rememberDebouncer(
|
||||
delayMillis: Long = 300L,
|
||||
coroutineScope: CoroutineScope = rememberCoroutineScope()
|
||||
): Debouncer {
|
||||
return remember { Debouncer(delayMillis, coroutineScope) }
|
||||
}
|
||||
|
||||
// ======================== 节流处理 ========================
|
||||
|
||||
/**
|
||||
* 节流处理器
|
||||
*/
|
||||
class Throttler(private val timeoutMs: Long = 300L) {
|
||||
private var lastRunTime: Long = 0
|
||||
|
||||
/**
|
||||
* 执行节流操作
|
||||
*/
|
||||
fun throttle(block: () -> Unit) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastRunTime > timeoutMs) {
|
||||
lastRunTime = now
|
||||
block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记住节流处理器
|
||||
*/
|
||||
@Composable
|
||||
fun rememberThrottler(timeoutMs: Long = 300L): Throttler {
|
||||
return remember { Throttler(timeoutMs) }
|
||||
}
|
||||
|
||||
// ======================== 双击检测 ========================
|
||||
|
||||
/**
|
||||
* 双击检测器
|
||||
*/
|
||||
class DoubleClickDetector(
|
||||
private val timeout: Long = 300L,
|
||||
private val onSingleClick: () -> Unit = {},
|
||||
private val onDoubleClick: () -> Unit
|
||||
) {
|
||||
private var clickCount by mutableStateOf(0)
|
||||
private var lastClickTime by mutableStateOf(0L)
|
||||
|
||||
/**
|
||||
* 处理点击事件
|
||||
*/
|
||||
fun processClick(coroutineScope: CoroutineScope) {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - lastClickTime < timeout) {
|
||||
clickCount++
|
||||
if (clickCount == 2) {
|
||||
onDoubleClick()
|
||||
clickCount = 0
|
||||
}
|
||||
} else {
|
||||
clickCount = 1
|
||||
coroutineScope.launch {
|
||||
delay(timeout)
|
||||
if (clickCount == 1) {
|
||||
onSingleClick()
|
||||
}
|
||||
clickCount = 0
|
||||
}
|
||||
}
|
||||
lastClickTime = currentTime
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记住双击检测器
|
||||
*/
|
||||
@Composable
|
||||
fun rememberDoubleClickDetector(
|
||||
timeout: Long = 300L,
|
||||
onSingleClick: () -> Unit = {},
|
||||
onDoubleClick: () -> Unit
|
||||
): () -> Unit {
|
||||
val detector = remember { DoubleClickDetector(timeout, onSingleClick, onDoubleClick) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
return {
|
||||
detector.processClick(scope)
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== 长按检测 ========================
|
||||
|
||||
/**
|
||||
* 长按检测器
|
||||
*/
|
||||
class LongPressDetector(
|
||||
private val delay: Long = 1000L,
|
||||
private val onLongPress: () -> Unit,
|
||||
private val onClick: () -> Unit = {}
|
||||
) {
|
||||
private var pressJob: Job? = null
|
||||
|
||||
/**
|
||||
* 处理按压事件
|
||||
*/
|
||||
fun handlePress(coroutineScope: CoroutineScope) {
|
||||
pressJob = coroutineScope.launch {
|
||||
delay(delay)
|
||||
onLongPress()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理释放事件
|
||||
*/
|
||||
fun handleRelease() {
|
||||
pressJob?.cancel()
|
||||
pressJob = null
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记住长按检测器
|
||||
*/
|
||||
@Composable
|
||||
fun rememberLongPressDetector(
|
||||
delay: Long = 1000L,
|
||||
onLongPress: () -> Unit,
|
||||
onClick: () -> Unit = {}
|
||||
): Pair<() -> Unit, () -> Unit> {
|
||||
val detector = remember { LongPressDetector(delay, onLongPress, onClick) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
return Pair(
|
||||
first = { detector.handlePress(scope) },
|
||||
second = { detector.handleRelease() }
|
||||
)
|
||||
}
|
||||
|
||||
// ======================== 组合工具 ========================
|
||||
|
||||
/**
|
||||
* 带状态的按钮控制器
|
||||
*/
|
||||
class StatefulButtonController {
|
||||
var isLoading by mutableStateOf(false)
|
||||
private val clickFilter = ClickFilter()
|
||||
|
||||
/**
|
||||
* 处理按钮点击
|
||||
*/
|
||||
suspend fun handleClick(block: suspend () -> Unit) {
|
||||
if (clickFilter.processClick()) {
|
||||
isLoading = true
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记住带状态的按钮控制器
|
||||
*/
|
||||
@Composable
|
||||
fun rememberStatefulButtonController(): StatefulButtonController {
|
||||
return remember { StatefulButtonController() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.sw.inbound.utils
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
class SPUtil private constructor(context: Context, private val spName: String) {
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: SPUtil? = null
|
||||
|
||||
fun getInstance(
|
||||
context: Context = ContextUtils.getAppContext(),
|
||||
spName: String = "default_sp"
|
||||
): SPUtil {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: SPUtil(context.applicationContext, spName).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val sharedPreferences by lazy {
|
||||
context.getSharedPreferences(spName, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
// 基础存储方法
|
||||
fun put(key: String, value: Any?) {
|
||||
when (value) {
|
||||
null -> remove(key) // 存入null视为删除
|
||||
is String -> sharedPreferences.edit { putString(key, value) }
|
||||
is Int -> sharedPreferences.edit { putInt(key, value) }
|
||||
is Long -> sharedPreferences.edit { putLong(key, value) }
|
||||
is Float -> sharedPreferences.edit { putFloat(key, value) }
|
||||
is Boolean -> sharedPreferences.edit { putBoolean(key, value) }
|
||||
is Set<*> -> sharedPreferences.edit { putStringSet(key, value as Set<String>) }
|
||||
else -> throw IllegalArgumentException("Unsupported type: ${value.javaClass.name}")
|
||||
}
|
||||
notifyDataChanged(key)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> get(key: String, defaultValue: T? = null): T? {
|
||||
return when (defaultValue) {
|
||||
is String -> sharedPreferences.getString(key, defaultValue) as T
|
||||
is Int -> sharedPreferences.getInt(key, defaultValue) as T
|
||||
is Long -> sharedPreferences.getLong(key, defaultValue) as T
|
||||
is Float -> sharedPreferences.getFloat(key, defaultValue) as T
|
||||
is Boolean -> sharedPreferences.getBoolean(key, defaultValue) as T
|
||||
is Set<*> -> sharedPreferences.getStringSet(key, defaultValue as Set<String>) as T
|
||||
null -> when {
|
||||
sharedPreferences.contains(key) -> get(key, "") as? T // 尝试作为String获取
|
||||
else -> null
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Unsupported type: ${defaultValue.javaClass.name}")
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.edit { remove(key) }
|
||||
notifyDataChanged(key)
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
sharedPreferences.edit { clear() }
|
||||
notifyDataChanged(null)
|
||||
}
|
||||
|
||||
fun contains(key: String): Boolean {
|
||||
return sharedPreferences.contains(key)
|
||||
}
|
||||
|
||||
// 监听变化
|
||||
private val dataChangeFlow = MutableStateFlow(0)
|
||||
|
||||
private fun notifyDataChanged(key: String?) {
|
||||
dataChangeFlow.value++
|
||||
}
|
||||
|
||||
fun observeKey(key: String): Flow<Any?> {
|
||||
return dataChangeFlow.map { get(key) }
|
||||
}
|
||||
|
||||
// 属性委托支持
|
||||
fun int(key: String, default: Int = 0) = SpProperty(key, default)
|
||||
fun long(key: String, default: Long = 0L) = SpProperty(key, default)
|
||||
fun float(key: String, default: Float = 0f) = SpProperty(key, default)
|
||||
fun boolean(key: String, default: Boolean = false) = SpProperty(key, default)
|
||||
fun string(key: String, default: String = "") = SpProperty(key, default)
|
||||
fun stringSet(key: String, default: Set<String> = emptySet()) = SpProperty(key, default)
|
||||
|
||||
inner class SpProperty<T>(private val key: String, private val defaultValue: T) :
|
||||
ReadWriteProperty<Any?, T> {
|
||||
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
|
||||
return get(key, defaultValue) ?: defaultValue
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
|
||||
put(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.sw.inbound.utils
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
|
||||
open class TextFieldState(
|
||||
initialText: String = "",
|
||||
initialSelection: TextRange = TextRange(initialText.length)
|
||||
) {
|
||||
private var _value by mutableStateOf(TextFieldValue(initialText, initialSelection))
|
||||
|
||||
open var value: TextFieldValue
|
||||
get() = _value
|
||||
set(newValue) {
|
||||
_value = newValue
|
||||
}
|
||||
|
||||
open fun updateFromString(text: String) {
|
||||
_value = TextFieldValue(text, TextRange(text.length))
|
||||
}
|
||||
|
||||
val text: String get() = _value.text
|
||||
}
|
||||
|
||||
class AmountFieldState(initialAmount: String = "") : TextFieldState(initialAmount) {
|
||||
// 获取原始数字字符串(不含格式字符)
|
||||
fun getRawAmount(): String = value.text.filter { it.isDigit() }
|
||||
|
||||
// 获取Double类型的金额值
|
||||
fun getAmountValue(): Double = value.text
|
||||
.filter { it.isDigit() || it == '.' }
|
||||
.toDoubleOrNull() ?: 0.0
|
||||
|
||||
// 重写value的setter以实现金额格式化
|
||||
override var value: TextFieldValue
|
||||
get() = super.value
|
||||
set(newValue) {
|
||||
super.value = formatAmountValue(newValue)
|
||||
}
|
||||
|
||||
// 从外部更新金额(如从数据库加载)
|
||||
override fun updateFromString(amount: String) {
|
||||
super.updateFromString(formatAmount(amount))
|
||||
}
|
||||
|
||||
private fun formatAmountValue(input: TextFieldValue): TextFieldValue {
|
||||
val filtered = input.text.filter { it.isDigit() }
|
||||
val formatted = formatAmount(filtered)
|
||||
|
||||
// 计算新光标位置
|
||||
val newCursorPos = calculateNewCursorPosition(
|
||||
originalText = input.text,
|
||||
originalSelection = input.selection,
|
||||
filteredText = filtered,
|
||||
formattedText = formatted
|
||||
)
|
||||
|
||||
return TextFieldValue(
|
||||
text = formatted,
|
||||
selection = TextRange(newCursorPos)
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatAmount(amount: String): String {
|
||||
val filtered = amount.filter { it.isDigit() }
|
||||
return when {
|
||||
filtered.isEmpty() -> "0.00"
|
||||
filtered.length <= 2 -> "0.${filtered.padStart(2, '0')}"
|
||||
else -> "${filtered.dropLast(2)}.${filtered.takeLast(2)}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateNewCursorPosition(
|
||||
originalText: String,
|
||||
originalSelection: TextRange,
|
||||
filteredText: String,
|
||||
formattedText: String
|
||||
): Int {
|
||||
// 如果在末尾添加,保持光标在末尾
|
||||
if (originalSelection.start >= originalText.length) {
|
||||
return formattedText.length
|
||||
}
|
||||
|
||||
// 计算原始文本中光标前的数字个数
|
||||
val digitsBeforeCursor = originalText
|
||||
.substring(0, originalSelection.start)
|
||||
.count { it.isDigit() }
|
||||
|
||||
// 在格式化文本中找到对应位置
|
||||
var digitCount = 0
|
||||
formattedText.forEachIndexed { index, char ->
|
||||
if (char.isDigit()) {
|
||||
digitCount++
|
||||
if (digitCount > digitsBeforeCursor) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formattedText.length
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.sw.inbound.utils
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
/**
|
||||
* 多功能线程工具类
|
||||
* 结合协程、Handler和线程池实现线程切换
|
||||
*/
|
||||
object ThreadUtils : CoroutineScope {
|
||||
// 主线程Handler
|
||||
private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
|
||||
|
||||
// 后台线程池(IO密集型任务)
|
||||
private val ioThreadPool: ExecutorService by lazy {
|
||||
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2)
|
||||
}
|
||||
|
||||
// CPU密集型线程池
|
||||
private val cpuThreadPool: ExecutorService by lazy {
|
||||
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors())
|
||||
}
|
||||
|
||||
// 协程Job管理
|
||||
private val job = Job()
|
||||
|
||||
override val coroutineContext: CoroutineContext
|
||||
get() = Dispatchers.Main + job
|
||||
|
||||
// ========== Handler相关方法 ==========
|
||||
|
||||
/**
|
||||
* 在主线程执行任务
|
||||
* @param delayMillis 延迟时间(毫秒)
|
||||
*/
|
||||
fun runOnUiThread(delayMillis: Long = 0, block: () -> Unit) {
|
||||
if (delayMillis > 0) {
|
||||
mainHandler.postDelayed(block, delayMillis)
|
||||
} else {
|
||||
if (isOnMainThread()) {
|
||||
block()
|
||||
} else {
|
||||
mainHandler.post(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除主线程任务
|
||||
*/
|
||||
fun removeUiThreadTask(block: () -> Unit) {
|
||||
mainHandler.removeCallbacks(block)
|
||||
}
|
||||
|
||||
// ========== 线程池相关方法 ==========
|
||||
|
||||
/**
|
||||
* 在IO线程执行任务
|
||||
*/
|
||||
fun runOnIoThread(block: () -> Unit) {
|
||||
ioThreadPool.execute(block)
|
||||
}
|
||||
|
||||
/**
|
||||
* 在CPU计算线程执行任务
|
||||
*/
|
||||
fun runOnCpuThread(block: () -> Unit) {
|
||||
cpuThreadPool.execute(block)
|
||||
}
|
||||
|
||||
// ========== 协程相关方法 ==========
|
||||
|
||||
/**
|
||||
* 启动协程(默认在主线程)
|
||||
*/
|
||||
fun launch(block: suspend CoroutineScope.() -> Unit): Job {
|
||||
return launch(coroutineContext, block = block)
|
||||
}
|
||||
|
||||
/**
|
||||
* 在IO线程启动协程
|
||||
*/
|
||||
fun launchOnIo(block: suspend CoroutineScope.() -> Unit): Job {
|
||||
return launch(Dispatchers.IO, block = block)
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到主线程(协程环境)
|
||||
*/
|
||||
suspend fun <T> switchToMain(block: suspend CoroutineScope.() -> T): T {
|
||||
return withContext(Dispatchers.Main, block)
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到IO线程(协程环境)
|
||||
*/
|
||||
suspend fun <T> switchToIo(block: suspend CoroutineScope.() -> T): T {
|
||||
return withContext(Dispatchers.IO, block)
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否在主线程
|
||||
*/
|
||||
fun isOnMainThread(): Boolean {
|
||||
return Looper.myLooper() == Looper.getMainLooper()
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资源
|
||||
*/
|
||||
fun release() {
|
||||
job.cancel()
|
||||
ioThreadPool.shutdown()
|
||||
cpuThreadPool.shutdown()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.sw.inbound.utils
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
object ToastUtils {
|
||||
private var show by mutableStateOf(false)
|
||||
private var message by mutableStateOf("")
|
||||
|
||||
fun showToast(msg: String) {
|
||||
message = msg
|
||||
show = true
|
||||
// Toast.makeText(ContextUtils.getAppContext(), msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ToastComposable() {
|
||||
if (show) {
|
||||
LaunchedEffect(Unit) {
|
||||
delay(2000) // 自动2秒后消失
|
||||
show = false
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
.fillMaxSize()
|
||||
.padding(bottom = 56.dp),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
modifier = Modifier
|
||||
.background(Color.Black.copy(alpha = 0.7f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
color = Color.White,
|
||||
fontSize = 24.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user