1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 package org.mozilla.fenix.library.bookmarks.edit
7 import android.content.DialogInterface
8 import android.content.res.ColorStateList
9 import android.os.Bundle
10 import android.text.Editable
11 import android.text.TextWatcher
12 import android.view.Menu
13 import android.view.MenuInflater
14 import android.view.MenuItem
15 import android.view.View
16 import androidx.appcompat.app.AlertDialog
17 import androidx.appcompat.app.AppCompatActivity
18 import androidx.appcompat.widget.Toolbar
19 import androidx.core.content.ContextCompat
20 import androidx.fragment.app.Fragment
21 import androidx.fragment.app.activityViewModels
22 import androidx.lifecycle.lifecycleScope
23 import androidx.navigation.Navigation
24 import androidx.navigation.fragment.findNavController
25 import androidx.navigation.fragment.navArgs
26 import kotlinx.coroutines.Dispatchers.IO
27 import kotlinx.coroutines.Dispatchers.Main
28 import kotlinx.coroutines.launch
29 import kotlinx.coroutines.withContext
30 import mozilla.appservices.places.uniffi.PlacesException
31 import mozilla.components.concept.storage.BookmarkInfo
32 import mozilla.components.concept.storage.BookmarkNode
33 import mozilla.components.concept.storage.BookmarkNodeType
34 import mozilla.components.support.ktx.android.content.getColorFromAttr
35 import mozilla.components.support.ktx.android.view.hideKeyboard
36 import mozilla.components.support.ktx.android.view.showKeyboard
37 import org.mozilla.fenix.NavHostActivity
38 import org.mozilla.fenix.R
39 import org.mozilla.fenix.components.FenixSnackbar
40 import org.mozilla.fenix.components.metrics.Event
41 import org.mozilla.fenix.databinding.FragmentEditBookmarkBinding
42 import org.mozilla.fenix.ext.components
43 import org.mozilla.fenix.ext.getRootView
44 import org.mozilla.fenix.ext.nav
45 import org.mozilla.fenix.ext.placeCursorAtEnd
46 import org.mozilla.fenix.ext.requireComponents
47 import org.mozilla.fenix.ext.setToolbarColors
48 import org.mozilla.fenix.ext.toShortUrl
49 import org.mozilla.fenix.library.bookmarks.BookmarksSharedViewModel
50 import org.mozilla.fenix.library.bookmarks.friendlyRootTitle
53 * Menu to edit the name, URL, and location of a bookmark item.
55 class EditBookmarkFragment : Fragment(R.layout.fragment_edit_bookmark) {
56 private var _binding: FragmentEditBookmarkBinding? = null
57 private val binding get() = _binding!!
59 private val args by navArgs<EditBookmarkFragmentArgs>()
60 private val sharedViewModel: BookmarksSharedViewModel by activityViewModels()
61 private var bookmarkNode: BookmarkNode? = null
62 private var bookmarkParent: BookmarkNode? = null
63 private var initialParentGuid: String? = null
65 override fun onCreate(savedInstanceState: Bundle?) {
66 super.onCreate(savedInstanceState)
67 setHasOptionsMenu(true)
70 override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
71 super.onViewCreated(view, savedInstanceState)
73 _binding = FragmentEditBookmarkBinding.bind(view)
77 viewLifecycleOwner.lifecycleScope.launch(Main) {
78 val context = requireContext()
79 val bookmarkNodeBeforeReload = bookmarkNode
80 val bookmarksStorage = context.components.core.bookmarksStorage
82 bookmarkNode = withContext(IO) {
83 bookmarksStorage.getBookmark(args.guidToEdit)
86 if (initialParentGuid == null) {
87 initialParentGuid = bookmarkNode?.parentGuid
90 bookmarkParent = withContext(IO) {
91 // Use user-selected parent folder if it's set, or node's current parent otherwise.
92 if (sharedViewModel.selectedFolder != null) {
93 sharedViewModel.selectedFolder
95 bookmarkNode?.parentGuid?.let { bookmarksStorage.getBookmark(it) }
99 when (bookmarkNode?.type) {
100 BookmarkNodeType.FOLDER -> {
101 activity?.title = getString(R.string.edit_bookmark_folder_fragment_title)
102 binding.inputLayoutBookmarkUrl.visibility = View.GONE
103 binding.bookmarkUrlEdit.visibility = View.GONE
104 binding.bookmarkUrlLabel.visibility = View.GONE
106 BookmarkNodeType.ITEM -> {
107 activity?.title = getString(R.string.edit_bookmark_fragment_title)
109 else -> throw IllegalArgumentException()
112 val currentBookmarkNode = bookmarkNode
113 if (currentBookmarkNode != null && currentBookmarkNode != bookmarkNodeBeforeReload) {
114 binding.bookmarkNameEdit.setText(currentBookmarkNode.title)
115 binding.bookmarkUrlEdit.setText(currentBookmarkNode.url)
118 bookmarkParent?.let { node ->
119 binding.bookmarkParentFolderSelector.text = friendlyRootTitle(context, node)
122 binding.bookmarkParentFolderSelector.setOnClickListener {
123 sharedViewModel.selectedFolder = null
125 R.id.bookmarkEditFragment,
126 EditBookmarkFragmentDirections
127 .actionBookmarkEditFragmentToBookmarkSelectFolderFragment(
128 allowCreatingNewFolder = false,
129 // Don't allow moving folders into themselves.
130 hideFolderGuid = when (bookmarkNode!!.type) {
131 BookmarkNodeType.FOLDER -> bookmarkNode!!.guid
138 binding.bookmarkNameEdit.apply {
144 binding.bookmarkUrlEdit.addTextChangedListener(object : TextWatcher {
145 override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
149 override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
150 binding.bookmarkUrlEdit.onTextChanged(s)
152 binding.inputLayoutBookmarkUrl.error = null
153 binding.inputLayoutBookmarkUrl.errorIconDrawable = null
156 override fun afterTextChanged(s: Editable?) {
163 private fun initToolbar() {
164 val activity = activity as AppCompatActivity
165 val actionBar = (activity as NavHostActivity).getSupportActionBarAndInflateIfNecessary()
166 val toolbar = activity.findViewById<Toolbar>(R.id.navigationToolbar)
167 toolbar?.setToolbarColors(
168 foreground = activity.getColorFromAttr(R.attr.textPrimary),
169 background = activity.getColorFromAttr(R.attr.foundation)
174 override fun onPause() {
176 binding.bookmarkNameEdit.hideKeyboard()
177 binding.bookmarkUrlEdit.hideKeyboard()
178 binding.progressBarBookmark.visibility = View.GONE
181 override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
182 inflater.inflate(R.menu.bookmarks_edit, menu)
185 override fun onOptionsItemSelected(item: MenuItem): Boolean {
186 return when (item.itemId) {
187 R.id.delete_bookmark_button -> {
188 displayDeleteBookmarkDialog()
191 R.id.save_bookmark_button -> {
192 updateBookmarkFromTextChanges()
196 else -> super.onOptionsItemSelected(item)
200 private fun displayDeleteBookmarkDialog() {
201 activity?.let { activity ->
202 AlertDialog.Builder(activity).apply {
203 setMessage(R.string.bookmark_deletion_confirmation)
204 setNegativeButton(R.string.bookmark_delete_negative) { dialog: DialogInterface, _ ->
207 setPositiveButton(R.string.tab_collection_dialog_positive) { dialog: DialogInterface, _ ->
208 // Use fragment's lifecycle; the view may be gone by the time dialog is interacted with.
209 lifecycleScope.launch(IO) {
210 requireComponents.core.bookmarksStorage.deleteNode(args.guidToEdit)
211 requireComponents.analytics.metrics.track(Event.RemoveBookmark)
214 Navigation.findNavController(requireActivity(), R.id.container)
217 bookmarkNode?.let { bookmark ->
219 view = activity.getRootView()!!,
220 isDisplayedWithBrowserToolbar = args.requiresSnackbarPaddingForToolbar
224 R.string.bookmark_deletion_snackbar_message,
225 bookmark.url?.toShortUrl(context.components.publicSuffixList)
240 private fun updateBookmarkFromTextChanges() {
241 binding.progressBarBookmark.visibility = View.VISIBLE
242 val nameText = binding.bookmarkNameEdit.text.toString()
243 val urlText = binding.bookmarkUrlEdit.text.toString()
244 updateBookmarkNode(nameText, urlText)
247 private fun updateBookmarkNode(title: String?, url: String?) {
248 viewLifecycleOwner.lifecycleScope.launch(IO) {
250 requireComponents.let { components ->
251 if (title != bookmarkNode?.title || url != bookmarkNode?.url) {
252 components.analytics.metrics.track(Event.EditedBookmark)
254 val parentGuid = sharedViewModel.selectedFolder?.guid ?: bookmarkNode!!.parentGuid
255 val parentChanged = initialParentGuid != parentGuid
256 // Only track the 'moved' event if new parent was selected.
258 components.analytics.metrics.track(Event.MovedBookmark)
260 components.core.bookmarksStorage.updateNode(
264 // Setting position to 'null' is treated as a 'move to the end' by the storage API.
265 if (parentChanged) null else bookmarkNode?.position,
267 if (bookmarkNode?.type == BookmarkNodeType.ITEM) url else null
272 binding.inputLayoutBookmarkUrl.error = null
273 binding.inputLayoutBookmarkUrl.errorIconDrawable = null
275 findNavController().popBackStack()
277 } catch (e: PlacesException.UrlParseFailed) {
279 binding.inputLayoutBookmarkUrl.error = getString(R.string.bookmark_invalid_url_error)
280 binding.inputLayoutBookmarkUrl.setErrorIconDrawable(R.drawable.mozac_ic_warning_with_bottom_padding)
281 binding.inputLayoutBookmarkUrl.setErrorIconTintList(
282 ColorStateList.valueOf(
283 ContextCompat.getColor(requireContext(), R.color.fx_mobile_text_color_warning)
289 binding.progressBarBookmark.visibility = View.INVISIBLE
292 override fun onDestroyView() {
293 super.onDestroyView()