Skip to content

Commit 64527d3

Browse files
committed
Add BatchProgressBottomSheet with countdown delay, cleaner UI for batch actions
1 parent 866002f commit 64527d3

File tree

5 files changed

+324
-63
lines changed

5 files changed

+324
-63
lines changed

app/src/main/java/com/appcontrolx/ui/AppListFragment.kt

Lines changed: 28 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -345,76 +345,41 @@ class AppListFragment : Fragment() {
345345
val pm = policyManager ?: return
346346
val rm = rollbackManager
347347

348-
// Show progress dialog with live log
349-
val progressDialog = BatchProgressDialog.newInstance(action.name, packages.size)
350-
progressDialog.show(childFragmentManager, BatchProgressDialog.TAG)
348+
// Get app names for display
349+
val appNames = packages.map { pkg ->
350+
adapter.getAppName(pkg) ?: pkg.substringAfterLast(".")
351+
}
351352

352-
lifecycleScope.launch {
353-
var successCount = 0
354-
var failCount = 0
355-
356-
try {
357-
rm?.saveSnapshot(packages)
358-
359-
withTimeout(ACTION_TIMEOUT_MS) {
360-
for (pkg in packages) {
361-
val appName = adapter.getAppName(pkg) ?: pkg.substringAfterLast(".")
362-
363-
// Execute on IO thread
364-
val result = withContext(Dispatchers.IO) {
365-
when (action) {
366-
ActionBottomSheet.Action.FREEZE -> pm.freezeApp(pkg)
367-
ActionBottomSheet.Action.UNFREEZE -> pm.unfreezeApp(pkg)
368-
ActionBottomSheet.Action.UNINSTALL -> pm.uninstallApp(pkg)
369-
ActionBottomSheet.Action.FORCE_STOP -> pm.forceStop(pkg)
370-
ActionBottomSheet.Action.RESTRICT_BACKGROUND -> pm.restrictBackground(pkg)
371-
ActionBottomSheet.Action.ALLOW_BACKGROUND -> pm.allowBackground(pkg)
372-
ActionBottomSheet.Action.CLEAR_CACHE -> executor?.execute("pm clear --cache-only $pkg")?.map { } ?: Result.failure(Exception("No executor"))
373-
ActionBottomSheet.Action.CLEAR_DATA -> executor?.execute("pm clear $pkg")?.map { } ?: Result.failure(Exception("No executor"))
374-
}
375-
}
376-
377-
// Update log on main thread
378-
withContext(Dispatchers.Main) {
379-
val status = getStatusText(action, result.isSuccess)
380-
progressDialog.addLogEntry(appName, status, result.isSuccess)
381-
}
382-
383-
if (result.isSuccess) successCount++ else failCount++
353+
// Show new BottomSheet with countdown
354+
val bottomSheet = BatchProgressBottomSheet.newInstance(
355+
actionName = action.name,
356+
appNames = appNames,
357+
packageNames = packages,
358+
onExecute = { pkg ->
359+
withContext(Dispatchers.IO) {
360+
when (action) {
361+
ActionBottomSheet.Action.FREEZE -> pm.freezeApp(pkg)
362+
ActionBottomSheet.Action.UNFREEZE -> pm.unfreezeApp(pkg)
363+
ActionBottomSheet.Action.UNINSTALL -> pm.uninstallApp(pkg)
364+
ActionBottomSheet.Action.FORCE_STOP -> pm.forceStop(pkg)
365+
ActionBottomSheet.Action.RESTRICT_BACKGROUND -> pm.restrictBackground(pkg)
366+
ActionBottomSheet.Action.ALLOW_BACKGROUND -> pm.allowBackground(pkg)
367+
ActionBottomSheet.Action.CLEAR_CACHE -> executor?.execute("pm clear --cache-only $pkg")?.map { } ?: Result.failure(Exception("No executor"))
368+
ActionBottomSheet.Action.CLEAR_DATA -> executor?.execute("pm clear $pkg")?.map { } ?: Result.failure(Exception("No executor"))
384369
}
385370
}
386-
387-
// Show completion
388-
progressDialog.setCompleted(successCount, failCount)
389-
390-
rm?.logAction(ActionLog(
391-
action = action.name,
392-
packages = packages,
393-
success = failCount == 0,
394-
message = if (failCount > 0) "$failCount failed" else null
395-
))
396-
397-
// Delay before dismiss
398-
kotlinx.coroutines.delay(2000)
399-
progressDialog.dismiss()
400-
371+
},
372+
onComplete = {
401373
adapter.deselectAll()
402374
clearCache()
403375
loadApps(forceRefresh = true)
404-
405-
} catch (e: TimeoutCancellationException) {
406-
progressDialog.dismiss()
407-
showErrorDialog(
408-
getString(R.string.error_timeout_title),
409-
getString(R.string.error_timeout_message)
410-
)
411-
} catch (e: Exception) {
412-
progressDialog.dismiss()
413-
showErrorDialog(
414-
getString(R.string.error_action_title),
415-
e.message ?: getString(R.string.error_unknown)
416-
)
417376
}
377+
)
378+
bottomSheet.show(childFragmentManager, BatchProgressBottomSheet.TAG)
379+
380+
// Save snapshot before action
381+
lifecycleScope.launch {
382+
rm?.saveSnapshot(packages)
418383
}
419384
}
420385

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package com.appcontrolx.ui
2+
3+
import android.os.Bundle
4+
import android.view.LayoutInflater
5+
import android.view.View
6+
import android.view.ViewGroup
7+
import androidx.lifecycle.lifecycleScope
8+
import androidx.recyclerview.widget.LinearLayoutManager
9+
import androidx.recyclerview.widget.RecyclerView
10+
import com.appcontrolx.R
11+
import com.appcontrolx.databinding.BottomSheetBatchProgressBinding
12+
import com.appcontrolx.databinding.ItemBatchAppBinding
13+
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
14+
import kotlinx.coroutines.Job
15+
import kotlinx.coroutines.delay
16+
import kotlinx.coroutines.launch
17+
18+
class BatchProgressBottomSheet : BottomSheetDialogFragment() {
19+
20+
private var _binding: BottomSheetBatchProgressBinding? = null
21+
private val binding get() = _binding
22+
23+
private var actionName = ""
24+
private var appNames = listOf<String>()
25+
private var packageNames = listOf<String>()
26+
private var onExecute: (suspend (String) -> Result<Unit>)? = null
27+
private var onComplete: (() -> Unit)? = null
28+
29+
private val adapter = BatchAppAdapter()
30+
private var executionJob: Job? = null
31+
private var isCancelled = false
32+
33+
companion object {
34+
const val TAG = "BatchProgressBottomSheet"
35+
private const val COUNTDOWN_SECONDS = 3
36+
37+
fun newInstance(
38+
actionName: String,
39+
appNames: List<String>,
40+
packageNames: List<String>,
41+
onExecute: suspend (String) -> Result<Unit>,
42+
onComplete: () -> Unit
43+
): BatchProgressBottomSheet {
44+
return BatchProgressBottomSheet().apply {
45+
this.actionName = actionName
46+
this.appNames = appNames
47+
this.packageNames = packageNames
48+
this.onExecute = onExecute
49+
this.onComplete = onComplete
50+
}
51+
}
52+
}
53+
54+
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
55+
_binding = BottomSheetBatchProgressBinding.inflate(inflater, container, false)
56+
return _binding?.root
57+
}
58+
59+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
60+
super.onViewCreated(view, savedInstanceState)
61+
setupUI()
62+
startCountdown()
63+
}
64+
65+
private fun setupUI() {
66+
val b = binding ?: return
67+
68+
b.tvAction.text = actionName.replace("_", " ")
69+
b.progressBar.max = packageNames.size
70+
b.progressBar.progress = 0
71+
b.tvProgress.text = getString(R.string.batch_waiting)
72+
73+
// Setup RecyclerView with app list
74+
adapter.submitList(appNames.mapIndexed { index, name ->
75+
BatchAppItem(name, packageNames[index], BatchStatus.PENDING)
76+
})
77+
b.recyclerView.layoutManager = LinearLayoutManager(context)
78+
b.recyclerView.adapter = adapter
79+
80+
b.btnCancel.setOnClickListener {
81+
isCancelled = true
82+
executionJob?.cancel()
83+
dismiss()
84+
}
85+
}
86+
87+
private fun startCountdown() {
88+
executionJob = lifecycleScope.launch {
89+
// Countdown
90+
for (i in COUNTDOWN_SECONDS downTo 1) {
91+
if (isCancelled) return@launch
92+
binding?.tvCountdown?.text = getString(R.string.batch_countdown, i)
93+
delay(1000)
94+
}
95+
96+
binding?.tvCountdown?.text = ""
97+
executeActions()
98+
}
99+
}
100+
101+
private suspend fun executeActions() {
102+
val b = binding ?: return
103+
var successCount = 0
104+
var failCount = 0
105+
106+
packageNames.forEachIndexed { index, pkg ->
107+
if (isCancelled) return
108+
109+
b.tvProgress.text = getString(R.string.batch_progress, index + 1, packageNames.size)
110+
111+
// Update status to processing
112+
adapter.updateStatus(index, BatchStatus.PROCESSING)
113+
114+
val result = onExecute?.invoke(pkg) ?: Result.failure(Exception("No executor"))
115+
116+
if (result.isSuccess) {
117+
successCount++
118+
adapter.updateStatus(index, BatchStatus.SUCCESS)
119+
} else {
120+
failCount++
121+
adapter.updateStatus(index, BatchStatus.FAILED)
122+
}
123+
124+
b.progressBar.progress = index + 1
125+
delay(100) // Small delay for UI
126+
}
127+
128+
// Complete
129+
b.tvProgress.text = if (failCount == 0) {
130+
getString(R.string.batch_all_success, successCount)
131+
} else {
132+
getString(R.string.batch_partial_success, successCount, failCount)
133+
}
134+
b.btnCancel.text = getString(R.string.btn_close)
135+
b.btnCancel.setOnClickListener {
136+
onComplete?.invoke()
137+
dismiss()
138+
}
139+
}
140+
141+
override fun onDestroyView() {
142+
super.onDestroyView()
143+
_binding = null
144+
}
145+
146+
// Data classes
147+
enum class BatchStatus { PENDING, PROCESSING, SUCCESS, FAILED }
148+
149+
data class BatchAppItem(
150+
val appName: String,
151+
val packageName: String,
152+
var status: BatchStatus
153+
)
154+
155+
// Adapter
156+
inner class BatchAppAdapter : RecyclerView.Adapter<BatchAppAdapter.ViewHolder>() {
157+
private val items = mutableListOf<BatchAppItem>()
158+
159+
fun submitList(list: List<BatchAppItem>) {
160+
items.clear()
161+
items.addAll(list)
162+
notifyDataSetChanged()
163+
}
164+
165+
fun updateStatus(index: Int, status: BatchStatus) {
166+
if (index in items.indices) {
167+
items[index].status = status
168+
notifyItemChanged(index)
169+
}
170+
}
171+
172+
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
173+
val binding = ItemBatchAppBinding.inflate(LayoutInflater.from(parent.context), parent, false)
174+
return ViewHolder(binding)
175+
}
176+
177+
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
178+
holder.bind(items[position])
179+
}
180+
181+
override fun getItemCount() = items.size
182+
183+
inner class ViewHolder(private val binding: ItemBatchAppBinding) : RecyclerView.ViewHolder(binding.root) {
184+
fun bind(item: BatchAppItem) {
185+
binding.tvAppName.text = item.appName
186+
187+
val (statusText, statusColor) = when (item.status) {
188+
BatchStatus.PENDING -> "-" to R.color.on_surface_secondary
189+
BatchStatus.PROCESSING -> "..." to R.color.status_neutral
190+
BatchStatus.SUCCESS -> getString(R.string.log_status_success) to R.color.status_positive
191+
BatchStatus.FAILED -> getString(R.string.log_status_failed) to R.color.status_negative
192+
}
193+
194+
binding.tvStatus.text = statusText
195+
binding.tvStatus.setTextColor(resources.getColor(statusColor, null))
196+
}
197+
}
198+
}
199+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3+
xmlns:app="http://schemas.android.com/apk/res-auto"
4+
android:layout_width="match_parent"
5+
android:layout_height="wrap_content"
6+
android:orientation="vertical"
7+
android:padding="20dp"
8+
android:minHeight="400dp">
9+
10+
<!-- Header -->
11+
<LinearLayout
12+
android:layout_width="match_parent"
13+
android:layout_height="wrap_content"
14+
android:orientation="horizontal"
15+
android:gravity="center_vertical">
16+
17+
<TextView
18+
android:id="@+id/tvAction"
19+
android:layout_width="0dp"
20+
android:layout_height="wrap_content"
21+
android:layout_weight="1"
22+
android:textSize="20sp"
23+
android:textStyle="bold" />
24+
25+
<TextView
26+
android:id="@+id/tvCountdown"
27+
android:layout_width="wrap_content"
28+
android:layout_height="wrap_content"
29+
android:textSize="14sp"
30+
android:textColor="?attr/colorPrimary" />
31+
</LinearLayout>
32+
33+
<!-- Progress -->
34+
<com.google.android.material.progressindicator.LinearProgressIndicator
35+
android:id="@+id/progressBar"
36+
android:layout_width="match_parent"
37+
android:layout_height="wrap_content"
38+
android:layout_marginTop="16dp"
39+
app:trackThickness="6dp"
40+
app:trackCornerRadius="3dp" />
41+
42+
<TextView
43+
android:id="@+id/tvProgress"
44+
android:layout_width="wrap_content"
45+
android:layout_height="wrap_content"
46+
android:layout_marginTop="8dp"
47+
android:textSize="13sp"
48+
android:textColor="@color/on_surface_secondary" />
49+
50+
<!-- App List -->
51+
<androidx.recyclerview.widget.RecyclerView
52+
android:id="@+id/recyclerView"
53+
android:layout_width="match_parent"
54+
android:layout_height="0dp"
55+
android:layout_weight="1"
56+
android:layout_marginTop="16dp"
57+
android:clipToPadding="false" />
58+
59+
<!-- Cancel Button -->
60+
<com.google.android.material.button.MaterialButton
61+
android:id="@+id/btnCancel"
62+
android:layout_width="match_parent"
63+
android:layout_height="48dp"
64+
android:layout_marginTop="16dp"
65+
android:text="@string/btn_cancel"
66+
style="@style/Widget.Material3.Button.OutlinedButton" />
67+
68+
</LinearLayout>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:layout_width="match_parent"
4+
android:layout_height="wrap_content"
5+
android:orientation="horizontal"
6+
android:gravity="center_vertical"
7+
android:paddingVertical="8dp">
8+
9+
<TextView
10+
android:id="@+id/tvAppName"
11+
android:layout_width="0dp"
12+
android:layout_height="wrap_content"
13+
android:layout_weight="1"
14+
android:textSize="14sp"
15+
android:ellipsize="end"
16+
android:maxLines="1" />
17+
18+
<TextView
19+
android:id="@+id/tvStatus"
20+
android:layout_width="wrap_content"
21+
android:layout_height="wrap_content"
22+
android:textSize="12sp"
23+
android:textStyle="bold" />
24+
25+
</LinearLayout>

0 commit comments

Comments
 (0)