Skip to content

Commit 82d689e

Browse files
edit tags
Signed-off-by: tobiasKaminsky <tobias@kaminsky.me>
1 parent 46e45c3 commit 82d689e

11 files changed

Lines changed: 640 additions & 28 deletions

File tree

app/src/main/java/com/nextcloud/client/di/ComponentsModule.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import com.nextcloud.ui.SetStatusMessageBottomSheet;
3535
import com.nextcloud.ui.composeActivity.ComposeActivity;
3636
import com.nextcloud.ui.fileactions.FileActionsBottomSheet;
37+
import com.nextcloud.ui.tags.TagManagementBottomSheet;
3738
import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsBottomSheet;
3839
import com.nmc.android.ui.LauncherActivity;
3940
import com.owncloud.android.MainApp;
@@ -516,4 +517,7 @@ abstract class ComponentsModule {
516517

517518
@ContributesAndroidInjector
518519
abstract CommunityFragment communityFragment();
520+
521+
@ContributesAndroidInjector
522+
abstract TagManagementBottomSheet tagManagementBottomSheet();
519523
}

app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import com.nextcloud.client.documentscan.DocumentScanViewModel
1313
import com.nextcloud.client.etm.EtmViewModel
1414
import com.nextcloud.client.logger.ui.LogsViewModel
1515
import com.nextcloud.ui.fileactions.FileActionsViewModel
16-
import com.owncloud.android.ui.preview.pdf.PreviewPdfViewModel
16+
import com.nextcloud.ui.tags.TagManagementViewModel
1717
import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsViewModel
18+
import com.owncloud.android.ui.preview.pdf.PreviewPdfViewModel
1819
import com.owncloud.android.ui.unifiedsearch.UnifiedSearchViewModel
1920
import dagger.Binds
2021
import dagger.Module
@@ -57,6 +58,11 @@ abstract class ViewModelModule {
5758
@ViewModelKey(TrashbinFileActionsViewModel::class)
5859
abstract fun trashbinFileActionsViewModel(vm: TrashbinFileActionsViewModel): ViewModel
5960

61+
@Binds
62+
@IntoMap
63+
@ViewModelKey(TagManagementViewModel::class)
64+
abstract fun tagManagementViewModel(vm: TagManagementViewModel): ViewModel
65+
6066
@Binds
6167
abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory
6268
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
6+
*/
7+
package com.nextcloud.ui.tags
8+
9+
import android.graphics.Color
10+
import android.graphics.drawable.GradientDrawable
11+
import android.view.LayoutInflater
12+
import android.view.View
13+
import android.view.ViewGroup
14+
import android.widget.CheckBox
15+
import android.widget.TextView
16+
import androidx.recyclerview.widget.RecyclerView
17+
import com.owncloud.android.R
18+
import com.owncloud.android.lib.resources.tags.Tag
19+
20+
class TagListAdapter(
21+
private val onTagChecked: (Tag, Boolean) -> Unit,
22+
private val onCreateTag: (String) -> Unit
23+
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
24+
25+
private var tags: List<Tag> = emptyList()
26+
private var assignedTagIds: Set<String> = emptySet()
27+
private var query: String = ""
28+
private var showCreateItem: Boolean = false
29+
30+
companion object {
31+
private const val VIEW_TYPE_TAG = 0
32+
private const val VIEW_TYPE_CREATE = 1
33+
}
34+
35+
fun update(allTags: List<Tag>, assignedIds: Set<String>, searchQuery: String) {
36+
this.assignedTagIds = assignedIds
37+
this.query = searchQuery
38+
39+
tags = if (searchQuery.isBlank()) {
40+
allTags
41+
} else {
42+
allTags.filter { it.name.contains(searchQuery, ignoreCase = true) }
43+
}
44+
45+
showCreateItem = searchQuery.isNotBlank() && tags.none { it.name.equals(searchQuery, ignoreCase = true) }
46+
47+
notifyDataSetChanged()
48+
}
49+
50+
override fun getItemCount(): Int = tags.size + if (showCreateItem) 1 else 0
51+
52+
override fun getItemViewType(position: Int): Int {
53+
return if (showCreateItem && position == tags.size) VIEW_TYPE_CREATE else VIEW_TYPE_TAG
54+
}
55+
56+
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
57+
val inflater = LayoutInflater.from(parent.context)
58+
return if (viewType == VIEW_TYPE_CREATE) {
59+
val view = inflater.inflate(R.layout.tag_list_item, parent, false)
60+
CreateTagViewHolder(view)
61+
} else {
62+
val view = inflater.inflate(R.layout.tag_list_item, parent, false)
63+
TagViewHolder(view)
64+
}
65+
}
66+
67+
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
68+
when (holder) {
69+
is TagViewHolder -> {
70+
val tag = tags[position]
71+
holder.bind(tag, tag.id in assignedTagIds)
72+
}
73+
is CreateTagViewHolder -> {
74+
holder.bind(query)
75+
}
76+
}
77+
}
78+
79+
inner class TagViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
80+
private val colorDot: View = itemView.findViewById(R.id.tag_color_dot)
81+
private val tagName: TextView = itemView.findViewById(R.id.tag_name)
82+
private val checkBox: CheckBox = itemView.findViewById(R.id.tag_checkbox)
83+
84+
fun bind(tag: Tag, isAssigned: Boolean) {
85+
tagName.text = tag.name
86+
87+
if (tag.color != null) {
88+
try {
89+
val color = Color.parseColor(tag.color)
90+
val background = colorDot.background
91+
if (background is GradientDrawable) {
92+
background.setColor(color)
93+
}
94+
colorDot.visibility = View.VISIBLE
95+
} catch (e: IllegalArgumentException) {
96+
colorDot.visibility = View.INVISIBLE
97+
}
98+
} else {
99+
colorDot.visibility = View.INVISIBLE
100+
}
101+
102+
checkBox.setOnCheckedChangeListener(null)
103+
checkBox.isChecked = isAssigned
104+
checkBox.setOnCheckedChangeListener { _, isChecked ->
105+
onTagChecked(tag, isChecked)
106+
}
107+
108+
itemView.setOnClickListener {
109+
checkBox.isChecked = !checkBox.isChecked
110+
}
111+
}
112+
}
113+
114+
inner class CreateTagViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
115+
private val colorDot: View = itemView.findViewById(R.id.tag_color_dot)
116+
private val tagName: TextView = itemView.findViewById(R.id.tag_name)
117+
private val checkBox: CheckBox = itemView.findViewById(R.id.tag_checkbox)
118+
119+
fun bind(name: String) {
120+
colorDot.visibility = View.INVISIBLE
121+
tagName.text = itemView.context.getString(R.string.create_tag_format, name)
122+
checkBox.visibility = View.GONE
123+
124+
itemView.setOnClickListener {
125+
onCreateTag(name)
126+
}
127+
}
128+
}
129+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
6+
*/
7+
package com.nextcloud.ui.tags
8+
9+
import android.os.Bundle
10+
import android.view.LayoutInflater
11+
import android.view.View
12+
import android.view.ViewGroup
13+
import androidx.core.os.bundleOf
14+
import androidx.core.widget.doAfterTextChanged
15+
import androidx.fragment.app.setFragmentResult
16+
import androidx.lifecycle.Lifecycle
17+
import androidx.lifecycle.ViewModelProvider
18+
import androidx.lifecycle.lifecycleScope
19+
import androidx.lifecycle.repeatOnLifecycle
20+
import androidx.recyclerview.widget.LinearLayoutManager
21+
import com.google.android.material.bottomsheet.BottomSheetBehavior
22+
import com.google.android.material.bottomsheet.BottomSheetDialog
23+
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
24+
import com.nextcloud.android.common.ui.theme.utils.ColorRole
25+
import com.nextcloud.client.di.Injectable
26+
import com.nextcloud.client.di.ViewModelFactory
27+
import com.owncloud.android.databinding.TagManagementBottomSheetBinding
28+
import com.owncloud.android.lib.resources.tags.Tag
29+
import com.owncloud.android.utils.theme.ViewThemeUtils
30+
import kotlinx.coroutines.launch
31+
import javax.inject.Inject
32+
33+
class TagManagementBottomSheet : BottomSheetDialogFragment(), Injectable {
34+
35+
@Inject
36+
lateinit var vmFactory: ViewModelFactory
37+
38+
@Inject
39+
lateinit var viewThemeUtils: ViewThemeUtils
40+
41+
private var _binding: TagManagementBottomSheetBinding? = null
42+
private val binding get() = _binding!!
43+
44+
private lateinit var viewModel: TagManagementViewModel
45+
private lateinit var tagAdapter: TagListAdapter
46+
47+
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
48+
viewModel = ViewModelProvider(this, vmFactory)[TagManagementViewModel::class.java]
49+
_binding = TagManagementBottomSheetBinding.inflate(inflater, container, false)
50+
51+
val bottomSheetDialog = dialog as BottomSheetDialog
52+
bottomSheetDialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED
53+
bottomSheetDialog.behavior.skipCollapsed = true
54+
55+
viewThemeUtils.platform.colorViewBackground(binding.bottomSheet, ColorRole.SURFACE)
56+
57+
setupAdapter()
58+
setupSearch()
59+
observeState()
60+
61+
val fileId = requireArguments().getLong(ARG_FILE_ID)
62+
val currentTags = requireArguments().getParcelableArrayList<Tag>(ARG_CURRENT_TAGS) ?: arrayListOf()
63+
viewModel.load(fileId, currentTags)
64+
65+
return binding.root
66+
}
67+
68+
private fun setupAdapter() {
69+
tagAdapter = TagListAdapter(
70+
onTagChecked = { tag, isChecked ->
71+
if (isChecked) {
72+
viewModel.assignTag(tag)
73+
} else {
74+
viewModel.unassignTag(tag)
75+
}
76+
},
77+
onCreateTag = { name ->
78+
viewModel.createAndAssignTag(name)
79+
binding.searchEditText.text?.clear()
80+
}
81+
)
82+
83+
binding.tagList.apply {
84+
layoutManager = LinearLayoutManager(requireContext())
85+
adapter = tagAdapter
86+
}
87+
}
88+
89+
private fun setupSearch() {
90+
binding.searchEditText.doAfterTextChanged { text ->
91+
viewModel.setSearchQuery(text?.toString() ?: "")
92+
}
93+
}
94+
95+
private fun observeState() {
96+
viewLifecycleOwner.lifecycleScope.launch {
97+
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
98+
viewModel.uiState.collect { state ->
99+
when (state) {
100+
is TagManagementViewModel.TagUiState.Loading -> {
101+
binding.loadingIndicator.visibility = View.VISIBLE
102+
binding.tagList.visibility = View.GONE
103+
}
104+
is TagManagementViewModel.TagUiState.Loaded -> {
105+
binding.loadingIndicator.visibility = View.GONE
106+
binding.tagList.visibility = View.VISIBLE
107+
tagAdapter.update(state.allTags, state.assignedTagIds, state.query)
108+
}
109+
is TagManagementViewModel.TagUiState.Error -> {
110+
binding.loadingIndicator.visibility = View.GONE
111+
binding.tagList.visibility = View.GONE
112+
}
113+
}
114+
}
115+
}
116+
}
117+
}
118+
119+
override fun onDestroyView() {
120+
val assignedTags = viewModel.getAssignedTags()
121+
setFragmentResult(REQUEST_KEY, bundleOf(RESULT_KEY_TAGS to ArrayList(assignedTags)))
122+
123+
super.onDestroyView()
124+
_binding = null
125+
}
126+
127+
companion object {
128+
const val REQUEST_KEY = "TAG_MANAGEMENT_REQUEST"
129+
const val RESULT_KEY_TAGS = "RESULT_TAGS"
130+
private const val ARG_FILE_ID = "ARG_FILE_ID"
131+
private const val ARG_CURRENT_TAGS = "ARG_CURRENT_TAGS"
132+
133+
fun newInstance(fileId: Long, currentTags: List<Tag>): TagManagementBottomSheet {
134+
return TagManagementBottomSheet().apply {
135+
arguments = bundleOf(
136+
ARG_FILE_ID to fileId,
137+
ARG_CURRENT_TAGS to ArrayList(currentTags)
138+
)
139+
}
140+
}
141+
}
142+
}

0 commit comments

Comments
 (0)