Load event app lists asynchronously
This commit is contained in:
@@ -88,6 +88,7 @@ dependencies {
|
||||
implementation(libs.fragment.ktx)
|
||||
implementation(libs.material)
|
||||
implementation(libs.drawerlayout)
|
||||
implementation(libs.recyclerview)
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
|
||||
testImplementation(libs.junit)
|
||||
|
||||
@@ -1,64 +1,70 @@
|
||||
package se.ajpanton.notificationlog
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.text.style.StyleSpan
|
||||
import android.text.style.UnderlineSpan
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CheckBox
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import se.ajpanton.notificationlog.capture.SeenApps
|
||||
import se.ajpanton.notificationlog.databinding.FragmentEventSettingsBinding
|
||||
import se.ajpanton.notificationlog.settings.AppRuleMode
|
||||
import se.ajpanton.notificationlog.settings.LoggingRuleStore
|
||||
import se.ajpanton.notificationlog.settings.LoggingType
|
||||
import se.ajpanton.notificationlog.settings.AppListItem
|
||||
import se.ajpanton.notificationlog.settings.AppListOrdering
|
||||
import se.ajpanton.notificationlog.settings.AppRuleMode
|
||||
import se.ajpanton.notificationlog.settings.ListedApp
|
||||
import se.ajpanton.notificationlog.capture.SeenApps
|
||||
import se.ajpanton.notificationlog.settings.LoggingRule
|
||||
import se.ajpanton.notificationlog.settings.LoggingRuleStore
|
||||
import se.ajpanton.notificationlog.settings.LoggingType
|
||||
|
||||
class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
|
||||
private var binding: FragmentEventSettingsBinding? = null
|
||||
private lateinit var type: LoggingType
|
||||
private lateinit var store: LoggingRuleStore
|
||||
private lateinit var appAdapter: AppListAdapter
|
||||
private var appLoadGeneration = 0
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding = FragmentEventSettingsBinding.bind(view)
|
||||
type = requireArguments().getSerializable(ARG_TYPE, LoggingType::class.java)!!
|
||||
store = LoggingRuleStore(requireContext())
|
||||
appAdapter = AppListAdapter(::setPackageSelected)
|
||||
binding!!.pageTitle.setText(requireArguments().getInt(ARG_TITLE))
|
||||
binding!!.appList.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding!!.appList.adapter = appAdapter
|
||||
binding!!.appListRefresh.setOnRefreshListener(::reloadAppList)
|
||||
installBottomRefresh()
|
||||
bindRule()
|
||||
binding!!.appListRefresh.setOnRefreshListener(::refreshAppList)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
appLoadGeneration++
|
||||
binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun installBottomRefresh() {
|
||||
val list = binding!!.appList
|
||||
var touchStartY = 0f
|
||||
val scroll = binding!!.appScroll
|
||||
scroll.setOnTouchListener { _, event ->
|
||||
list.setOnTouchListener { _, event ->
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> touchStartY = event.y
|
||||
MotionEvent.ACTION_UP -> {
|
||||
val atBottom = scroll.scrollY >= (scroll.getChildAt(0).height - scroll.height).coerceAtLeast(0)
|
||||
if (atBottom && event.y < touchStartY) {
|
||||
refreshAppList()
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_UP -> if (!list.canScrollVertically(1) && event.y < touchStartY) reloadAppList()
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() { binding = null; super.onDestroyView() }
|
||||
|
||||
private fun refreshAppList() {
|
||||
val currentBinding = binding ?: return
|
||||
currentBinding.appListRefresh.isRefreshing = true
|
||||
currentBinding.appListRefresh.post {
|
||||
if (binding === currentBinding) {
|
||||
reloadAppList()
|
||||
currentBinding.appListRefresh.isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindRule() {
|
||||
val rule = store.ruleFor(type)
|
||||
listOf(
|
||||
@@ -75,75 +81,164 @@ class EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
|
||||
binding!!.seenFirstToggle.isEnabled = !rule.onlySeenApps
|
||||
binding!!.routineUpdatesToggle.visibility = if (type == LoggingType.EDITS) View.VISIBLE else View.GONE
|
||||
binding!!.routineUpdatesToggle.isChecked = rule.ignoreRoutineUpdates
|
||||
updateRuleLabels()
|
||||
updateRuleLabels(rule)
|
||||
binding!!.masterToggle.setOnCheckedChangeListener { _, checked -> save { copy(enabled = checked) } }
|
||||
binding!!.appRuleToggle.setOnCheckedChangeListener { _, checked -> save { copy(appRuleMode = if (checked) AppRuleMode.WHITELIST else AppRuleMode.BLACKLIST) } }
|
||||
binding!!.appRuleToggle.setOnCheckedChangeListener { _, checked ->
|
||||
save { copy(appRuleMode = if (checked) AppRuleMode.WHITELIST else AppRuleMode.BLACKLIST) }
|
||||
}
|
||||
binding!!.onlySeenToggle.setOnCheckedChangeListener { _, checked -> save { copy(onlySeenApps = checked) } }
|
||||
binding!!.seenFirstToggle.setOnCheckedChangeListener { _, checked -> save { copy(seenAppsFirst = checked) } }
|
||||
binding!!.routineUpdatesToggle.setOnCheckedChangeListener { _, checked -> save { copy(ignoreRoutineUpdates = checked) } }
|
||||
reloadAppList()
|
||||
}
|
||||
|
||||
private fun save(change: se.ajpanton.notificationlog.settings.LoggingRule.() -> se.ajpanton.notificationlog.settings.LoggingRule) {
|
||||
private fun save(change: LoggingRule.() -> LoggingRule) {
|
||||
store.save(type, store.ruleFor(type).change())
|
||||
bindRule()
|
||||
}
|
||||
|
||||
private fun updateRuleLabels() {
|
||||
val rule = store.ruleFor(type)
|
||||
binding!!.appRuleToggle.text = if (rule.appRuleMode == AppRuleMode.WHITELIST) "Whitelist chosen" else "Blacklist chosen"
|
||||
binding!!.onlySeenToggle.text = if (rule.onlySeenApps) "Show only seen apps" else "Show all apps"
|
||||
private fun updateRuleLabels(rule: LoggingRule) {
|
||||
binding!!.masterToggle.text = "Enable logging"
|
||||
binding!!.appRuleToggle.text = modeLabel(rule.appRuleMode)
|
||||
binding!!.onlySeenToggle.text = "Show only seen apps"
|
||||
binding!!.seenFirstToggle.text = "Show seen apps first"
|
||||
}
|
||||
|
||||
private fun modeLabel(mode: AppRuleMode): CharSequence {
|
||||
val text = "Blacklist / Whitelist"
|
||||
val selected = if (mode == AppRuleMode.BLACKLIST) "Blacklist" else "Whitelist"
|
||||
val start = text.indexOf(selected)
|
||||
return SpannableString(text).apply {
|
||||
setSpan(StyleSpan(android.graphics.Typeface.BOLD), start, start + selected.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
setSpan(UnderlineSpan(), start, start + selected.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reloadAppList() {
|
||||
val container = binding!!.appList
|
||||
container.removeAllViews()
|
||||
val currentBinding = binding ?: return
|
||||
if (currentBinding.appListRefresh.isRefreshing) {
|
||||
// A fresh swipe replaces the current snapshot; no live updates occur otherwise.
|
||||
}
|
||||
val generation = ++appLoadGeneration
|
||||
currentBinding.appListLoading.visibility = View.VISIBLE
|
||||
currentBinding.appListRefresh.isRefreshing = true
|
||||
val rule = store.ruleFor(type)
|
||||
val seen = SeenApps.snapshot()
|
||||
val apps = requireContext().packageManager.getInstalledApplications(0)
|
||||
.map { info ->
|
||||
val context = requireContext().applicationContext
|
||||
Thread {
|
||||
val apps = context.packageManager.getInstalledApplications(0).map { info ->
|
||||
ListedApp(
|
||||
label = requireContext().packageManager.getApplicationLabel(info).toString(),
|
||||
label = context.packageManager.getApplicationLabel(info).toString(),
|
||||
packageName = info.packageName,
|
||||
seen = info.packageName in seen,
|
||||
selected = info.packageName in rule.selectedPackages,
|
||||
)
|
||||
}
|
||||
AppListOrdering.items(apps, rule.onlySeenApps, rule.seenAppsFirst).forEach { item ->
|
||||
container.addView(if (item is AppListItem.App) appView(item.value) else separator())
|
||||
val items = AppListOrdering.items(apps, rule.onlySeenApps, rule.seenAppsFirst)
|
||||
activity?.runOnUiThread {
|
||||
if (binding === currentBinding && generation == appLoadGeneration) {
|
||||
appAdapter.submit(items)
|
||||
currentBinding.appListLoading.visibility = View.GONE
|
||||
currentBinding.appListRefresh.isRefreshing = false
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun appView(row: ListedApp): View = LinearLayout(requireContext()).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
val checkbox = CheckBox(context).apply { isChecked = row.selected }
|
||||
checkbox.setOnCheckedChangeListener { _, checked ->
|
||||
private fun setPackageSelected(packageName: String, selected: Boolean) {
|
||||
val current = store.ruleFor(type)
|
||||
val packages = current.selectedPackages.toMutableSet().apply {
|
||||
if (checked) add(row.packageName) else remove(row.packageName)
|
||||
if (selected) add(packageName) else remove(packageName)
|
||||
}
|
||||
store.save(type, current.copy(selectedPackages = packages))
|
||||
// Deliberately do not reload: an unchecked unseen row must remain reachable.
|
||||
}
|
||||
addView(checkbox)
|
||||
addView(LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
addView(TextView(context).apply { text = row.label; textSize = 16f; setTypeface(typeface, 1) })
|
||||
addView(TextView(context).apply { text = row.packageName; textSize = 12f })
|
||||
})
|
||||
// Do not reload: an unchecked unseen row remains visible until the next requested refresh.
|
||||
}
|
||||
|
||||
private fun separator() = View(requireContext()).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1).apply { setMargins(0, 12, 0, 12) }
|
||||
setBackgroundColor(0x33000000)
|
||||
private class AppListAdapter(
|
||||
private val onSelectedChanged: (String, Boolean) -> Unit,
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
private var items: List<AppListItem> = emptyList()
|
||||
|
||||
fun submit(newItems: List<AppListItem>) {
|
||||
items = newItems
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int = if (items[position] is AppListItem.App) APP else SEPARATOR
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = if (viewType == APP) {
|
||||
val row = LinearLayout(parent.context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setPadding(0, dp(parent, 4), 0, dp(parent, 4))
|
||||
}
|
||||
val checkbox = CheckBox(parent.context)
|
||||
val textColumn = LinearLayout(parent.context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
val label = TextView(parent.context).apply { textSize = 16f; setTypeface(typeface, android.graphics.Typeface.BOLD) }
|
||||
val packageName = TextView(parent.context).apply { textSize = 12f }
|
||||
textColumn.addView(label)
|
||||
textColumn.addView(packageName)
|
||||
row.addView(checkbox)
|
||||
row.addView(textColumn)
|
||||
AppHolder(row, checkbox, label, packageName)
|
||||
} else {
|
||||
val divider = View(parent.context).apply {
|
||||
layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, dp(parent, 1)).apply {
|
||||
setMargins(0, dp(parent, 12), 0, dp(parent, 12))
|
||||
}
|
||||
setBackgroundColor(ContextCompat.getColor(parent.context, android.R.color.darker_gray))
|
||||
}
|
||||
SeparatorHolder(divider)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
if (holder is AppHolder && item is AppListItem.App) {
|
||||
val app = item.value
|
||||
holder.label.text = app.label
|
||||
holder.packageName.text = app.packageName
|
||||
holder.checkbox.setOnCheckedChangeListener(null)
|
||||
holder.checkbox.isChecked = app.selected
|
||||
holder.checkbox.setOnCheckedChangeListener { _, checked ->
|
||||
onSelectedChanged(app.packageName, checked)
|
||||
items = items.map { current ->
|
||||
if (current is AppListItem.App && current.value.packageName == app.packageName) {
|
||||
AppListItem.App(current.value.copy(selected = checked))
|
||||
} else current
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
private class AppHolder(
|
||||
view: View,
|
||||
val checkbox: CheckBox,
|
||||
val label: TextView,
|
||||
val packageName: TextView,
|
||||
) : RecyclerView.ViewHolder(view)
|
||||
|
||||
private class SeparatorHolder(view: View) : RecyclerView.ViewHolder(view)
|
||||
|
||||
private companion object {
|
||||
const val APP = 0
|
||||
const val SEPARATOR = 1
|
||||
fun dp(parent: ViewGroup, value: Int): Int = (value * parent.resources.displayMetrics.density).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_TITLE = "title"
|
||||
private const val ARG_TYPE = "type"
|
||||
|
||||
fun newInstance(@StringRes title: Int, type: LoggingType) = EventSettingsFragment().apply {
|
||||
arguments = Bundle().apply { putInt(ARG_TITLE, title); putSerializable(ARG_TYPE, type) }
|
||||
arguments = Bundle().apply {
|
||||
putInt(ARG_TITLE, title)
|
||||
putSerializable(ARG_TYPE, type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,18 @@ object AppListOrdering {
|
||||
val apps = allApps.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label })
|
||||
val shown = if (onlySeen) apps.filter { it.seen || it.selected } else apps
|
||||
val seen = shown.filter { it.seen }
|
||||
val other = shown.filterNot { it.seen }
|
||||
val separate = (onlySeen || seenAppsFirst) && seen.isNotEmpty() && other.isNotEmpty()
|
||||
val selectedUnseen = shown.filter { !it.seen }
|
||||
if (!seenAppsFirst || seen.isEmpty()) return shown.map(AppListItem::App)
|
||||
return buildList {
|
||||
if (separate) {
|
||||
seen.forEach { add(AppListItem.App(it)) }
|
||||
if (onlySeen) {
|
||||
if (selectedUnseen.isNotEmpty()) {
|
||||
add(AppListItem.Separator)
|
||||
other.forEach { add(AppListItem.App(it)) }
|
||||
selectedUnseen.forEach { add(AppListItem.App(it)) }
|
||||
}
|
||||
} else {
|
||||
add(AppListItem.Separator)
|
||||
// The full alphabetical list intentionally repeats seen apps after its quick-access group.
|
||||
shown.forEach { add(AppListItem.App(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,83 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/app_list_refresh" android:layout_width="match_parent" android:layout_height="match_parent"><ScrollView android:id="@+id/app_scroll" android:layout_width="match_parent" android:layout_height="match_parent">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="@dimen/page_padding">
|
||||
<TextView android:id="@+id/page_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="?attr/textAppearanceHeadline5" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/master_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="Logging enabled" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/app_rule_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/only_seen_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/seen_first_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Show seen apps at the top" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/routine_updates_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Ignore routine updates" />
|
||||
<LinearLayout android:id="@+id/app_list" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:orientation="vertical" />
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/page_padding">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/page_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceHeadline5" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/master_toggle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="Enable logging" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/app_rule_toggle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/only_seen_toggle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Show only seen apps" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/seen_first_toggle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Show seen apps first" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/routine_updates_toggle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Ignore routine updates" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/app_list_refresh"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/app_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:fastScrollAlwaysVisible="true"
|
||||
android:fastScrollEnabled="true"
|
||||
android:paddingBottom="16dp" />
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/app_list_loading"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ProgressBar
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Loading apps…" />
|
||||
</LinearLayout>
|
||||
</ScrollView></androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -6,9 +6,9 @@ import org.junit.Test
|
||||
class AppListOrderingTest {
|
||||
private fun app(label: String, seen: Boolean = false, selected: Boolean = false) = ListedApp(label, "pkg.$label", seen, selected)
|
||||
|
||||
@Test fun `seen apps are first with a separator when requested`() {
|
||||
@Test fun `seen apps are repeated before the full list when requested`() {
|
||||
val items = AppListOrdering.items(listOf(app("Zulu"), app("Beta", seen = true), app("Alpha", seen = true)), false, true)
|
||||
assertEquals(listOf("Alpha", "Beta", "|", "Zulu"), items.map { if (it is AppListItem.App) it.value.label else "|" })
|
||||
assertEquals(listOf("Alpha", "Beta", "|", "Alpha", "Beta", "Zulu"), items.map { if (it is AppListItem.App) it.value.label else "|" })
|
||||
}
|
||||
|
||||
@Test fun `selected unseen app remains after seen apps in only-seen mode`() {
|
||||
|
||||
@@ -6,6 +6,7 @@ coreKtx = "1.13.1"
|
||||
fragmentKtx = "1.8.5"
|
||||
material = "1.10.0"
|
||||
drawerLayout = "1.2.0"
|
||||
recyclerView = "1.3.2"
|
||||
junit = "4.13.2"
|
||||
|
||||
[libraries]
|
||||
@@ -14,6 +15,7 @@ core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx"
|
||||
fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" }
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
drawerlayout = { group = "androidx.drawerlayout", name = "drawerlayout", version.ref = "drawerLayout" }
|
||||
recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerView" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
|
||||
[plugins]
|
||||
|
||||
Reference in New Issue
Block a user