diff --git a/.kotlin/errors/errors-1783643668109.log b/.kotlin/errors/errors-1783643668109.log new file mode 100644 index 0000000..2af1222 --- /dev/null +++ b/.kotlin/errors/errors-1783643668109.log @@ -0,0 +1,48 @@ +kotlin version: 2.2.10 +error message: java.nio.file.AccessDeniedException: C:\Users\Administrator\AppData\Local\kotlin\daemon\kotlin-daemon-client-tsmarker11134052769962658558.tmp + +error message: Daemon compilation failed: Could not connect to Kotlin compile daemon +java.lang.RuntimeException: Could not connect to Kotlin compile daemon + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:214) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111) + at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74) + at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:68) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:64) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:61) + at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:102) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:61) + at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44) + at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41) + at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209) + at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204) + at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66) + at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59) + at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166) + at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59) + at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53) + at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:58) + at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:176) + at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169) + at org.gradle.internal.Factories$1.create(Factories.java:30) + at org.gradle.internal.work.DefaultWorkerLeaseService.lambda$withLocksAcquired$0(DefaultWorkerLeaseService.java:269) + at org.gradle.internal.work.ResourceLockStatistics$1.measure(ResourceLockStatistics.java:42) + at org.gradle.internal.work.DefaultWorkerLeaseService.withLocksAcquired(DefaultWorkerLeaseService.java:267) + at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:259) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133) + at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572) + at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) + at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64) + at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) + at java.base/java.lang.Thread.run(Thread.java:1583) + + diff --git a/.kotlin/errors/errors-1783645418169.log b/.kotlin/errors/errors-1783645418169.log new file mode 100644 index 0000000..43b12a2 --- /dev/null +++ b/.kotlin/errors/errors-1783645418169.log @@ -0,0 +1,48 @@ +kotlin version: 2.2.10 +error message: java.nio.file.AccessDeniedException: C:\Users\Administrator\AppData\Local\kotlin\daemon\kotlin-daemon-client-tsmarker4236714666597388016.tmp + +error message: Daemon compilation failed: Could not connect to Kotlin compile daemon +java.lang.RuntimeException: Could not connect to Kotlin compile daemon + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:214) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111) + at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74) + at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:68) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:64) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:61) + at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:102) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:61) + at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44) + at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41) + at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209) + at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204) + at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66) + at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59) + at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166) + at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59) + at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53) + at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:58) + at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:176) + at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169) + at org.gradle.internal.Factories$1.create(Factories.java:30) + at org.gradle.internal.work.DefaultWorkerLeaseService.lambda$withLocksAcquired$0(DefaultWorkerLeaseService.java:269) + at org.gradle.internal.work.ResourceLockStatistics$1.measure(ResourceLockStatistics.java:42) + at org.gradle.internal.work.DefaultWorkerLeaseService.withLocksAcquired(DefaultWorkerLeaseService.java:267) + at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:259) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133) + at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572) + at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) + at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64) + at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) + at java.base/java.lang.Thread.run(Thread.java:1583) + + diff --git a/.kotlin/errors/errors-1783645640346.log b/.kotlin/errors/errors-1783645640346.log new file mode 100644 index 0000000..db5a68a --- /dev/null +++ b/.kotlin/errors/errors-1783645640346.log @@ -0,0 +1,48 @@ +kotlin version: 2.2.10 +error message: java.nio.file.AccessDeniedException: C:\Users\Administrator\AppData\Local\kotlin\daemon\kotlin-daemon-client-tsmarker12754528945444950713.tmp + +error message: Daemon compilation failed: Could not connect to Kotlin compile daemon +java.lang.RuntimeException: Could not connect to Kotlin compile daemon + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:214) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159) + at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111) + at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74) + at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:68) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:64) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:61) + at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:102) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:61) + at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44) + at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41) + at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209) + at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204) + at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66) + at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59) + at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166) + at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59) + at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53) + at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41) + at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:58) + at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:176) + at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169) + at org.gradle.internal.Factories$1.create(Factories.java:30) + at org.gradle.internal.work.DefaultWorkerLeaseService.lambda$withLocksAcquired$0(DefaultWorkerLeaseService.java:269) + at org.gradle.internal.work.ResourceLockStatistics$1.measure(ResourceLockStatistics.java:42) + at org.gradle.internal.work.DefaultWorkerLeaseService.withLocksAcquired(DefaultWorkerLeaseService.java:267) + at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:259) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127) + at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164) + at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133) + at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572) + at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) + at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64) + at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642) + at java.base/java.lang.Thread.run(Thread.java:1583) + + diff --git a/app/src/main/java/com/ckkj/defense_device/home/HomeMenuFragment.kt b/app/src/main/java/com/ckkj/defense_device/home/HomeMenuFragment.kt index c8faf51..f45183d 100644 --- a/app/src/main/java/com/ckkj/defense_device/home/HomeMenuFragment.kt +++ b/app/src/main/java/com/ckkj/defense_device/home/HomeMenuFragment.kt @@ -39,6 +39,7 @@ import com.ckkj.defense_device.utils.RetrofitNetwork import com.ckkj.defense_device.utils.UserinfoUtil import com.ckkj.defense_device.view.DefenseDialogs import com.ckkj.defense_device.view.LoadingDialog +import com.ckkj.defense_device.view.PullRefreshLayout import com.ckkj.defense_device.view.showDefenseDialog import com.gyf.immersionbar.ImmersionBar import com.maning.mlkitscanner.scan.MNScanManager @@ -62,6 +63,9 @@ class HomeMenuFragment : Fragment() { private val pageSize = 10 private var hasMore = true private var isLoading = false + private var loadingDialog: LoadingDialog? = null + private var lastNoMoreToastTime: Long = 0L + private val noMoreToastInterval: Long = 1500L private val requestPermissionsLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() @@ -162,7 +166,7 @@ class HomeMenuFragment : Fragment() { } private fun fetchInviterInfo(inviteCode: String) { - val loadingDialog = LoadingDialog(requireActivity()).apply { show() } + val loadingDialog = LoadingDialog(requireActivity()).setMessage(this.getString(R.string.loading)).apply { show() } CoroutineScope(Dispatchers.IO).launch { try { val response = RetrofitNetwork.getNetworkService() @@ -297,9 +301,11 @@ class HomeMenuFragment : Fragment() { ) } - binding.swipeRefreshLayout.setOnRefreshListener { - refreshDeviceList() - } + binding.swipeRefreshLayout.setOnRefreshListener(object : PullRefreshLayout.OnRefreshListener { + override fun onRefresh() { + refreshDeviceList() + } + }) binding.rvDeviceList.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { @@ -307,9 +313,24 @@ class HomeMenuFragment : Fragment() { val layoutManager = recyclerView.layoutManager as GridLayoutManager val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition() val totalItemCount = layoutManager.itemCount + val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() + + if (firstVisibleItemPosition == 0) { + val firstChild = layoutManager.findViewByPosition(0) + firstChild?.let { child -> + val top = child.top + if (top >= 0) { + val offset = (top.toFloat() / dpToPx(80f)).coerceIn(0f, 1f) + binding.gradientLayout.translationY = -offset * dpToPx(20f) + } + } + } if (!isLoading && hasMore && lastVisibleItemPosition >= totalItemCount - 1 && dy > 0) { loadMoreDevices() + } else if (!hasMore && !isLoading && lastVisibleItemPosition >= totalItemCount - 1 + && dy > 0 && totalItemCount > 0) { + showNoMoreToast() } } }) @@ -425,7 +446,7 @@ class HomeMenuFragment : Fragment() { canViewDevice = userInfo?.role == "appadmin" && familyBean.role != "member" updateAdminUI() updateFamilyDisplayText(familyName) - fetchDeviceList() + fetchDeviceList(showLoading = true) FamilySwitchEvent.notifyFamilySwitched(familyName) } else { Toast.makeText(requireContext(), response.msg, Toast.LENGTH_SHORT).show() @@ -439,15 +460,20 @@ class HomeMenuFragment : Fragment() { } } - private fun fetchDeviceList(page: Int = 1, isLoadMore: Boolean = false) { + private fun fetchDeviceList(page: Int = 1, isLoadMore: Boolean = false, showLoading: Boolean = false) { if (!canViewDevice || isLoading) return isLoading = true + loadingDialog = if (showLoading) LoadingDialog(requireActivity()).setMessage("加载中...").apply { show() } else null + CoroutineScope(Dispatchers.IO).launch { try { val response = RetrofitNetwork.getNetworkService().getDeviceList() withContext(Dispatchers.Main) { + if (!isAdded) return@withContext + loadingDialog?.dismiss() + loadingDialog = null Log.d("DeviceList", "code = ${response.code}, data = ${response.data}") if (response.code == 200 && response.data != null) { val newDevices = response.data @@ -470,25 +496,36 @@ class HomeMenuFragment : Fragment() { } } isLoading = false + binding.swipeRefreshLayout.finishRefresh() } } catch (e: Exception) { withContext(Dispatchers.Main) { + if (!isAdded) return@withContext + loadingDialog?.dismiss() + loadingDialog = null noNetwork() Toast.makeText(requireContext(), R.string.network_error, Toast.LENGTH_SHORT).show() isLoading = false if (isLoadMore) { hasMore = false } + binding.swipeRefreshLayout.finishRefresh() } } } } + private fun showNoMoreToast() { + val now = System.currentTimeMillis() + if (now - lastNoMoreToastTime < noMoreToastInterval) return + lastNoMoreToastTime = now + ToastUtils.showToast(requireContext(), getString(R.string.no_more_data)) + } + private fun refreshDeviceList() { currentPage = 1 hasMore = true fetchDeviceList(currentPage, false) - binding.swipeRefreshLayout.isRefreshing = false } private fun loadMoreDevices() { @@ -566,6 +603,14 @@ class HomeMenuFragment : Fragment() { ).toInt() } + private fun dpToPx(dp: Float): Float { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dp, + resources.displayMetrics + ) + } + private class GridHorizontalSpaceItemDecoration( private val horizontalSpace: Int ) : RecyclerView.ItemDecoration() { @@ -588,6 +633,8 @@ class HomeMenuFragment : Fragment() { override fun onDestroyView() { super.onDestroyView() + loadingDialog?.dismiss() + loadingDialog = null binding.rvFamilyList.adapter = null binding.rvDeviceList.adapter = null MessageStatusManager.removeListener(unreadCountChangeListener) diff --git a/app/src/main/java/com/ckkj/defense_device/view/LoadingDialog.kt b/app/src/main/java/com/ckkj/defense_device/view/LoadingDialog.kt index 4436af5..d100512 100644 --- a/app/src/main/java/com/ckkj/defense_device/view/LoadingDialog.kt +++ b/app/src/main/java/com/ckkj/defense_device/view/LoadingDialog.kt @@ -4,6 +4,8 @@ import android.app.Dialog import android.content.Context import android.graphics.Color import android.graphics.drawable.ColorDrawable +import android.os.Handler +import android.os.Looper import android.view.Gravity import android.view.LayoutInflater import android.view.WindowManager @@ -13,6 +15,13 @@ class LoadingDialog(context: Context) { private val dialog: Dialog = Dialog(context) private val binding: DialogLoadingBinding + private val handler = Handler(Looper.getMainLooper()) + private var showTime: Long = 0 + private val dismissRunnable = Runnable { + if (dialog.isShowing) { + dialog.dismiss() + } + } init { binding = DialogLoadingBinding.inflate(LayoutInflater.from(context)) @@ -35,14 +44,23 @@ class LoadingDialog(context: Context) { } fun show() { + handler.removeCallbacks(dismissRunnable) + showTime = System.currentTimeMillis() if (!dialog.isShowing) { dialog.show() } } fun dismiss() { - if (dialog.isShowing) { - dialog.dismiss() + handler.removeCallbacks(dismissRunnable) + val elapsed = System.currentTimeMillis() - showTime + val MIN_SHOW_TIME = 1000L + if (elapsed >= MIN_SHOW_TIME) { + if (dialog.isShowing) { + dialog.dismiss() + } + } else { + handler.postDelayed(dismissRunnable, MIN_SHOW_TIME - elapsed) } } diff --git a/app/src/main/java/com/ckkj/defense_device/view/PullRefreshLayout.kt b/app/src/main/java/com/ckkj/defense_device/view/PullRefreshLayout.kt new file mode 100644 index 0000000..311fe25 --- /dev/null +++ b/app/src/main/java/com/ckkj/defense_device/view/PullRefreshLayout.kt @@ -0,0 +1,288 @@ +package com.ckkj.defense_device.view + +import android.animation.ValueAnimator +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.view.ViewGroup +import android.view.animation.LinearInterpolator +import android.widget.ImageView +import android.widget.TextView +import com.ckkj.defense_device.R + +/** + * 自定义下拉刷新控件 + * 仿网易云/抖音风格:下拉时列表跟随向下移动,顶部出现 Logo + 文字下拉头。 + * + * 使用方式:直接包裹 RecyclerView 等可滚动子 View,调用 [setOnRefreshListener] 监听刷新。 + */ +class PullRefreshLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ViewGroup(context, attrs, defStyleAttr) { + + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + + /** 触发刷新的下拉阈值(Header 的高度) */ + private val headerHeight: Int = dp(120) + + /** 触发刷新的最大下拉距离(Header 高度的 1.5 倍) */ + private val maxPullDistance: Int = (headerHeight * 1.5f).toInt() + + private var headerView: View + private val ivRefreshIcon: ImageView + private val tvHint: TextView + + /** 子内容 View(通常是 RecyclerView) */ + private var contentView: View? = null + + private var downY = 0f + private var isDragging = false + + /** 当前下拉偏移量(>= 0) */ + private var currentOffset = 0f + + /** 是否处于刷新状态 */ + private var isRefreshing = false + set(value) { + if (field == value) return + field = value + if (value) { + startLoadingAnimation() + } else { + stopLoadingAnimation() + } + } + + private var refreshListener: OnRefreshListener? = null + private var resetAnimator: ValueAnimator? = null + + init { + clipToPadding = false + // 添加 Header 视图 + headerView = LayoutInflater.from(context).inflate(R.layout.layout_refresh_header, this, false) + addView(headerView) + ivRefreshIcon = headerView.findViewById(R.id.ivRefreshIcon) + tvHint = headerView.findViewById(R.id.tvRefreshHint) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val width = MeasureSpec.getSize(widthMeasureSpec) + val height = MeasureSpec.getSize(heightMeasureSpec) + + // 测量 Header + val headerHeightSpec = MeasureSpec.makeMeasureSpec(headerHeight, MeasureSpec.EXACTLY) + headerView.measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + headerHeightSpec + ) + + // 测量内容 View(仅第一个非 Header 子 View) + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child === headerView) continue + child.measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) + ) + contentView = child + } + setMeasuredDimension(width, height) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + // Header 布局在容器顶部之上(初始隐藏,下拉时滑入) + headerView.layout( + 0, + -headerHeight + currentOffset.toInt(), + width, + currentOffset.toInt() + ) + + // 内容 View 跟随偏移 + contentView?.layout( + 0, + currentOffset.toInt(), + width, + height + currentOffset.toInt() + ) + } + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + if (isRefreshing) return false + + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> { + downY = ev.y + isDragging = false + cancelResetAnimator() + } + MotionEvent.ACTION_MOVE -> { + val dy = ev.y - downY + if (dy > touchSlop && canPullDown()) { + isDragging = true + return true + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + isDragging = false + } + } + return false + } + + override fun onTouchEvent(ev: MotionEvent): Boolean { + if (isRefreshing) return false + + when (ev.actionMasked) { + MotionEvent.ACTION_MOVE -> { + val dy = ev.y - downY + if (dy > 0) { + if (!isDragging) { + if (dy > touchSlop) { + isDragging = true + } else { + return true + } + } + // 添加阻尼效果:越往下拉越吃力 + val damped = computePullOffset(dy) + currentOffset = damped.coerceAtMost(maxPullDistance.toFloat()) + updateHeaderState() + requestLayout() + return true + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (currentOffset >= headerHeight * 0.9f) { + // 触发刷新 + triggerRefresh() + } else { + // 回弹 + resetToIdle() + } + isDragging = false + return true + } + } + return isDragging + } + + /** + * 添加阻尼效果,让下拉越深越慢,模拟弹性手感 + */ + private fun computePullOffset(rawDy: Float): Float { + // 原始距离的 0.5 倍为基本偏移,超过阈值后逐渐衰减 + val resistance = 1.8f + return (rawDy / resistance).coerceAtMost(maxPullDistance.toFloat()) + } + + /** + * 判断是否可以从当前位置下拉(列表已经滚动到顶部) + */ + private fun canPullDown(): Boolean { + val content = contentView ?: return false + return !content.canScrollVertically(-1) + } + + /** + * 根据下拉距离更新 Header 的视觉状态(旋转角度、提示文字) + */ + private fun updateHeaderState() { + val ratio = (currentOffset / headerHeight).coerceIn(0f, 1f) + + // 刷新图标根据下拉比例旋转 0~360 度 + ivRefreshIcon.rotation = ratio * 360f + + // 文字提示 + tvHint.text = when { + currentOffset >= headerHeight * 0.9f -> context.getString(R.string.refresh_hint_release) + else -> context.getString(R.string.refresh_hint_pull) + } + } + + private fun triggerRefresh() { + // 平滑动画到 header 高度 + val animator = ValueAnimator.ofFloat(currentOffset, headerHeight.toFloat()) + animator.duration = 200L + animator.interpolator = LinearInterpolator() + animator.addUpdateListener { + currentOffset = it.animatedValue as Float + requestLayout() + } + animator.start() + resetAnimator = animator + + isRefreshing = true + tvHint.text = context.getString(R.string.refresh_hint_refreshing) + refreshListener?.onRefresh() + } + + /** + * 结束刷新并回弹 + */ + fun finishRefresh() { + if (!isRefreshing) return + isRefreshing = false + resetToIdle() + } + + private fun resetToIdle() { + cancelResetAnimator() + val animator = ValueAnimator.ofFloat(currentOffset, 0f) + animator.duration = 300L + animator.interpolator = LinearInterpolator() + animator.addUpdateListener { + currentOffset = it.animatedValue as Float + if (!isRefreshing) { + // 刷新结束后,重置图标旋转角度 + ivRefreshIcon.rotation = 0f + tvHint.text = context.getString(R.string.refresh_hint_pull) + } + requestLayout() + } + animator.start() + resetAnimator = animator + } + + private fun cancelResetAnimator() { + resetAnimator?.cancel() + resetAnimator = null + } + + private fun startLoadingAnimation() { + // 持续旋转动画 + ivRefreshIcon.animate() + .rotation(ivRefreshIcon.rotation + 360f) + .setDuration(800L) + .setInterpolator(LinearInterpolator()) + .withEndAction { + if (isRefreshing) startLoadingAnimation() + } + .start() + } + + private fun stopLoadingAnimation() { + ivRefreshIcon.animate().cancel() + ivRefreshIcon.rotation = 0f + tvHint.text = context.getString(R.string.refresh_hint_pull) + } + + fun setOnRefreshListener(listener: OnRefreshListener) { + this.refreshListener = listener + } + + interface OnRefreshListener { + fun onRefresh() + } + + private fun dp(value: Int): Int { + val density = resources.displayMetrics.density + return (value * density).toInt() + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_loading.xml b/app/src/main/res/layout/dialog_loading.xml index cd687d7..9bf44e0 100644 --- a/app/src/main/res/layout/dialog_loading.xml +++ b/app/src/main/res/layout/dialog_loading.xml @@ -20,7 +20,7 @@ android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/uploading" - android:textColor="@color/black" + android:textColor="@color/green" android:textSize="16sp"/> \ No newline at end of file diff --git a/app/src/main/res/layout/home_menu_fragment.xml b/app/src/main/res/layout/home_menu_fragment.xml index 6d8326f..e0e13e5 100644 --- a/app/src/main/res/layout/home_menu_fragment.xml +++ b/app/src/main/res/layout/home_menu_fragment.xml @@ -134,7 +134,7 @@ - - + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-xxxhdpi/setting.png b/app/src/main/res/mipmap-xxxhdpi/setting.png new file mode 100644 index 0000000..f7a5a1a Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/setting.png differ diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 059a0fe..cbc3f3e 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -512,4 +512,11 @@ 注销 确定注销该用户吗? + + 没有更多了 + 正在刷新… + 下拉刷新 + 松开刷新 + 正在刷新… + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 378aa45..fbe81c2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -519,4 +519,11 @@ Current Account Cancel Confirm Cancel Account User Account + + + No more data + Refreshing… + Pull down to refresh + Release to refresh + Refreshing… \ No newline at end of file