diff --git a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java index d6f19299750b..f2730df49df4 100644 --- a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java +++ b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java @@ -33,6 +33,7 @@ import com.nextcloud.ui.SetStatusMessageBottomSheet; import com.nextcloud.ui.composeActivity.ComposeActivity; import com.nextcloud.ui.fileactions.FileActionsBottomSheet; +import com.nextcloud.ui.tags.TagManagementBottomSheet; import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsBottomSheet; import com.nmc.android.ui.LauncherActivity; import com.owncloud.android.MainApp; @@ -512,4 +513,7 @@ abstract class ComponentsModule { @ContributesAndroidInjector abstract CommunityFragment communityFragment(); + + @ContributesAndroidInjector + abstract TagManagementBottomSheet tagManagementBottomSheet(); } diff --git a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt index eb3e98a6c570..4426fcfd6c1a 100644 --- a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt @@ -13,8 +13,8 @@ import com.nextcloud.client.documentscan.DocumentScanViewModel import com.nextcloud.client.etm.EtmViewModel import com.nextcloud.client.logger.ui.LogsViewModel import com.nextcloud.ui.fileactions.FileActionsViewModel -import com.owncloud.android.ui.preview.pdf.PreviewPdfViewModel import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsViewModel +import com.owncloud.android.ui.preview.pdf.PreviewPdfViewModel import com.owncloud.android.ui.unifiedsearch.UnifiedSearchViewModel import dagger.Binds import dagger.Module diff --git a/app/src/main/java/com/nextcloud/ui/tags/TagManagementBottomSheet.kt b/app/src/main/java/com/nextcloud/ui/tags/TagManagementBottomSheet.kt new file mode 100644 index 000000000000..004fec2659ae --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/tags/TagManagementBottomSheet.kt @@ -0,0 +1,154 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.tags + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.BundleCompat +import androidx.core.os.bundleOf +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.di.Injectable +import com.nextcloud.ui.tags.adapter.TagListAdapter +import com.nextcloud.ui.tags.model.TagUiState +import com.nextcloud.ui.tags.repository.TagManagementRepositoryImpl +import com.nextcloud.utils.extensions.getTypedActivity +import com.owncloud.android.databinding.TagManagementBottomSheetBinding +import com.owncloud.android.lib.resources.tags.Tag +import com.owncloud.android.ui.activity.BaseActivity +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.launch +import javax.inject.Inject + +class TagManagementBottomSheet : + BottomSheetDialogFragment(), + Injectable { + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + private var _binding: TagManagementBottomSheetBinding? = null + private val binding get() = _binding!! + + private lateinit var viewModel: TagManagementViewModel + private lateinit var tagAdapter: TagListAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val clientRepository = getTypedActivity(BaseActivity::class.java)?.clientRepository + ?: error("clientRepository not available") + val repository = TagManagementRepositoryImpl(clientRepository) + viewModel = TagManagementViewModel(repository) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = TagManagementBottomSheetBinding.inflate(inflater, container, false) + + val bottomSheetDialog = dialog as BottomSheetDialog + bottomSheetDialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + bottomSheetDialog.behavior.skipCollapsed = true + + viewThemeUtils.platform.colorViewBackground(binding.bottomSheet, ColorRole.SURFACE) + + setupAdapter() + setupSearch() + observeState() + + val fileId = requireArguments().getLong(ARG_FILE_ID) + val currentTags = BundleCompat.getParcelableArrayList(requireArguments(), ARG_CURRENT_TAGS, Tag::class.java) + ?: arrayListOf() + viewModel.fetch(fileId, currentTags) + + return binding.root + } + + private fun setupAdapter() { + tagAdapter = TagListAdapter( + onTagChecked = { tag, isChecked -> + if (isChecked) { + viewModel.assignTag(tag) + } else { + viewModel.unassignTag(tag) + } + }, + onCreateTag = { name -> + viewModel.createAndAssignTag(name) + binding.searchEditText.text?.clear() + } + ) + + binding.tagList.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = tagAdapter + } + } + + private fun setupSearch() { + binding.searchEditText.doAfterTextChanged { text -> + viewModel.setSearchQuery(text?.toString() ?: "") + } + } + + private fun observeState() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> + when (state) { + is TagUiState.Loading -> { + binding.loadingIndicator.visibility = View.VISIBLE + binding.tagList.visibility = View.GONE + } + + is TagUiState.Loaded -> { + binding.loadingIndicator.visibility = View.GONE + binding.tagList.visibility = View.VISIBLE + tagAdapter.update(state.allTags, state.assignedTagIds, state.query) + } + + is TagUiState.Error -> { + binding.loadingIndicator.visibility = View.GONE + binding.tagList.visibility = View.GONE + } + } + } + } + } + } + + override fun onDestroyView() { + val assignedTags = viewModel.getAssignedTags() + setFragmentResult(REQUEST_KEY, bundleOf(RESULT_KEY_TAGS to ArrayList(assignedTags))) + + super.onDestroyView() + _binding = null + } + + companion object { + const val REQUEST_KEY = "TAG_MANAGEMENT_REQUEST" + const val RESULT_KEY_TAGS = "RESULT_TAGS" + private const val ARG_FILE_ID = "ARG_FILE_ID" + private const val ARG_CURRENT_TAGS = "ARG_CURRENT_TAGS" + + fun newInstance(fileId: Long, currentTags: List): TagManagementBottomSheet = + TagManagementBottomSheet().apply { + arguments = bundleOf( + ARG_FILE_ID to fileId, + ARG_CURRENT_TAGS to ArrayList(currentTags) + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/tags/TagManagementViewModel.kt b/app/src/main/java/com/nextcloud/ui/tags/TagManagementViewModel.kt new file mode 100644 index 000000000000..eec2f5f231e6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/tags/TagManagementViewModel.kt @@ -0,0 +1,110 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.ui.tags + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.ui.tags.model.TagUiState +import com.nextcloud.ui.tags.model.toLoaded +import com.nextcloud.ui.tags.repository.TagManagementRepository +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.tags.Tag +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class TagManagementViewModel(private val repository: TagManagementRepository) : ViewModel() { + + companion object { + private const val TAG = "TagManagementViewModel" + } + + private val _uiState = MutableStateFlow(TagUiState.Loading) + val uiState: StateFlow = _uiState + + private var fileId: Long = -1 + + fun fetch(fileId: Long, currentTags: List) { + this.fileId = fileId + viewModelScope.launch { + val tags = repository.fetch(fileId, currentTags) + + // TODO: handle error ui state + _uiState.update { + tags.toLoaded(currentTags) + } + } + } + + fun assignTag(tag: Tag) = setTagAssigned(tag, assign = true) + + fun unassignTag(tag: Tag) = setTagAssigned(tag, assign = false) + + // TODO: handle error ui state + private fun setTagAssigned(tag: Tag, assign: Boolean) { + val loaded = _uiState.value as? TagUiState.Loaded ?: return + if ((tag.id in loaded.assignedTagIds) == assign) return + + fun apply(assigned: Boolean) = _uiState.update { state -> + if (state is TagUiState.Loaded) { + state.copy( + assignedTagIds = if (assigned) state.assignedTagIds + tag.id else state.assignedTagIds - tag.id + ) + } else { + state + } + } + + apply(assign) + + viewModelScope.launch { + val success = if (assign) repository.assignTag(fileId, tag) else repository.unassignTag(fileId, tag) + if (!success) { + Log_OC.e(TAG, "cannot ${if (assign) "assign" else "unassign"} tag") + apply(!assign) + } + } + } + + fun createAndAssignTag(name: String) { + viewModelScope.launch { + val (allTags, newTagId) = repository.createAndAssignTag(fileId, name) ?: return@launch + _uiState.update { state -> + if (state is TagUiState.Loaded) { + state.copy( + allTags = allTags, + assignedTagIds = state.assignedTagIds + newTagId + ) + } else { + TagUiState.Loaded( + allTags = allTags, + assignedTagIds = setOf(newTagId) + ) + } + } + } + } + + fun setSearchQuery(query: String) { + _uiState.update { state -> + if (state is TagUiState.Loaded) { + state.copy(query = query) + } else { + state + } + } + } + + fun getAssignedTags(): List { + val state = _uiState.value + if (state is TagUiState.Loaded) { + return state.allTags.filter { it.id in state.assignedTagIds } + } + return emptyList() + } +} diff --git a/app/src/main/java/com/nextcloud/ui/tags/adapter/TagListAdapter.kt b/app/src/main/java/com/nextcloud/ui/tags/adapter/TagListAdapter.kt new file mode 100644 index 000000000000..562a83ff7115 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/tags/adapter/TagListAdapter.kt @@ -0,0 +1,86 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.tags.adapter + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.ui.tags.adapter.viewholder.CreateTagViewHolder +import com.nextcloud.ui.tags.adapter.viewholder.TagViewHolder +import com.owncloud.android.R +import com.owncloud.android.lib.resources.tags.Tag + +class TagListAdapter(private val onTagChecked: (Tag, Boolean) -> Unit, private val onCreateTag: (String) -> Unit) : + RecyclerView.Adapter() { + + private var tags: List = emptyList() + private var assignedTagIds: Set = emptySet() + private var query: String = "" + private var showCreateItem: Boolean = false + + companion object { + private const val VIEW_TYPE_TAG = 0 + private const val VIEW_TYPE_CREATE = 1 + } + + init { + setHasStableIds(true) + } + + @SuppressLint("NotifyDataSetChanged") + fun update(allTags: List, assignedIds: Set, searchQuery: String) { + this.assignedTagIds = assignedIds + this.query = searchQuery + + tags = if (searchQuery.isBlank()) { + allTags + } else { + allTags.filter { it.name.contains(searchQuery, ignoreCase = true) } + } + + showCreateItem = searchQuery.isNotBlank() && tags.none { it.name.equals(searchQuery, ignoreCase = true) } + + notifyDataSetChanged() + } + + override fun getItemId(position: Int): Long = if (getItemViewType(position) == VIEW_TYPE_CREATE) { + Long.MIN_VALUE + } else { + tags[position].id.hashCode().toLong() + } + + override fun getItemCount(): Int = tags.size + if (showCreateItem) 1 else 0 + + override fun getItemViewType(position: Int): Int = + if (showCreateItem && position == tags.size) VIEW_TYPE_CREATE else VIEW_TYPE_TAG + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return if (viewType == VIEW_TYPE_CREATE) { + val view = inflater.inflate(R.layout.tag_list_item, parent, false) + CreateTagViewHolder(view) + } else { + val view = inflater.inflate(R.layout.tag_list_item, parent, false) + TagViewHolder(view) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is TagViewHolder -> { + val tag = tags[position] + holder.bind(tag, tag.id in assignedTagIds, onTagChecked) + } + + is CreateTagViewHolder -> { + holder.bind(query, onCreateTag) + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/tags/adapter/viewholder/CreateTagViewHolder.kt b/app/src/main/java/com/nextcloud/ui/tags/adapter/viewholder/CreateTagViewHolder.kt new file mode 100644 index 000000000000..0439dffbe539 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/tags/adapter/viewholder/CreateTagViewHolder.kt @@ -0,0 +1,30 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.tags.adapter.viewholder + +import android.view.View +import android.widget.CheckBox +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.owncloud.android.R + +class CreateTagViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val colorDot: View = itemView.findViewById(R.id.tag_color_dot) + private val tagName: TextView = itemView.findViewById(R.id.tag_name) + private val checkBox: CheckBox = itemView.findViewById(R.id.tag_checkbox) + + fun bind(name: String, onCreateTag: (String) -> Unit) { + colorDot.visibility = View.INVISIBLE + tagName.text = itemView.context.getString(R.string.create_tag_format, name) + checkBox.visibility = View.GONE + + itemView.setOnClickListener { + onCreateTag(name) + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/tags/adapter/viewholder/TagViewHolder.kt b/app/src/main/java/com/nextcloud/ui/tags/adapter/viewholder/TagViewHolder.kt new file mode 100644 index 000000000000..276c8751d934 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/tags/adapter/viewholder/TagViewHolder.kt @@ -0,0 +1,52 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.tags.adapter.viewholder + +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.view.View +import android.widget.CheckBox +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.owncloud.android.R +import com.owncloud.android.lib.resources.tags.Tag + +class TagViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val colorDot: View = itemView.findViewById(R.id.tag_color_dot) + private val tagName: TextView = itemView.findViewById(R.id.tag_name) + private val checkBox: CheckBox = itemView.findViewById(R.id.tag_checkbox) + + fun bind(tag: Tag, isAssigned: Boolean, onTagChecked: (Tag, Boolean) -> Unit) { + tagName.text = tag.name + + if (tag.color != null) { + try { + val color = Color.parseColor(tag.color) + val background = colorDot.background + if (background is GradientDrawable) { + background.setColor(color) + } + colorDot.visibility = View.VISIBLE + } catch (e: IllegalArgumentException) { + colorDot.visibility = View.INVISIBLE + } + } else { + colorDot.visibility = View.INVISIBLE + } + + checkBox.setOnCheckedChangeListener(null) + checkBox.isChecked = isAssigned + checkBox.setOnCheckedChangeListener { _, isChecked -> + onTagChecked(tag, isChecked) + } + + itemView.setOnClickListener { + checkBox.isChecked = !checkBox.isChecked + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/tags/model/TagUiState.kt b/app/src/main/java/com/nextcloud/ui/tags/model/TagUiState.kt new file mode 100644 index 000000000000..d1156f22c474 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/tags/model/TagUiState.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.tags.model + +import com.owncloud.android.lib.resources.tags.Tag + +sealed interface TagUiState { + object Loading : TagUiState + data class Loaded(val allTags: List, val assignedTagIds: Set, val query: String = "") : TagUiState + object Error : TagUiState +} + +fun List.toLoaded(currentTags: List): TagUiState { + val assignedTagNames = currentTags.map { it.name }.toSet() + + val assignedIds = this + .filter { it.name in assignedTagNames } + .map { it.id } + .toSet() + + return TagUiState.Loaded(this, assignedIds) +} diff --git a/app/src/main/java/com/nextcloud/ui/tags/repository/TagManagementRepository.kt b/app/src/main/java/com/nextcloud/ui/tags/repository/TagManagementRepository.kt new file mode 100644 index 000000000000..47f42d64f224 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/tags/repository/TagManagementRepository.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.tags.repository + +import com.owncloud.android.lib.resources.tags.Tag + +interface TagManagementRepository { + suspend fun fetch(fileId: Long, currentTags: List): List + suspend fun assignTag(fileId: Long, tag: Tag): Boolean + suspend fun unassignTag(fileId: Long, tag: Tag): Boolean + suspend fun createAndAssignTag(fileId: Long, name: String): Pair, String>? +} diff --git a/app/src/main/java/com/nextcloud/ui/tags/repository/TagManagementRepositoryImpl.kt b/app/src/main/java/com/nextcloud/ui/tags/repository/TagManagementRepositoryImpl.kt new file mode 100644 index 000000000000..30ab30629a93 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/tags/repository/TagManagementRepositoryImpl.kt @@ -0,0 +1,86 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.tags.repository + +import com.nextcloud.repository.ClientRepository +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.tags.CreateTagRemoteOperation +import com.owncloud.android.lib.resources.tags.DeleteTagRemoteOperation +import com.owncloud.android.lib.resources.tags.GetTagsRemoteOperation +import com.owncloud.android.lib.resources.tags.PutTagRemoteOperation +import com.owncloud.android.lib.resources.tags.Tag +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Suppress("TooGenericExceptionCaught") +class TagManagementRepositoryImpl(private val clientRepository: ClientRepository) : TagManagementRepository { + + companion object { + private const val TAG = "TagManagementRepositoryImpl" + } + + override suspend fun fetch(fileId: Long, currentTags: List): List = withContext(Dispatchers.IO) { + return@withContext try { + val ocClient = clientRepository.getOwncloudClient() ?: return@withContext listOf() + val result = GetTagsRemoteOperation().execute(ocClient) + if (result.isSuccess) result.resultData else listOf() + } catch (e: Exception) { + Log_OC.e(TAG, "cannot fetch tags: $e") + listOf() + } + } + + override suspend fun assignTag(fileId: Long, tag: Tag): Boolean = withContext(Dispatchers.IO) { + return@withContext try { + val ncClient = clientRepository.getNextcloudClient() ?: return@withContext false + val result = PutTagRemoteOperation(tag.id, fileId).execute(ncClient) + result.isSuccess + } catch (e: Exception) { + Log_OC.e(TAG, "cannot assign tag: $e") + false + } + } + + override suspend fun unassignTag(fileId: Long, tag: Tag): Boolean = withContext(Dispatchers.IO) { + return@withContext try { + val ncClient = clientRepository.getNextcloudClient() ?: return@withContext false + val result = DeleteTagRemoteOperation(tag.id, fileId).execute(ncClient) + result.isSuccess + } catch (e: Exception) { + Log_OC.e(TAG, "cannot unassign tag: $e") + false + } + } + + override suspend fun createAndAssignTag(fileId: Long, name: String): Pair, String>? = + withContext(Dispatchers.IO) { + return@withContext try { + val ncClient = clientRepository.getNextcloudClient() ?: return@withContext null + val ocClient = clientRepository.getOwncloudClient() ?: return@withContext null + + val createResult = CreateTagRemoteOperation(name).execute(ncClient) + if (!createResult.isSuccess) { + return@withContext null + } + + val tagsResult = GetTagsRemoteOperation().execute(ocClient) + if (!tagsResult.isSuccess) { + return@withContext null + } + + val allTags = tagsResult.resultData + val newTag = allTags.find { it.name == name } ?: return@withContext null + + val result = PutTagRemoteOperation(newTag.id, fileId).execute(ncClient) + if (result.isSuccess) allTags to newTag.id else null + } catch (e: Exception) { + Log_OC.e(TAG, "cannot create and assign tag: $e") + null + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/tags/util/TagChipsHelper.kt b/app/src/main/java/com/nextcloud/ui/tags/util/TagChipsHelper.kt new file mode 100644 index 000000000000..bff9e19ecf91 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/tags/util/TagChipsHelper.kt @@ -0,0 +1,67 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.tags.util + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.view.View +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup +import com.owncloud.android.R +import com.owncloud.android.lib.resources.tags.Tag +import com.owncloud.android.utils.theme.ViewThemeUtils + +class TagChipsHelper(private val viewThemeUtils: ViewThemeUtils) { + companion object { + private const val CORNER_SIZE = 100.0f + } + + fun refresh(context: Context, chipGroup: ChipGroup, tags: List, onEditClicked: Runnable) { + chipGroup.removeAllViews() + chipGroup.visibility = View.VISIBLE + + for (tag in tags) { + val chip = Chip(context).apply { + text = tag.name + chipBackgroundColor = ColorStateList.valueOf( + context.resources.getColor(R.color.bg_default, context.theme) + ) + shapeAppearanceModel = shapeAppearanceModel.toBuilder() + .setAllCornerSizes(CORNER_SIZE) + .build() + isClickable = false + } + chip.setEnsureMinTouchTargetSize(false) + viewThemeUtils.material.themeChipSuggestion(chip) + + if (tag.color != null) { + val color = Color.parseColor(tag.color) + chip.chipStrokeColor = ColorStateList.valueOf(color) + chip.setTextColor(color) + } + + chipGroup.addView(chip) + } + + val editChip = Chip(context).apply { + setChipIconResource(R.drawable.ic_edit) + setText(R.string.manage_tags) + chipBackgroundColor = ColorStateList.valueOf( + context.resources.getColor(R.color.bg_default, context.theme) + ) + shapeAppearanceModel = shapeAppearanceModel.toBuilder() + .setAllCornerSizes(CORNER_SIZE) + .build() + setOnClickListener { onEditClicked.run() } + } + editChip.setEnsureMinTouchTargetSize(false) + viewThemeUtils.material.themeChipSuggestion(editChip) + chipGroup.addView(editChip) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java index 829e1b4dcc31..102e068ea5e8 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java @@ -10,9 +10,7 @@ package com.owncloud.android.ui.fragment; import android.content.Context; -import android.content.res.ColorStateList; import android.graphics.Bitmap; -import android.graphics.Color; import android.os.Bundle; import android.view.LayoutInflater; import android.view.Menu; @@ -20,7 +18,6 @@ import android.view.View.OnClickListener; import android.view.ViewGroup; -import com.google.android.material.chip.Chip; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.tabs.TabLayout; import com.nextcloud.client.account.User; @@ -34,6 +31,8 @@ import com.nextcloud.client.preferences.AppPreferences; import com.nextcloud.ui.fileactions.FileAction; import com.nextcloud.ui.fileactions.FileActionsBottomSheet; +import com.nextcloud.ui.tags.util.TagChipsHelper; +import com.nextcloud.ui.tags.TagManagementBottomSheet; import com.nextcloud.utils.MenuUtils; import com.nextcloud.utils.extensions.BundleExtensionsKt; import com.nextcloud.utils.extensions.FileExtensionsKt; @@ -113,6 +112,8 @@ public class FileDetailFragment extends FileFragment implements OnClickListener, @Inject ViewThemeUtils viewThemeUtils; @Inject BackgroundJobManager backgroundJobManager; + private TagChipsHelper tagChipsHelper; + /** * Public factory method to create new FileDetailFragment instances. *

@@ -241,29 +242,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, return null; } - if (getFile().getTags().isEmpty()) { - binding.tagsGroup.setVisibility(View.GONE); - } else { - for (Tag tag : getFile().getTags()) { - Chip chip = new Chip(context); - chip.setText(tag.getName()); - chip.setChipBackgroundColor(ColorStateList.valueOf(getResources().getColor(R.color.bg_default, - context.getTheme()))); - chip.setShapeAppearanceModel(chip.getShapeAppearanceModel().toBuilder().setAllCornerSizes((100.0f)) - .build()); - chip.setEnsureMinTouchTargetSize(false); - chip.setClickable(false); - viewThemeUtils.material.themeChipSuggestion(chip); - - if (tag.getColor() != null) { - int color = Color.parseColor(tag.getColor()); - chip.setChipStrokeColor(ColorStateList.valueOf(color)); - chip.setTextColor(color); - } - - binding.tagsGroup.addView(chip); - } - } + refreshTagChips(context); return view; } @@ -282,6 +261,22 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat updateFileDetails(false, false); } + + getChildFragmentManager().setFragmentResultListener( + TagManagementBottomSheet.REQUEST_KEY, + getViewLifecycleOwner(), + (requestKey, result) -> { + ArrayList updatedTags = result.getParcelableArrayList(TagManagementBottomSheet.RESULT_KEY_TAGS); + if (updatedTags != null) { + getFile().setTags(updatedTags); + storageManager.saveFile(getFile()); + Context ctx = getContext(); + if (ctx != null) { + refreshTagChips(ctx); + } + } + } + ); } @Override @@ -301,6 +296,21 @@ private void onOverflowIconClicked() { .show(fragmentManager, "actions"); } + private void refreshTagChips(Context context) { + new TagChipsHelper(viewThemeUtils).refresh( + context, + binding.tagsGroup, + getFile().getTags(), + () -> { + TagManagementBottomSheet bottomSheet = TagManagementBottomSheet.Companion.newInstance( + getFile().getLocalId(), + getFile().getTags() + ); + bottomSheet.show(getChildFragmentManager(), "tag_management"); + } + ); + } + private void setupViewPager() { binding.tabLayout.removeAllTabs(); diff --git a/app/src/main/res/drawable/ic_tag_color_dot.xml b/app/src/main/res/drawable/ic_tag_color_dot.xml new file mode 100644 index 000000000000..ee7c3e0ea9d5 --- /dev/null +++ b/app/src/main/res/drawable/ic_tag_color_dot.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/layout/tag_list_item.xml b/app/src/main/res/layout/tag_list_item.xml new file mode 100644 index 000000000000..f10a065a70e9 --- /dev/null +++ b/app/src/main/res/layout/tag_list_item.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/tag_management_bottom_sheet.xml b/app/src/main/res/layout/tag_management_bottom_sheet.xml new file mode 100644 index 000000000000..53ce84305ac9 --- /dev/null +++ b/app/src/main/res/layout/tag_management_bottom_sheet.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d662ed36f1bf..57e9d6e69876 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1016,6 +1016,10 @@ New encrypted folder Virus detected. Upload cannot be completed! Tags + Manage tags + Search tags + Create tag: \"%1$s\" + Error managing tags Unable to fetch sharees. Adding sharee failed Adding share failed. This file or folder has already been shared with this person or group. diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 1d6b2710697a..e67049413ec2 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -21109,6 +21109,14 @@ + + + + + + + +