From 97e7113f2dd8ba11df75e585e0ab03ccf01d8a52 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 30 Apr 2026 16:13:53 +0200 Subject: [PATCH 01/11] wip Signed-off-by: alperozturk96 --- .../com/nextcloud/ui/ImageDetailFragment.kt | 48 ++++++++++++++++++- .../ui/fragment/FileDetailFragment.java | 5 +- .../res/layout/item_dropdown_with_icon.xml | 9 ++++ .../layout/preview_image_details_fragment.xml | 16 +++++++ 4 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 app/src/main/res/layout/item_dropdown_with_icon.xml diff --git a/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt b/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt index 766809e8247c..c59590ee5c63 100644 --- a/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt +++ b/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt @@ -16,6 +16,7 @@ import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ArrayAdapter import androidx.annotation.VisibleForTesting import androidx.core.content.ContextCompat import androidx.core.net.toUri @@ -72,6 +73,51 @@ class ImageDetailFragment : override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = PreviewImageDetailsFragmentBinding.inflate(layoutInflater, container, false) + // TODO Bottom sheet or dropdown? + data class DropdownItem(val text: String, val iconRes: Int) + val items = listOf( + DropdownItem("Option 1", R.drawable.outline_camera_24), + DropdownItem("Option 2", R.drawable.outline_image_24), + DropdownItem("Option 3", R.drawable.ic_information_outline) + ) + + val adapter = object : ArrayAdapter(requireContext(), R.layout.item_dropdown_with_icon, items) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = super.getView(position, convertView, parent) as android.widget.TextView + val item = getItem(position) + if (item != null) { + view.text = item.text + val drawable = ContextCompat.getDrawable(context, item.iconRes)?.mutate() + drawable?.let { + viewThemeUtils.platform.tintDrawable(requireContext(),it, ColorRole.ON_SURFACE) + } + view.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) + } + return view + } + } + binding.dropdownMenuAutoComplete.setAdapter(adapter) + + val defaultSelectedItem = items.firstOrNull() + if (defaultSelectedItem != null) { + binding.dropdownMenuAutoComplete.setText(defaultSelectedItem.text, false) + val drawable = ContextCompat.getDrawable(requireContext(), defaultSelectedItem.iconRes)?.mutate() + drawable?.let { + viewThemeUtils.platform.tintDrawable(requireContext(),it, ColorRole.ON_SURFACE) + } + binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + binding.dropdownMenuAutoComplete.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.standard_padding) + } + + binding.dropdownMenuAutoComplete.setOnItemClickListener { _, _, position, _ -> + val selected = items[position] + binding.dropdownMenuAutoComplete.setText(selected.text, false) + val drawable = ContextCompat.getDrawable(requireContext(), selected.iconRes)?.mutate() + binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + binding.dropdownMenuAutoComplete.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.standard_padding) + } + + binding.fileDetailsIcon.setImageDrawable( viewThemeUtils.platform.tintDrawable( requireContext(), @@ -395,4 +441,4 @@ class ImageDetailFragment : } } } -} +} \ No newline at end of file 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 32ed83f9fa39..dfb1f82b3471 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 @@ -319,9 +319,8 @@ private void setupViewPager() { binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.share_dialog_title).setIcon(R.drawable.selector_tab_share)); } - if (MimeTypeUtil.isImage(getFile())) { - binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.filedetails_details).setIcon(R.drawable.selector_media)); - } + // TODO - Change detail tab icon? + binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.filedetails_details).setIcon(R.drawable.selector_media)); viewThemeUtils.material.themeTabLayout(binding.tabLayout); diff --git a/app/src/main/res/layout/item_dropdown_with_icon.xml b/app/src/main/res/layout/item_dropdown_with_icon.xml new file mode 100644 index 000000000000..e9324acddd27 --- /dev/null +++ b/app/src/main/res/layout/item_dropdown_with_icon.xml @@ -0,0 +1,9 @@ + + diff --git a/app/src/main/res/layout/preview_image_details_fragment.xml b/app/src/main/res/layout/preview_image_details_fragment.xml index 72f0017e2ac5..d49741a3f741 100644 --- a/app/src/main/res/layout/preview_image_details_fragment.xml +++ b/app/src/main/res/layout/preview_image_details_fragment.xml @@ -17,6 +17,22 @@ android:orientation="vertical" android:padding="16dp"> + + + + + + Date: Tue, 5 May 2026 09:01:51 +0200 Subject: [PATCH 02/11] Rename .java to .kt Signed-off-by: alperozturk96 --- .../{FileDetailTabAdapter.java => FileDetailTabAdapter.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/main/java/com/owncloud/android/ui/adapter/{FileDetailTabAdapter.java => FileDetailTabAdapter.kt} (100%) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt similarity index 100% rename from app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.java rename to app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt From f49b8de190889a444f8158299b24f72e96b8f58e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 09:01:52 +0200 Subject: [PATCH 03/11] introduce file info fragment, separate image detail logic Signed-off-by: alperozturk96 --- .../FileDetailFragmentStaticServerIT.kt | 4 +- .../nextcloud/client/di/ComponentsModule.java | 4 +- .../com/nextcloud/ui/ImageDetailFragment.kt | 444 ------------------ .../nextcloud/ui/fileInfo/FileInfoFragment.kt | 168 +++++++ .../nextcloud/ui/fileInfo/ImageDetailInfo.kt | 294 ++++++++++++ .../ui/fileInfo/model/ImageMetadata.kt | 26 + .../ui/adapter/FileDetailTabAdapter.kt | 113 ++--- .../ui/fragment/FileDetailFragment.java | 4 +- .../main/res/drawable/ic_dashboard_filled.xml | 24 + .../res/drawable/ic_dashboard_outlined.xml | 24 + .../main/res/drawable/selector_file_info.xml | 11 + ...ls_fragment.xml => file_info_fragment.xml} | 0 12 files changed, 592 insertions(+), 524 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt create mode 100644 app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt create mode 100644 app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt create mode 100644 app/src/main/java/com/nextcloud/ui/fileInfo/model/ImageMetadata.kt create mode 100644 app/src/main/res/drawable/ic_dashboard_filled.xml create mode 100644 app/src/main/res/drawable/ic_dashboard_outlined.xml create mode 100644 app/src/main/res/drawable/selector_file_info.xml rename app/src/main/res/layout/{preview_image_details_fragment.xml => file_info_fragment.xml} (100%) diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt index da634d4d62c6..94fe57651bc5 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt @@ -15,7 +15,7 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isRoot import com.nextcloud.test.TestActivity -import com.nextcloud.ui.ImageDetailFragment +import com.nextcloud.ui.fileInfo.FileInfoFragment import com.owncloud.android.AbstractIT import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile @@ -79,7 +79,7 @@ class FileDetailFragmentStaticServerIT : AbstractIT() { var activity: TestActivity? = null scenario.onActivity { sut -> activity = sut - val fragment = ImageDetailFragment.newInstance(oCFile, user).apply { + val fragment = FileInfoFragment.newInstance(oCFile, user).apply { hideMap() } sut.addFragment(fragment) 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 42be74854cca..2643b10e530a 100644 --- a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java +++ b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java @@ -29,7 +29,7 @@ import com.nextcloud.receiver.NetworkChangeReceiver; import com.nextcloud.ui.ChooseAccountDialogFragment; import com.nextcloud.ui.ChooseStorageLocationDialogFragment; -import com.nextcloud.ui.ImageDetailFragment; +import com.nextcloud.ui.fileInfo.FileInfoFragment; import com.nextcloud.ui.SetOnlineStatusBottomSheet; import com.nextcloud.ui.SetStatusMessageBottomSheet; import com.nextcloud.ui.composeActivity.ComposeActivity; @@ -487,7 +487,7 @@ abstract class ComponentsModule { abstract EditImageActivity editImageActivity(); @ContributesAndroidInjector - abstract ImageDetailFragment imageDetailFragment(); + abstract FileInfoFragment fileInfoFragment(); @ContributesAndroidInjector abstract EtmBackgroundJobsFragment etmBackgroundJobsFragment(); diff --git a/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt b/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt deleted file mode 100644 index c59590ee5c63..000000000000 --- a/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt +++ /dev/null @@ -1,444 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2023 ZetaTom - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.ui - -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.graphics.drawable.LayerDrawable -import android.os.Bundle -import android.os.Parcelable -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import androidx.annotation.VisibleForTesting -import androidx.core.content.ContextCompat -import androidx.core.net.toUri -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import com.nextcloud.android.common.ui.theme.utils.ColorRole -import com.nextcloud.client.NominatimClient -import com.nextcloud.client.account.User -import com.nextcloud.client.di.Injectable -import com.nextcloud.utils.extensions.getParcelableArgument -import com.nextcloud.utils.extensions.logFileSize -import com.owncloud.android.MainApp -import com.owncloud.android.R -import com.owncloud.android.databinding.PreviewImageDetailsFragmentBinding -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.datamodel.ThumbnailsCacheManager -import com.owncloud.android.utils.BitmapUtils -import com.owncloud.android.utils.DisplayUtils -import com.owncloud.android.utils.theme.ViewThemeUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.parcelize.Parcelize -import org.osmdroid.config.Configuration -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.CustomZoomButtonsController -import org.osmdroid.views.overlay.ItemizedIconOverlay -import org.osmdroid.views.overlay.ItemizedIconOverlay.OnItemGestureListener -import org.osmdroid.views.overlay.OverlayItem -import java.lang.Long.max -import java.text.DateFormat -import java.text.SimpleDateFormat -import java.util.Locale -import javax.inject.Inject -import kotlin.math.pow -import kotlin.math.roundToInt - -class ImageDetailFragment : - Fragment(), - Injectable { - private lateinit var binding: PreviewImageDetailsFragmentBinding - private lateinit var file: OCFile - private lateinit var user: User - private lateinit var metadata: ImageMetadata - private lateinit var nominatimClient: NominatimClient - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - private val tag = "ImageDetailFragment" - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = PreviewImageDetailsFragmentBinding.inflate(layoutInflater, container, false) - - // TODO Bottom sheet or dropdown? - data class DropdownItem(val text: String, val iconRes: Int) - val items = listOf( - DropdownItem("Option 1", R.drawable.outline_camera_24), - DropdownItem("Option 2", R.drawable.outline_image_24), - DropdownItem("Option 3", R.drawable.ic_information_outline) - ) - - val adapter = object : ArrayAdapter(requireContext(), R.layout.item_dropdown_with_icon, items) { - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val view = super.getView(position, convertView, parent) as android.widget.TextView - val item = getItem(position) - if (item != null) { - view.text = item.text - val drawable = ContextCompat.getDrawable(context, item.iconRes)?.mutate() - drawable?.let { - viewThemeUtils.platform.tintDrawable(requireContext(),it, ColorRole.ON_SURFACE) - } - view.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) - } - return view - } - } - binding.dropdownMenuAutoComplete.setAdapter(adapter) - - val defaultSelectedItem = items.firstOrNull() - if (defaultSelectedItem != null) { - binding.dropdownMenuAutoComplete.setText(defaultSelectedItem.text, false) - val drawable = ContextCompat.getDrawable(requireContext(), defaultSelectedItem.iconRes)?.mutate() - drawable?.let { - viewThemeUtils.platform.tintDrawable(requireContext(),it, ColorRole.ON_SURFACE) - } - binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) - binding.dropdownMenuAutoComplete.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.standard_padding) - } - - binding.dropdownMenuAutoComplete.setOnItemClickListener { _, _, position, _ -> - val selected = items[position] - binding.dropdownMenuAutoComplete.setText(selected.text, false) - val drawable = ContextCompat.getDrawable(requireContext(), selected.iconRes)?.mutate() - binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) - binding.dropdownMenuAutoComplete.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.standard_padding) - } - - - binding.fileDetailsIcon.setImageDrawable( - viewThemeUtils.platform.tintDrawable( - requireContext(), - R.drawable.outline_image_24, - ColorRole.ON_BACKGROUND - ) - ) - - binding.cameraInformationIcon.setImageDrawable( - viewThemeUtils.platform.tintDrawable( - requireContext(), - R.drawable.outline_camera_24, - ColorRole.ON_BACKGROUND - ) - ) - - val arguments = arguments ?: throw IllegalStateException("arguments are mandatory") - file = arguments.getParcelableArgument(ARG_FILE, OCFile::class.java)!! - user = arguments.getParcelableArgument(ARG_USER, User::class.java)!! - - if (savedInstanceState != null) { - file = savedInstanceState.getParcelableArgument(ARG_FILE, OCFile::class.java)!! - user = savedInstanceState.getParcelableArgument(ARG_USER, User::class.java)!! - metadata = savedInstanceState.getParcelableArgument(ARG_METADATA, ImageMetadata::class.java)!! - } - - nominatimClient = NominatimClient( - getString(R.string.osm_geocoder_url), - getString(R.string.osm_geocoder_contact) - ) - - return binding.root - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - file.logFileSize(tag) - outState.putParcelable(ARG_FILE, file) - outState.putParcelable(ARG_USER, user) - outState.putParcelable(ARG_METADATA, metadata) - } - - override fun onStart() { - super.onStart() - gatherMetadata() - setupFragment() - } - - @SuppressLint("LongMethod") - private fun setupFragment() { - binding.fileInformationTime.text = metadata.date - - // detailed file information - val fileInformation = mutableListOf() - if ((metadata.length ?: 0) > 0 && (metadata.width ?: 0) > 0) { - try { - @Suppress("MagicNumber") - val pxlCount = when (val res = metadata.length!! * metadata.width!!.toLong()) { - in 0..999999 -> "%.2f".format(res / 1000000f) - in 1000000..9999999 -> "%.1f".format(res / 1000000f) - else -> (res / 1000000).toString() - } - - fileInformation.add(String.format(getString(R.string.image_preview_unit_megapixel), pxlCount)) - fileInformation.add("${metadata.width!!} × ${metadata.length!!}") - } catch (_: NumberFormatException) { - } - } - metadata.fileSize?.let { fileInformation.add(it) } - - if (fileInformation.isNotEmpty()) { - binding.fileInformationDetails.text = fileInformation.joinToString(separator = TEXT_SEP) - binding.fileInformation.visibility = View.VISIBLE - } - - setImageTakenConditions() - - // initialise map and address views - metadata.location?.let { location -> - initMap(location.first, location.second) - binding.imageLocation.visibility = View.VISIBLE - - // launch reverse geocoding request - lifecycleScope.launch(Dispatchers.IO) { - val geocodingResult = nominatimClient.reverseGeocode(location.first, location.second) - if (geocodingResult != null) { - withContext(Dispatchers.Main) { - binding.imageLocationText.visibility = View.VISIBLE - binding.imageLocationText.text = geocodingResult.displayName - } - } - } - } - } - - private fun setImageTakenConditions() { - // camera make and model - val makeModel = if (metadata.make?.let { metadata.model?.contains(it) } == false) { - "${metadata.make} ${metadata.model}" - } else { - metadata.model ?: metadata.make - } - - if (metadata.make == null || metadata.model?.contains(metadata.make!!) == true) { - binding.imgTCMakeModel.text = metadata.model - } else { - binding.imgTCMakeModel.text = String.format( - getString(R.string.make_model), - metadata.make, - metadata.model - ) - } - - // image taking conditions - val imageTakingConditions = mutableListOf() - metadata.aperture?.let { - imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_fnumber), it)) - } - metadata.exposure?.let { - imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_seconds), it)) - } - metadata.focalLen?.let { - imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_millimetres), it)) - } - metadata.iso?.let { - imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_iso), it)) - } - - if (imageTakingConditions.isNotEmpty() && makeModel != null) { - binding.imgTCMakeModel.text = makeModel - binding.imgTCConditions.text = imageTakingConditions.joinToString(separator = TEXT_SEP) - binding.imgTC.visibility = View.VISIBLE - } - } - - @SuppressLint("ClickableViewAccessibility") - private fun initMap(latitude: Double, longitude: Double, zoom: Double = 13.0) { - // required for OpenStreetMap - Configuration.getInstance().userAgentValue = MainApp.getUserAgent() - - val location = GeoPoint(latitude, longitude) - - binding.imageLocationMap.apply { - setTileSource(TileSourceFactory.MAPNIK) - - // set expected boundaries - setScrollableAreaLimitLatitude(SCROLL_LIMIT, -SCROLL_LIMIT, 0) - isVerticalMapRepetitionEnabled = false - minZoomLevel = 2.0 - maxZoomLevel = NominatimClient.Companion.ZoomLevel.MAX.int.toDouble() - - // initial location - controller.setCenter(location) - controller.setZoom(zoom) - - // scale labels to be legible - isTilesScaledToDpi = true - setZoomRounding(true) - - // hide zoom buttons - zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) - - // enable multi-touch zoom - setMultiTouchControls(true) - setOnTouchListener { v, _ -> - v.parent.requestDisallowInterceptTouchEvent(true) - false - } - - val markerOverlay = ItemizedIconOverlay( - mutableListOf(OverlayItem(null, null, location)), - imagePinDrawable(context), - markerOnGestureListener(latitude, longitude), - context - ) - - overlays.add(markerOverlay) - - onResume() - } - - // add copyright notice - binding.imageLocationMapCopyright.text = binding.imageLocationMap.tileProvider.tileSource.copyrightNotice - } - - @VisibleForTesting - fun hideMap() { - binding.imageLocationMap.visibility = View.GONE - } - - @SuppressLint("SimpleDateFormat") - private fun gatherMetadata() { - val fileSize = DisplayUtils.bytesToHumanReadable(file.fileLength) - var timestamp = max(file.modificationTimestamp, file.creationTimestamp) - if (file.isDown) { - val exif = androidx.exifinterface.media.ExifInterface(file.storagePath) - var length = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_IMAGE_LENGTH)?.toInt() - var width = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_IMAGE_WIDTH)?.toInt() - var exposure = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_SHUTTER_SPEED_VALUE) - - // get timestamp from date string - exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_DATETIME)?.let { - timestamp = SimpleDateFormat("y:M:d H:m:s", Locale.ROOT).parse(it)?.time ?: timestamp - } - - // format exposure string - if (exposure == null) { - exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_EXPOSURE_TIME)?.let { - exposure = "1/" + (1 / it.toDouble()).toInt() - } - } else if ("/" in exposure!!) { - try { - exposure!!.split("/").also { - exposure = "1/" + 2f.pow(it[0].toFloat() / it[1].toFloat()).roundToInt() - } - } catch (_: NumberFormatException) { - } - } - - // determine size if not contained in exif data - if ((width ?: 0) <= 0 || (length ?: 0) <= 0) { - val res = BitmapUtils.getImageResolution(file.storagePath) - width = res[0] - length = res[1] - } - - metadata = ImageMetadata( - fileSize = fileSize, - length = length, - width = width, - exposure = exposure, - date = formatDate(timestamp), - location = exif.latLong?.let { Pair(it[0], it[1]) }, - aperture = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_F_NUMBER), - focalLen = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM), - make = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_MAKE), - model = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_MODEL), - iso = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_ISO_SPEED) ?: exif.getAttribute( - androidx.exifinterface.media.ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY - ) - ) - } else { - // get metadata from server - val location = if (file.geoLocation == null) { - null - } else { - Pair(file.geoLocation!!.latitude, file.geoLocation!!.longitude) - } - metadata = ImageMetadata( - fileSize = fileSize, - date = formatDate(timestamp), - location = location, - width = file.imageDimension?.width?.toInt(), - length = file.imageDimension?.height?.toInt() - ) - } - } - - @SuppressLint("SimpleDateFormat") - private fun formatDate(timestamp: Long): String = buildString { - append(SimpleDateFormat("EEEE").format(timestamp)) - append(TEXT_SEP) - append(DateFormat.getDateInstance(DateFormat.MEDIUM).format(timestamp)) - append(TEXT_SEP) - append(DateFormat.getTimeInstance(DateFormat.SHORT).format(timestamp)) - } - - private fun imagePinDrawable(context: Context): LayerDrawable { - val drawable = ContextCompat.getDrawable(context, R.drawable.photo_pin) as LayerDrawable - - val bitmap = - ThumbnailsCacheManager.getBitmapFromDiskCache(ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId) - BitmapUtils.bitmapToCircularBitmapDrawable(resources, bitmap)?.let { - drawable.setDrawable(1, it) - } - - return drawable - } - - /** - * OnItemGestureListener for marker in MapView. - */ - private fun markerOnGestureListener(latitude: Double, longitude: Double) = - object : OnItemGestureListener { - override fun onItemSingleTapUp(index: Int, item: OverlayItem): Boolean { - val intent = Intent(Intent.ACTION_VIEW, "geo:0,0?q=$latitude,$longitude".toUri()) - DisplayUtils.startIntentIfAppAvailable(intent, activity, R.string.no_map_app_availble) - return true - } - - override fun onItemLongPress(index: Int, item: OverlayItem): Boolean = false - } - - @Parcelize - private data class ImageMetadata( - val fileSize: String? = null, - val date: String? = null, - val length: Int? = null, - val width: Int? = null, - val exposure: String? = null, - val aperture: String? = null, - val focalLen: String? = null, - val iso: String? = null, - val make: String? = null, - val model: String? = null, - val location: Pair? = null - ) : Parcelable - - companion object { - private const val ARG_FILE = "FILE" - private const val ARG_USER = "USER" - private const val ARG_METADATA = "METADATA" - private const val TEXT_SEP = " • " - private const val SCROLL_LIMIT = 80.0 - - @JvmStatic - fun newInstance(file: OCFile, user: User): ImageDetailFragment = ImageDetailFragment().apply { - arguments = Bundle().apply { - putParcelable(ARG_FILE, file) - putParcelable(ARG_USER, user) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt new file mode 100644 index 000000000000..2c45a2cf8d5f --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt @@ -0,0 +1,168 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2023 ZetaTom + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ + +package com.nextcloud.ui.fileInfo + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.TextView +import androidx.annotation.VisibleForTesting +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.account.User +import com.nextcloud.client.di.Injectable +import com.nextcloud.ui.fileInfo.model.ImageMetadata +import com.nextcloud.utils.extensions.getParcelableArgument +import com.owncloud.android.R +import com.owncloud.android.databinding.FileInfoFragmentBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.MimeTypeUtil +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +class FileInfoFragment : + Fragment(), + Injectable { + private lateinit var binding: FileInfoFragmentBinding + private lateinit var file: OCFile + private lateinit var user: User + private var metadata: ImageMetadata? = null + private var imageDetailInfo: ImageDetailInfo? = null + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FileInfoFragmentBinding.inflate(layoutInflater, container, false) + + val arguments = arguments ?: throw IllegalStateException("arguments are mandatory") + file = arguments.getParcelableArgument(ARG_FILE, OCFile::class.java)!! + user = arguments.getParcelableArgument(ARG_USER, User::class.java)!! + + if (savedInstanceState != null) { + file = savedInstanceState.getParcelableArgument(ARG_FILE, OCFile::class.java)!! + user = savedInstanceState.getParcelableArgument(ARG_USER, User::class.java)!! + metadata = savedInstanceState.getParcelableArgument(ARG_METADATA, ImageMetadata::class.java)!! + } + + imageDetailInfo = ImageDetailInfo(this) + + + // TODO Bottom sheet or dropdown? + data class DropdownItem(val text: String, val iconRes: Int) + val items = listOf( + DropdownItem("Option 1", R.drawable.outline_camera_24), + DropdownItem("Option 2", R.drawable.outline_image_24), + DropdownItem("Option 3", R.drawable.ic_information_outline) + ) + + val adapter = object : ArrayAdapter(requireContext(), R.layout.item_dropdown_with_icon, items) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = super.getView(position, convertView, parent) as TextView + val item = getItem(position) + if (item != null) { + view.text = item.text + val drawable = ContextCompat.getDrawable(context, item.iconRes)?.mutate() + drawable?.let { + viewThemeUtils.platform.tintDrawable(requireContext(),it, ColorRole.ON_SURFACE) + } + view.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) + } + return view + } + } + binding.dropdownMenuAutoComplete.setAdapter(adapter) + + val defaultSelectedItem = items.firstOrNull() + if (defaultSelectedItem != null) { + binding.dropdownMenuAutoComplete.setText(defaultSelectedItem.text, false) + val drawable = ContextCompat.getDrawable(requireContext(), defaultSelectedItem.iconRes)?.mutate() + drawable?.let { + viewThemeUtils.platform.tintDrawable(requireContext(),it, ColorRole.ON_SURFACE) + } + binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + binding.dropdownMenuAutoComplete.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.standard_padding) + } + + binding.dropdownMenuAutoComplete.setOnItemClickListener { _, _, position, _ -> + val selected = items[position] + binding.dropdownMenuAutoComplete.setText(selected.text, false) + val drawable = ContextCompat.getDrawable(requireContext(), selected.iconRes)?.mutate() + binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + binding.dropdownMenuAutoComplete.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.standard_padding) + } + + // + + binding.fileDetailsIcon.setImageDrawable( + viewThemeUtils.platform.tintDrawable( + requireContext(), + R.drawable.outline_image_24, + ColorRole.ON_BACKGROUND + ) + ) + + binding.cameraInformationIcon.setImageDrawable( + viewThemeUtils.platform.tintDrawable( + requireContext(), + R.drawable.outline_camera_24, + ColorRole.ON_BACKGROUND + ) + ) + + return binding.root + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.run { + putParcelable(ARG_FILE, file) + putParcelable(ARG_USER, user) + putParcelable(ARG_METADATA, metadata) + } + } + + override fun onStart() { + super.onStart() + + if (MimeTypeUtil.isImage(file)) { + metadata = imageDetailInfo?.gatherMetadata(file) + } + + setupFragment() + } + + @VisibleForTesting + fun hideMap() { + binding.imageLocationMap.visibility = View.GONE + } + + private fun setupFragment() { + if (MimeTypeUtil.isImage(file)) { + metadata?.let { imageDetailInfo?.init(file, it, binding) } + } + } + + companion object { + private const val ARG_FILE = "FILE" + private const val ARG_USER = "USER" + private const val ARG_METADATA = "METADATA" + + @JvmStatic + fun newInstance(file: OCFile, user: User): FileInfoFragment = FileInfoFragment().apply { + arguments = Bundle().apply { + putParcelable(ARG_FILE, file) + putParcelable(ARG_USER, user) + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt new file mode 100644 index 000000000000..861223f3eda6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt @@ -0,0 +1,294 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileInfo + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.graphics.drawable.LayerDrawable +import android.view.View +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.exifinterface.media.ExifInterface +import androidx.lifecycle.lifecycleScope +import com.nextcloud.client.NominatimClient +import com.nextcloud.ui.fileInfo.model.ImageMetadata +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.databinding.FileInfoFragmentBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.utils.BitmapUtils +import com.owncloud.android.utils.DisplayUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController +import org.osmdroid.views.overlay.ItemizedIconOverlay +import org.osmdroid.views.overlay.OverlayItem +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Locale +import kotlin.math.pow +import kotlin.math.roundToInt +import kotlin.ranges.contains +import kotlin.text.contains +import kotlin.text.split + +class ImageDetailInfo(private val fragment: FileInfoFragment) { + companion object { + private const val TEXT_SEP = " • " + private const val SCROLL_LIMIT = 80.0 + } + + fun init(file: OCFile, metadata: ImageMetadata, binding: FileInfoFragmentBinding) { + binding.fileInformationTime.text = metadata.date + + // detailed file information + val fileInformation = mutableListOf() + if ((metadata.length ?: 0) > 0 && (metadata.width ?: 0) > 0) { + try { + @Suppress("MagicNumber") + val pxlCount = when (val res = metadata.length!! * metadata.width!!.toLong()) { + in 0..999999 -> "%.2f".format(res / 1000000f) + in 1000000..9999999 -> "%.1f".format(res / 1000000f) + else -> (res / 1000000).toString() + } + + fileInformation.add(String.format(fragment.getString(R.string.image_preview_unit_megapixel), pxlCount)) + fileInformation.add("${metadata.width} × ${metadata.length}") + } catch (_: NumberFormatException) { + } + } + metadata.fileSize?.let { fileInformation.add(it) } + + if (fileInformation.isNotEmpty()) { + binding.fileInformationDetails.text = fileInformation.joinToString(separator = TEXT_SEP) + binding.fileInformation.visibility = View.VISIBLE + } + + setImageTakenConditions(metadata, binding) + + // initialise map and address views + metadata.location?.let { location -> + initMap(binding, file,location.first,location.second) + binding.imageLocation.visibility = View.VISIBLE + + // launch reverse geocoding request + fragment.lifecycleScope.launch(Dispatchers.IO) { + val nominatimClient = NominatimClient( + fragment.getString(R.string.osm_geocoder_url), + fragment.getString(R.string.osm_geocoder_contact) + ) + val geocodingResult = nominatimClient.reverseGeocode(location.first, location.second) + if (geocodingResult != null) { + withContext(Dispatchers.Main) { + binding.imageLocationText.visibility = View.VISIBLE + binding.imageLocationText.text = geocodingResult.displayName + } + } + } + } + } + + private fun setImageTakenConditions(metadata: ImageMetadata, binding: FileInfoFragmentBinding) { + // camera make and model + val makeModel = if (metadata.make?.let { metadata.model?.contains(it) } == false) { + "${metadata.make} ${metadata.model}" + } else { + metadata.model ?: metadata.make + } + + if (metadata.make == null || metadata.model?.contains(metadata.make) == true) { + binding.imgTCMakeModel.text = metadata.model + } else { + binding.imgTCMakeModel.text = String.format( + fragment.getString(R.string.make_model), + metadata.make, + metadata.model + ) + } + + // image taking conditions + val imageTakingConditions = mutableListOf() + metadata.aperture?.let { + imageTakingConditions.add(String.format(fragment.getString(R.string.image_preview_unit_fnumber), it)) + } + metadata.exposure?.let { + imageTakingConditions.add(String.format(fragment.getString(R.string.image_preview_unit_seconds), it)) + } + metadata.focalLen?.let { + imageTakingConditions.add(String.format(fragment.getString(R.string.image_preview_unit_millimetres), it)) + } + metadata.iso?.let { + imageTakingConditions.add(String.format(fragment.getString(R.string.image_preview_unit_iso), it)) + } + + if (imageTakingConditions.isNotEmpty() && makeModel != null) { + binding.imgTCMakeModel.text = makeModel + binding.imgTCConditions.text = imageTakingConditions.joinToString(separator = TEXT_SEP) + binding.imgTC.visibility = View.VISIBLE + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun initMap(binding: FileInfoFragmentBinding, file: OCFile, latitude: Double, longitude: Double, zoom: Double = 13.0) { + // required for OpenStreetMap + Configuration.getInstance().userAgentValue = MainApp.getUserAgent() + + val location = GeoPoint(latitude, longitude) + + binding.imageLocationMap.apply { + setTileSource(TileSourceFactory.MAPNIK) + + // set expected boundaries + setScrollableAreaLimitLatitude(SCROLL_LIMIT, -SCROLL_LIMIT, 0) + isVerticalMapRepetitionEnabled = false + minZoomLevel = 2.0 + maxZoomLevel = NominatimClient.Companion.ZoomLevel.MAX.int.toDouble() + + // initial location + controller.setCenter(location) + controller.setZoom(zoom) + + // scale labels to be legible + isTilesScaledToDpi = true + setZoomRounding(true) + + // hide zoom buttons + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + + // enable multi-touch zoom + setMultiTouchControls(true) + setOnTouchListener { v, _ -> + v.parent.requestDisallowInterceptTouchEvent(true) + false + } + + val markerOverlay = ItemizedIconOverlay( + mutableListOf(OverlayItem(null, null, location)), + imagePinDrawable(context, file), + markerOnGestureListener(latitude, longitude), + context + ) + + overlays.add(markerOverlay) + + onResume() + } + + // add copyright notice + binding.imageLocationMapCopyright.text = binding.imageLocationMap.tileProvider.tileSource.copyrightNotice + } + + @SuppressLint("SimpleDateFormat") + fun gatherMetadata(file: OCFile): ImageMetadata { + val fileSize = DisplayUtils.bytesToHumanReadable(file.fileLength) + var timestamp = java.lang.Long.max(file.modificationTimestamp, file.creationTimestamp) + return if (file.isDown) { + val exif = ExifInterface(file.storagePath) + var length = exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)?.toInt() + var width = exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)?.toInt() + var exposure = exif.getAttribute(ExifInterface.TAG_SHUTTER_SPEED_VALUE) + + // get timestamp from date string + exif.getAttribute(ExifInterface.TAG_DATETIME)?.let { + timestamp = SimpleDateFormat("y:M:d H:m:s", Locale.ROOT).parse(it)?.time ?: timestamp + } + + // format exposure string + if (exposure == null) { + exif.getAttribute(ExifInterface.TAG_EXPOSURE_TIME)?.let { + exposure = "1/" + (1 / it.toDouble()).toInt() + } + } else if ("/" in exposure) { + try { + exposure.split("/").also { + exposure = "1/" + 2f.pow(it[0].toFloat() / it[1].toFloat()).roundToInt() + } + } catch (_: NumberFormatException) { + } + } + + // determine size if not contained in exif data + if ((width ?: 0) <= 0 || (length ?: 0) <= 0) { + val res = BitmapUtils.getImageResolution(file.storagePath) + width = res[0] + length = res[1] + } + + ImageMetadata( + fileSize = fileSize, + length = length, + width = width, + exposure = exposure, + date = formatDate(timestamp), + location = exif.latLong?.let { Pair(it[0], it[1]) }, + aperture = exif.getAttribute(ExifInterface.TAG_F_NUMBER), + focalLen = exif.getAttribute(ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM), + make = exif.getAttribute(ExifInterface.TAG_MAKE), + model = exif.getAttribute(ExifInterface.TAG_MODEL), + iso = exif.getAttribute(ExifInterface.TAG_ISO_SPEED) ?: exif.getAttribute( + ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY + ) + ) + } else { + // get metadata from server + val location = if (file.geoLocation == null) { + null + } else { + Pair(file.geoLocation!!.latitude, file.geoLocation!!.longitude) + } + ImageMetadata( + fileSize = fileSize, + date = formatDate(timestamp), + location = location, + width = file.imageDimension?.width?.toInt(), + length = file.imageDimension?.height?.toInt() + ) + } + } + + @SuppressLint("SimpleDateFormat") + private fun formatDate(timestamp: Long): String = buildString { + append(SimpleDateFormat("EEEE").format(timestamp)) + append(TEXT_SEP) + append(DateFormat.getDateInstance(DateFormat.MEDIUM).format(timestamp)) + append(TEXT_SEP) + append(DateFormat.getTimeInstance(DateFormat.SHORT).format(timestamp)) + } + + private fun imagePinDrawable(context: Context, file: OCFile): LayerDrawable { + val drawable = ContextCompat.getDrawable(context, R.drawable.photo_pin) as LayerDrawable + + val bitmap = + ThumbnailsCacheManager.getBitmapFromDiskCache(ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId) + BitmapUtils.bitmapToCircularBitmapDrawable(fragment.resources, bitmap)?.let { + drawable.setDrawable(1, it) + } + + return drawable + } + + /** + * OnItemGestureListener for marker in MapView. + */ + private fun markerOnGestureListener(latitude: Double, longitude: Double) = + object : ItemizedIconOverlay.OnItemGestureListener { + override fun onItemSingleTapUp(index: Int, item: OverlayItem): Boolean { + val intent = Intent(Intent.ACTION_VIEW, "geo:0,0?q=$latitude,$longitude".toUri()) + DisplayUtils.startIntentIfAppAvailable(intent, fragment.activity, R.string.no_map_app_availble) + return true + } + + override fun onItemLongPress(index: Int, item: OverlayItem): Boolean = false + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/model/ImageMetadata.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/model/ImageMetadata.kt new file mode 100644 index 000000000000..ad57a9141f40 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/model/ImageMetadata.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileInfo.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ImageMetadata( + val fileSize: String? = null, + val date: String? = null, + val length: Int? = null, + val width: Int? = null, + val exposure: String? = null, + val aperture: String? = null, + val focalLen: String? = null, + val iso: String? = null, + val make: String? = null, + val model: String? = null, + val location: Pair? = null +) : Parcelable diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt index 9f0f271e494c..fa66d28e7f81 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt @@ -1,88 +1,55 @@ /* * Nextcloud - Android Client * + * SPDX-FileCopyrightText: 2026 Alper Ozturk * SPDX-FileCopyrightText: 2018 Andy Scherzinger * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only */ -package com.owncloud.android.ui.adapter; - -import com.nextcloud.client.account.User; -import com.nextcloud.ui.ImageDetailFragment; -import com.owncloud.android.datamodel.OCFile; -import com.owncloud.android.ui.fragment.FileDetailActivitiesFragment; -import com.owncloud.android.ui.fragment.FileDetailSharingFragment; -import com.owncloud.android.utils.MimeTypeUtil; - -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.viewpager2.adapter.FragmentStateAdapter; - -/** - * File details pager adapter. - */ -public class FileDetailTabAdapter extends FragmentStateAdapter { - private final OCFile file; - private final User user; - private final boolean showSharingTab; - - private FileDetailSharingFragment fileDetailSharingFragment; - private FileDetailActivitiesFragment fileDetailActivitiesFragment; - private ImageDetailFragment imageDetailFragment; - - public FileDetailTabAdapter(FragmentActivity fragmentActivity, - OCFile file, - User user, - boolean showSharingTab) { - super(fragmentActivity); - this.file = file; - this.user = user; - this.showSharingTab = showSharingTab; - } - - public FileDetailSharingFragment getFileDetailSharingFragment() { - return fileDetailSharingFragment; - } - - public FileDetailActivitiesFragment getFileDetailActivitiesFragment() { - return fileDetailActivitiesFragment; - } - - public ImageDetailFragment getImageDetailFragment() { - return imageDetailFragment; - } - - @NonNull - @Override - public Fragment createFragment(int position) { - return switch (position) { - case 1 -> { - fileDetailSharingFragment = FileDetailSharingFragment.newInstance(file, user); - yield fileDetailSharingFragment; +package com.owncloud.android.ui.adapter + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.nextcloud.client.account.User +import com.nextcloud.ui.fileInfo.FileInfoFragment +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.fragment.FileDetailActivitiesFragment +import com.owncloud.android.ui.fragment.FileDetailSharingFragment + +class FileDetailTabAdapter( + fragmentActivity: FragmentActivity, + private val file: OCFile, + private val user: User, + private val showSharingTab: Boolean +) : FragmentStateAdapter(fragmentActivity) { + var fileDetailSharingFragment: FileDetailSharingFragment? = null + private set + var fileDetailActivitiesFragment: FileDetailActivitiesFragment? = null + private set + + override fun createFragment(position: Int): Fragment { + return when (position) { + 1 -> { + fileDetailSharingFragment = FileDetailSharingFragment.newInstance(file, user) + fileDetailSharingFragment } - case 2 -> { - imageDetailFragment = ImageDetailFragment.newInstance(file, user); - yield imageDetailFragment; + + 2 -> { + FileInfoFragment.newInstance(file, user) } - default -> { - fileDetailActivitiesFragment = FileDetailActivitiesFragment.newInstance(file, user); - yield fileDetailActivitiesFragment; + + else -> { + fileDetailActivitiesFragment = FileDetailActivitiesFragment.newInstance(file, user) + fileDetailActivitiesFragment } - }; + }!! } - @Override - public int getItemCount() { - if (showSharingTab) { - if (MimeTypeUtil.isImage(file)) { - return 3; - } - return 2; + override fun getItemCount(): Int { + return if (showSharingTab) { + 3 } else { - if (MimeTypeUtil.isImage(file)) { - return 2; - } - return 1; + 2 } } } 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 dfb1f82b3471..0f11b68b83f3 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 @@ -319,8 +319,7 @@ private void setupViewPager() { binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.share_dialog_title).setIcon(R.drawable.selector_tab_share)); } - // TODO - Change detail tab icon? - binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.filedetails_details).setIcon(R.drawable.selector_media)); + binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.filedetails_details).setIcon(R.drawable.selector_file_info)); viewThemeUtils.material.themeTabLayout(binding.tabLayout); @@ -387,7 +386,6 @@ public void onTabReselected(TabLayout.Tab tab) { @Override public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); - FileExtensionsKt.logFileSize(getFile(), TAG); outState.putParcelable(ARG_FILE, getFile()); outState.putParcelable(ARG_USER, user); } diff --git a/app/src/main/res/drawable/ic_dashboard_filled.xml b/app/src/main/res/drawable/ic_dashboard_filled.xml new file mode 100644 index 000000000000..2d5b26893695 --- /dev/null +++ b/app/src/main/res/drawable/ic_dashboard_filled.xml @@ -0,0 +1,24 @@ + + + + diff --git a/app/src/main/res/drawable/ic_dashboard_outlined.xml b/app/src/main/res/drawable/ic_dashboard_outlined.xml new file mode 100644 index 000000000000..49ba8ca94c99 --- /dev/null +++ b/app/src/main/res/drawable/ic_dashboard_outlined.xml @@ -0,0 +1,24 @@ + + + + diff --git a/app/src/main/res/drawable/selector_file_info.xml b/app/src/main/res/drawable/selector_file_info.xml new file mode 100644 index 000000000000..71684759b904 --- /dev/null +++ b/app/src/main/res/drawable/selector_file_info.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/layout/preview_image_details_fragment.xml b/app/src/main/res/layout/file_info_fragment.xml similarity index 100% rename from app/src/main/res/layout/preview_image_details_fragment.xml rename to app/src/main/res/layout/file_info_fragment.xml From 7f9556e7e8ec4e32662fbbed36657e549d212189 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 09:27:36 +0200 Subject: [PATCH 04/11] simplify, split gov logic Signed-off-by: alperozturk96 --- .../nextcloud/ui/fileInfo/FileInfoFragment.kt | 121 +------- .../ui/fileInfo/GovernanceDetailInfo.kt | 73 +++++ .../nextcloud/ui/fileInfo/ImageDetailInfo.kt | 291 +++++++++--------- .../ui/fileInfo/model/SensitivityLabel.kt | 10 + .../ui/adapter/FileDetailTabAdapter.kt | 39 +-- 5 files changed, 252 insertions(+), 282 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt create mode 100644 app/src/main/java/com/nextcloud/ui/fileInfo/model/SensitivityLabel.kt diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt index 2c45a2cf8d5f..7a04aa727f98 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt @@ -1,9 +1,8 @@ /* * Nextcloud - Android Client * - * SPDX-FileCopyrightText: 2023 ZetaTom - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.ui.fileInfo @@ -12,17 +11,11 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.TextView import androidx.annotation.VisibleForTesting -import androidx.core.content.ContextCompat +import androidx.core.os.BundleCompat import androidx.fragment.app.Fragment -import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.account.User import com.nextcloud.client.di.Injectable -import com.nextcloud.ui.fileInfo.model.ImageMetadata -import com.nextcloud.utils.extensions.getParcelableArgument -import com.owncloud.android.R import com.owncloud.android.databinding.FileInfoFragmentBinding import com.owncloud.android.datamodel.OCFile import com.owncloud.android.utils.MimeTypeUtil @@ -33,10 +26,14 @@ class FileInfoFragment : Fragment(), Injectable { private lateinit var binding: FileInfoFragmentBinding - private lateinit var file: OCFile - private lateinit var user: User - private var metadata: ImageMetadata? = null - private var imageDetailInfo: ImageDetailInfo? = null + + private val file: OCFile? by lazy { + arguments?.let { BundleCompat.getParcelable(it, ARG_FILE, OCFile::class.java) } + } + + private val user: User? by lazy { + arguments?.let { BundleCompat.getParcelable(it, ARG_USER, User::class.java) } + } @Inject lateinit var viewThemeUtils: ViewThemeUtils @@ -44,80 +41,13 @@ class FileInfoFragment : override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FileInfoFragmentBinding.inflate(layoutInflater, container, false) - val arguments = arguments ?: throw IllegalStateException("arguments are mandatory") - file = arguments.getParcelableArgument(ARG_FILE, OCFile::class.java)!! - user = arguments.getParcelableArgument(ARG_USER, User::class.java)!! - - if (savedInstanceState != null) { - file = savedInstanceState.getParcelableArgument(ARG_FILE, OCFile::class.java)!! - user = savedInstanceState.getParcelableArgument(ARG_USER, User::class.java)!! - metadata = savedInstanceState.getParcelableArgument(ARG_METADATA, ImageMetadata::class.java)!! - } - - imageDetailInfo = ImageDetailInfo(this) - - - // TODO Bottom sheet or dropdown? - data class DropdownItem(val text: String, val iconRes: Int) - val items = listOf( - DropdownItem("Option 1", R.drawable.outline_camera_24), - DropdownItem("Option 2", R.drawable.outline_image_24), - DropdownItem("Option 3", R.drawable.ic_information_outline) - ) - - val adapter = object : ArrayAdapter(requireContext(), R.layout.item_dropdown_with_icon, items) { - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val view = super.getView(position, convertView, parent) as TextView - val item = getItem(position) - if (item != null) { - view.text = item.text - val drawable = ContextCompat.getDrawable(context, item.iconRes)?.mutate() - drawable?.let { - viewThemeUtils.platform.tintDrawable(requireContext(),it, ColorRole.ON_SURFACE) - } - view.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) - } - return view - } - } - binding.dropdownMenuAutoComplete.setAdapter(adapter) - - val defaultSelectedItem = items.firstOrNull() - if (defaultSelectedItem != null) { - binding.dropdownMenuAutoComplete.setText(defaultSelectedItem.text, false) - val drawable = ContextCompat.getDrawable(requireContext(), defaultSelectedItem.iconRes)?.mutate() - drawable?.let { - viewThemeUtils.platform.tintDrawable(requireContext(),it, ColorRole.ON_SURFACE) - } - binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) - binding.dropdownMenuAutoComplete.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.standard_padding) - } - - binding.dropdownMenuAutoComplete.setOnItemClickListener { _, _, position, _ -> - val selected = items[position] - binding.dropdownMenuAutoComplete.setText(selected.text, false) - val drawable = ContextCompat.getDrawable(requireContext(), selected.iconRes)?.mutate() - binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) - binding.dropdownMenuAutoComplete.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.standard_padding) + val imageDetailInfo = ImageDetailInfo(this, viewThemeUtils) + if (MimeTypeUtil.isImage(file)) { + file?.let { imageDetailInfo.init(it, binding) } } - // - - binding.fileDetailsIcon.setImageDrawable( - viewThemeUtils.platform.tintDrawable( - requireContext(), - R.drawable.outline_image_24, - ColorRole.ON_BACKGROUND - ) - ) - - binding.cameraInformationIcon.setImageDrawable( - viewThemeUtils.platform.tintDrawable( - requireContext(), - R.drawable.outline_camera_24, - ColorRole.ON_BACKGROUND - ) - ) + val governanceDetailInfo = GovernanceDetailInfo(binding, viewThemeUtils, this) + governanceDetailInfo.init() return binding.root } @@ -127,18 +57,7 @@ class FileInfoFragment : outState.run { putParcelable(ARG_FILE, file) putParcelable(ARG_USER, user) - putParcelable(ARG_METADATA, metadata) - } - } - - override fun onStart() { - super.onStart() - - if (MimeTypeUtil.isImage(file)) { - metadata = imageDetailInfo?.gatherMetadata(file) } - - setupFragment() } @VisibleForTesting @@ -146,18 +65,10 @@ class FileInfoFragment : binding.imageLocationMap.visibility = View.GONE } - private fun setupFragment() { - if (MimeTypeUtil.isImage(file)) { - metadata?.let { imageDetailInfo?.init(file, it, binding) } - } - } - companion object { private const val ARG_FILE = "FILE" private const val ARG_USER = "USER" - private const val ARG_METADATA = "METADATA" - @JvmStatic fun newInstance(file: OCFile, user: User): FileInfoFragment = FileInfoFragment().apply { arguments = Bundle().apply { putParcelable(ARG_FILE, file) diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt new file mode 100644 index 000000000000..a8dd7fb43b23 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt @@ -0,0 +1,73 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileInfo + +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.ui.fileInfo.model.SensitivityLabel +import com.owncloud.android.R +import com.owncloud.android.databinding.FileInfoFragmentBinding +import com.owncloud.android.utils.theme.ViewThemeUtils + +class GovernanceDetailInfo( + private val binding: FileInfoFragmentBinding, + private val viewThemeUtils: ViewThemeUtils, + private val fragment: FileInfoFragment +) { + + fun init() { + val items = listOf( + SensitivityLabel("Option 1", R.drawable.outline_camera_24), + SensitivityLabel("Option 2", R.drawable.outline_image_24), + SensitivityLabel("Option 3", R.drawable.ic_information_outline) + ) + + val adapter = object : + ArrayAdapter(fragment.requireContext(), R.layout.item_dropdown_with_icon, items) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = super.getView(position, convertView, parent) as TextView + val item = getItem(position) + if (item != null) { + view.text = item.text + val drawable = ContextCompat.getDrawable(context, item.iconRes)?.mutate() + drawable?.let { + viewThemeUtils.platform.tintDrawable(fragment.requireContext(), it, ColorRole.ON_SURFACE) + } + view.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) + } + return view + } + } + binding.dropdownMenuAutoComplete.setAdapter(adapter) + + val defaultSelectedItem = items.firstOrNull() + if (defaultSelectedItem != null) { + binding.dropdownMenuAutoComplete.setText(defaultSelectedItem.text, false) + val drawable = ContextCompat.getDrawable(fragment.requireContext(), defaultSelectedItem.iconRes)?.mutate() + drawable?.let { + viewThemeUtils.platform.tintDrawable(fragment.requireContext(), it, ColorRole.ON_SURFACE) + } + binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + binding.dropdownMenuAutoComplete.compoundDrawablePadding = + fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) + } + + binding.dropdownMenuAutoComplete.setOnItemClickListener { _, _, position, _ -> + val selected = items[position] + binding.dropdownMenuAutoComplete.setText(selected.text, false) + val drawable = ContextCompat.getDrawable(fragment.requireContext(), selected.iconRes)?.mutate() + binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + binding.dropdownMenuAutoComplete.compoundDrawablePadding = + fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt index 861223f3eda6..2fbb72c2cf28 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt @@ -16,6 +16,7 @@ import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.exifinterface.media.ExifInterface import androidx.lifecycle.lifecycleScope +import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.NominatimClient import com.nextcloud.ui.fileInfo.model.ImageMetadata import com.owncloud.android.MainApp @@ -25,6 +26,7 @@ import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.ThumbnailsCacheManager import com.owncloud.android.utils.BitmapUtils import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -39,60 +41,71 @@ import java.text.SimpleDateFormat import java.util.Locale import kotlin.math.pow import kotlin.math.roundToInt -import kotlin.ranges.contains -import kotlin.text.contains -import kotlin.text.split -class ImageDetailInfo(private val fragment: FileInfoFragment) { +class ImageDetailInfo(private val fragment: FileInfoFragment, private val viewThemeUtils: ViewThemeUtils) { + companion object { private const val TEXT_SEP = " • " private const val SCROLL_LIMIT = 80.0 } - fun init(file: OCFile, metadata: ImageMetadata, binding: FileInfoFragmentBinding) { + fun init(file: OCFile, binding: FileInfoFragmentBinding) { + val metadata = gatherMetadata(file) binding.fileInformationTime.text = metadata.date + binding.fileDetailsIcon.setImageDrawable( + viewThemeUtils.platform.tintDrawable( + fragment.requireContext(), + R.drawable.outline_image_24, + ColorRole.ON_BACKGROUND + ) + ) - // detailed file information - val fileInformation = mutableListOf() - if ((metadata.length ?: 0) > 0 && (metadata.width ?: 0) > 0) { - try { - @Suppress("MagicNumber") - val pxlCount = when (val res = metadata.length!! * metadata.width!!.toLong()) { - in 0..999999 -> "%.2f".format(res / 1000000f) - in 1000000..9999999 -> "%.1f".format(res / 1000000f) - else -> (res / 1000000).toString() + binding.cameraInformationIcon.setImageDrawable( + viewThemeUtils.platform.tintDrawable( + fragment.requireContext(), + R.drawable.outline_camera_24, + ColorRole.ON_BACKGROUND + ) + ) + + val fileInformation = buildList { + val length = metadata.length ?: 0 + val width = metadata.width ?: 0 + if (length > 0 && width > 0) { + runCatching { + @Suppress("MagicNumber") + val pxlCount = when (val res = length * width.toLong()) { + in 0..999_999 -> "%.2f".format(res / 1_000_000f) + in 1_000_000..9_999_999 -> "%.1f".format(res / 1_000_000f) + else -> (res / 1_000_000).toString() + } + add(fragment.getString(R.string.image_preview_unit_megapixel).format(pxlCount)) + add("$width × $length") } - - fileInformation.add(String.format(fragment.getString(R.string.image_preview_unit_megapixel), pxlCount)) - fileInformation.add("${metadata.width} × ${metadata.length}") - } catch (_: NumberFormatException) { } + metadata.fileSize?.let { add(it) } } - metadata.fileSize?.let { fileInformation.add(it) } if (fileInformation.isNotEmpty()) { - binding.fileInformationDetails.text = fileInformation.joinToString(separator = TEXT_SEP) + binding.fileInformationDetails.text = fileInformation.joinToString(TEXT_SEP) binding.fileInformation.visibility = View.VISIBLE } setImageTakenConditions(metadata, binding) - // initialise map and address views - metadata.location?.let { location -> - initMap(binding, file,location.first,location.second) + metadata.location?.let { (lat, lon) -> + initMap(binding, file, lat, lon) binding.imageLocation.visibility = View.VISIBLE - // launch reverse geocoding request fragment.lifecycleScope.launch(Dispatchers.IO) { val nominatimClient = NominatimClient( fragment.getString(R.string.osm_geocoder_url), fragment.getString(R.string.osm_geocoder_contact) ) - val geocodingResult = nominatimClient.reverseGeocode(location.first, location.second) - if (geocodingResult != null) { + nominatimClient.reverseGeocode(lat, lon)?.let { result -> withContext(Dispatchers.Main) { + binding.imageLocationText.text = result.displayName binding.imageLocationText.visibility = View.VISIBLE - binding.imageLocationText.text = geocodingResult.displayName } } } @@ -100,187 +113,157 @@ class ImageDetailInfo(private val fragment: FileInfoFragment) { } private fun setImageTakenConditions(metadata: ImageMetadata, binding: FileInfoFragmentBinding) { - // camera make and model - val makeModel = if (metadata.make?.let { metadata.model?.contains(it) } == false) { - "${metadata.make} ${metadata.model}" - } else { - metadata.model ?: metadata.make + val makeModel = when { + metadata.make == null -> metadata.model + metadata.model?.contains(metadata.make) == true -> metadata.model + else -> fragment.getString(R.string.make_model).format(metadata.make, metadata.model) } - if (metadata.make == null || metadata.model?.contains(metadata.make) == true) { - binding.imgTCMakeModel.text = metadata.model - } else { - binding.imgTCMakeModel.text = String.format( - fragment.getString(R.string.make_model), - metadata.make, - metadata.model - ) - } - - // image taking conditions - val imageTakingConditions = mutableListOf() - metadata.aperture?.let { - imageTakingConditions.add(String.format(fragment.getString(R.string.image_preview_unit_fnumber), it)) - } - metadata.exposure?.let { - imageTakingConditions.add(String.format(fragment.getString(R.string.image_preview_unit_seconds), it)) - } - metadata.focalLen?.let { - imageTakingConditions.add(String.format(fragment.getString(R.string.image_preview_unit_millimetres), it)) - } - metadata.iso?.let { - imageTakingConditions.add(String.format(fragment.getString(R.string.image_preview_unit_iso), it)) + val imageTakingConditions = buildList { + metadata.aperture?.let { add(fragment.getString(R.string.image_preview_unit_fnumber).format(it)) } + metadata.exposure?.let { add(fragment.getString(R.string.image_preview_unit_seconds).format(it)) } + metadata.focalLen?.let { add(fragment.getString(R.string.image_preview_unit_millimetres).format(it)) } + metadata.iso?.let { add(fragment.getString(R.string.image_preview_unit_iso).format(it)) } } if (imageTakingConditions.isNotEmpty() && makeModel != null) { binding.imgTCMakeModel.text = makeModel - binding.imgTCConditions.text = imageTakingConditions.joinToString(separator = TEXT_SEP) + binding.imgTCConditions.text = imageTakingConditions.joinToString(TEXT_SEP) binding.imgTC.visibility = View.VISIBLE } } @SuppressLint("ClickableViewAccessibility") - private fun initMap(binding: FileInfoFragmentBinding, file: OCFile, latitude: Double, longitude: Double, zoom: Double = 13.0) { - // required for OpenStreetMap + private fun initMap( + binding: FileInfoFragmentBinding, + file: OCFile, + latitude: Double, + longitude: Double, + zoom: Double = 13.0 + ) { Configuration.getInstance().userAgentValue = MainApp.getUserAgent() val location = GeoPoint(latitude, longitude) binding.imageLocationMap.apply { setTileSource(TileSourceFactory.MAPNIK) - - // set expected boundaries setScrollableAreaLimitLatitude(SCROLL_LIMIT, -SCROLL_LIMIT, 0) isVerticalMapRepetitionEnabled = false minZoomLevel = 2.0 maxZoomLevel = NominatimClient.Companion.ZoomLevel.MAX.int.toDouble() - - // initial location controller.setCenter(location) controller.setZoom(zoom) - - // scale labels to be legible isTilesScaledToDpi = true setZoomRounding(true) - - // hide zoom buttons zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) - - // enable multi-touch zoom setMultiTouchControls(true) setOnTouchListener { v, _ -> v.parent.requestDisallowInterceptTouchEvent(true) false } - - val markerOverlay = ItemizedIconOverlay( - mutableListOf(OverlayItem(null, null, location)), - imagePinDrawable(context, file), - markerOnGestureListener(latitude, longitude), - context + overlays.add( + ItemizedIconOverlay( + mutableListOf(OverlayItem(null, null, location)), + imagePinDrawable(context, file), + markerOnGestureListener(latitude, longitude), + context + ) ) - - overlays.add(markerOverlay) - onResume() } - // add copyright notice - binding.imageLocationMapCopyright.text = binding.imageLocationMap.tileProvider.tileSource.copyrightNotice + binding.imageLocationMapCopyright.text = + binding.imageLocationMap.tileProvider.tileSource.copyrightNotice } - @SuppressLint("SimpleDateFormat") fun gatherMetadata(file: OCFile): ImageMetadata { val fileSize = DisplayUtils.bytesToHumanReadable(file.fileLength) - var timestamp = java.lang.Long.max(file.modificationTimestamp, file.creationTimestamp) + val timestamp = maxOf(file.modificationTimestamp, file.creationTimestamp) return if (file.isDown) { - val exif = ExifInterface(file.storagePath) - var length = exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)?.toInt() - var width = exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)?.toInt() - var exposure = exif.getAttribute(ExifInterface.TAG_SHUTTER_SPEED_VALUE) - - // get timestamp from date string - exif.getAttribute(ExifInterface.TAG_DATETIME)?.let { - timestamp = SimpleDateFormat("y:M:d H:m:s", Locale.ROOT).parse(it)?.time ?: timestamp - } + gatherLocalMetadata(file, fileSize, timestamp) + } else { + gatherRemoteMetadata(file, fileSize, timestamp) + } + } - // format exposure string - if (exposure == null) { - exif.getAttribute(ExifInterface.TAG_EXPOSURE_TIME)?.let { - exposure = "1/" + (1 / it.toDouble()).toInt() - } - } else if ("/" in exposure) { - try { - exposure.split("/").also { - exposure = "1/" + 2f.pow(it[0].toFloat() / it[1].toFloat()).roundToInt() - } - } catch (_: NumberFormatException) { - } - } + private fun gatherLocalMetadata(file: OCFile, fileSize: String, fallbackTimestamp: Long): ImageMetadata { + val exif = ExifInterface(file.storagePath) + val timestamp = parseTimestamp(exif, fallbackTimestamp) + val (width, length) = parseImageDimensions(exif, file.storagePath) + + return ImageMetadata( + fileSize = fileSize, + date = formatDate(timestamp), + length = length, + width = width, + exposure = parseExposure(exif), + location = exif.latLong?.let { Pair(it[0], it[1]) }, + aperture = exif.getAttribute(ExifInterface.TAG_F_NUMBER), + focalLen = exif.getAttribute(ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM), + make = exif.getAttribute(ExifInterface.TAG_MAKE), + model = exif.getAttribute(ExifInterface.TAG_MODEL), + iso = exif.getAttribute(ExifInterface.TAG_ISO_SPEED) + ?: exif.getAttribute(ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY) + ) + } - // determine size if not contained in exif data - if ((width ?: 0) <= 0 || (length ?: 0) <= 0) { - val res = BitmapUtils.getImageResolution(file.storagePath) - width = res[0] - length = res[1] - } + private fun gatherRemoteMetadata(file: OCFile, fileSize: String, timestamp: Long) = ImageMetadata( + fileSize = fileSize, + date = formatDate(timestamp), + location = file.geoLocation?.let { Pair(it.latitude, it.longitude) }, + width = file.imageDimension?.width?.toInt(), + length = file.imageDimension?.height?.toInt() + ) - ImageMetadata( - fileSize = fileSize, - length = length, - width = width, - exposure = exposure, - date = formatDate(timestamp), - location = exif.latLong?.let { Pair(it[0], it[1]) }, - aperture = exif.getAttribute(ExifInterface.TAG_F_NUMBER), - focalLen = exif.getAttribute(ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM), - make = exif.getAttribute(ExifInterface.TAG_MAKE), - model = exif.getAttribute(ExifInterface.TAG_MODEL), - iso = exif.getAttribute(ExifInterface.TAG_ISO_SPEED) ?: exif.getAttribute( - ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY - ) - ) - } else { - // get metadata from server - val location = if (file.geoLocation == null) { - null - } else { - Pair(file.geoLocation!!.latitude, file.geoLocation!!.longitude) + private fun parseTimestamp(exif: ExifInterface, fallback: Long): Long = + exif.getAttribute(ExifInterface.TAG_DATETIME)?.let { + SimpleDateFormat("y:M:d H:m:s", Locale.ROOT).parse(it)?.time + } ?: fallback + + @Suppress("ReturnCount") + private fun parseExposure(exif: ExifInterface): String? { + val shutterSpeed = exif.getAttribute(ExifInterface.TAG_SHUTTER_SPEED_VALUE) + val exposureTime = exif.getAttribute(ExifInterface.TAG_EXPOSURE_TIME) + ?.let { "1/${(1 / it.toDouble()).toInt()}" } + + val raw = shutterSpeed ?: exposureTime ?: return null + + if ('/' !in raw) return raw + + return runCatching { + raw.split("/").let { parts -> + "1/${2f.pow(parts[0].toFloat() / parts[1].toFloat()).roundToInt()}" } - ImageMetadata( - fileSize = fileSize, - date = formatDate(timestamp), - location = location, - width = file.imageDimension?.width?.toInt(), - length = file.imageDimension?.height?.toInt() - ) - } + }.getOrDefault(raw) + } + + private fun parseImageDimensions(exif: ExifInterface, storagePath: String): Pair { + val width = exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)?.toInt() + val length = exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)?.toInt() + + if ((width ?: 0) > 0 && (length ?: 0) > 0) return Pair(width, length) + + return BitmapUtils.getImageResolution(storagePath).let { Pair(it[0], it[1]) } } - @SuppressLint("SimpleDateFormat") private fun formatDate(timestamp: Long): String = buildString { - append(SimpleDateFormat("EEEE").format(timestamp)) + append(SimpleDateFormat("EEEE", Locale.getDefault()).format(timestamp)) append(TEXT_SEP) append(DateFormat.getDateInstance(DateFormat.MEDIUM).format(timestamp)) append(TEXT_SEP) append(DateFormat.getTimeInstance(DateFormat.SHORT).format(timestamp)) } - private fun imagePinDrawable(context: Context, file: OCFile): LayerDrawable { - val drawable = ContextCompat.getDrawable(context, R.drawable.photo_pin) as LayerDrawable - - val bitmap = - ThumbnailsCacheManager.getBitmapFromDiskCache(ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId) - BitmapUtils.bitmapToCircularBitmapDrawable(fragment.resources, bitmap)?.let { - drawable.setDrawable(1, it) + private fun imagePinDrawable(context: Context, file: OCFile): LayerDrawable = + (ContextCompat.getDrawable(context, R.drawable.photo_pin) as LayerDrawable).apply { + val bitmap = ThumbnailsCacheManager.getBitmapFromDiskCache( + ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId + ) + BitmapUtils.bitmapToCircularBitmapDrawable(fragment.resources, bitmap)?.let { + setDrawable(1, it) + } } - return drawable - } - - /** - * OnItemGestureListener for marker in MapView. - */ private fun markerOnGestureListener(latitude: Double, longitude: Double) = object : ItemizedIconOverlay.OnItemGestureListener { override fun onItemSingleTapUp(index: Int, item: OverlayItem): Boolean { @@ -289,6 +272,6 @@ class ImageDetailInfo(private val fragment: FileInfoFragment) { return true } - override fun onItemLongPress(index: Int, item: OverlayItem): Boolean = false + override fun onItemLongPress(index: Int, item: OverlayItem) = false } } diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/model/SensitivityLabel.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/model/SensitivityLabel.kt new file mode 100644 index 000000000000..d9895ee6fe3a --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/model/SensitivityLabel.kt @@ -0,0 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileInfo.model + +data class SensitivityLabel(val text: String, val iconRes: Int) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt index fa66d28e7f81..9fbcba02eb8f 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt @@ -22,34 +22,27 @@ class FileDetailTabAdapter( private val user: User, private val showSharingTab: Boolean ) : FragmentStateAdapter(fragmentActivity) { + + private enum class Tab(val position: Int) { + Activities(0), + Sharing(1), + Details(2) + } + var fileDetailSharingFragment: FileDetailSharingFragment? = null private set var fileDetailActivitiesFragment: FileDetailActivitiesFragment? = null private set - override fun createFragment(position: Int): Fragment { - return when (position) { - 1 -> { - fileDetailSharingFragment = FileDetailSharingFragment.newInstance(file, user) - fileDetailSharingFragment - } - - 2 -> { - FileInfoFragment.newInstance(file, user) - } - - else -> { - fileDetailActivitiesFragment = FileDetailActivitiesFragment.newInstance(file, user) - fileDetailActivitiesFragment - } - }!! - } + override fun createFragment(position: Int): Fragment = when (position) { + Tab.Sharing.position -> FileDetailSharingFragment.newInstance(file, user) + .also { fileDetailSharingFragment = it } + + Tab.Details.position -> FileInfoFragment.newInstance(file, user) - override fun getItemCount(): Int { - return if (showSharingTab) { - 3 - } else { - 2 - } + else -> FileDetailActivitiesFragment.newInstance(file, user) + .also { fileDetailActivitiesFragment = it } } + + override fun getItemCount(): Int = if (showSharingTab) Tab.entries.size else Tab.entries.size - 1 } From e509f755a5d92c142c8badd14abc582104e5d749 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 09:28:27 +0200 Subject: [PATCH 05/11] simplify, split gov logic Signed-off-by: alperozturk96 --- app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt index 7a04aa727f98..3eddf0c3e0ac 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt @@ -41,8 +41,8 @@ class FileInfoFragment : override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FileInfoFragmentBinding.inflate(layoutInflater, container, false) - val imageDetailInfo = ImageDetailInfo(this, viewThemeUtils) if (MimeTypeUtil.isImage(file)) { + val imageDetailInfo = ImageDetailInfo(this, viewThemeUtils) file?.let { imageDetailInfo.init(it, binding) } } From 2d1ead50a381c3f24fe9cae0d0ced7f3a23c45bb Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 09:55:22 +0200 Subject: [PATCH 06/11] split ui, better margins Signed-off-by: alperozturk96 --- .../ui/fileInfo/GovernanceDetailInfo.kt | 16 +- .../main/res/layout/file_info_fragment.xml | 260 ++++++++++-------- app/src/main/res/values/dims.xml | 2 +- app/src/main/res/values/strings.xml | 3 + 4 files changed, 155 insertions(+), 126 deletions(-) diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt index a8dd7fb43b23..90c2a1e35be2 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt @@ -47,26 +47,26 @@ class GovernanceDetailInfo( return view } } - binding.dropdownMenuAutoComplete.setAdapter(adapter) + binding.sensitivityLabelAutoComplete.setAdapter(adapter) val defaultSelectedItem = items.firstOrNull() if (defaultSelectedItem != null) { - binding.dropdownMenuAutoComplete.setText(defaultSelectedItem.text, false) + binding.sensitivityLabelAutoComplete.setText(defaultSelectedItem.text, false) val drawable = ContextCompat.getDrawable(fragment.requireContext(), defaultSelectedItem.iconRes)?.mutate() drawable?.let { viewThemeUtils.platform.tintDrawable(fragment.requireContext(), it, ColorRole.ON_SURFACE) } - binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) - binding.dropdownMenuAutoComplete.compoundDrawablePadding = + binding.sensitivityLabelAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + binding.sensitivityLabelAutoComplete.compoundDrawablePadding = fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) } - binding.dropdownMenuAutoComplete.setOnItemClickListener { _, _, position, _ -> + binding.sensitivityLabelAutoComplete.setOnItemClickListener { _, _, position, _ -> val selected = items[position] - binding.dropdownMenuAutoComplete.setText(selected.text, false) + binding.sensitivityLabelAutoComplete.setText(selected.text, false) val drawable = ContextCompat.getDrawable(fragment.requireContext(), selected.iconRes)?.mutate() - binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) - binding.dropdownMenuAutoComplete.compoundDrawablePadding = + binding.sensitivityLabelAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + binding.sensitivityLabelAutoComplete.compoundDrawablePadding = fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) } } diff --git a/app/src/main/res/layout/file_info_fragment.xml b/app/src/main/res/layout/file_info_fragment.xml index d49741a3f741..d70d31676595 100644 --- a/app/src/main/res/layout/file_info_fragment.xml +++ b/app/src/main/res/layout/file_info_fragment.xml @@ -15,164 +15,190 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:padding="16dp"> + android:padding="@dimen/standard_padding"> - + android:orientation="vertical"> + + + + + + - + android:layout_marginBottom="16dp" + android:hint="@string/governance_file_detention_label"> + + + + + + - + android:orientation="vertical"> + android:orientation="vertical"> + + - - android:src="@drawable/outline_image_24" /> + + + + + + + + android:gravity="center_vertical" + android:orientation="horizontal" + android:visibility="gone" + tools:visibility="visible"> - + - + android:orientation="vertical"> + + + + + + - - - + android:layout_width="match_parent" + android:layout_height="@dimen/image_detail_map_height" + android:background="@drawable/rounded_rect" + android:elevation="1dp" + android:orientation="vertical"> - - - - - - - - + android:visibility="gone" + tools:text="@string/placeholder_image_detail_location" + tools:visibility="visible" /> - - - - - - - + android:layout_height="match_parent"> + + + + + - - + - + - + + diff --git a/app/src/main/res/values/dims.xml b/app/src/main/res/values/dims.xml index 516c83ee4ca8..aee39fda9c48 100644 --- a/app/src/main/res/values/dims.xml +++ b/app/src/main/res/values/dims.xml @@ -39,7 +39,7 @@ 100dp 100dp 4dp - + 300dp 14dp 12sp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f763f4f7973d..c0d053118817 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,9 @@ ~ SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only --> + Sensitivity label + File detention + Failed to open text editor All files Favorites From 21497e66972f5589e36bfe20b8b8e0672e7db209 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 10:08:27 +0200 Subject: [PATCH 07/11] use m3 card to distinc between sections Signed-off-by: alperozturk96 --- .../nextcloud/ui/fileInfo/FileInfoFragment.kt | 1 + .../ui/fileInfo/GovernanceDetailInfo.kt | 5 + .../nextcloud/ui/fileInfo/ImageDetailInfo.kt | 3 + .../main/res/layout/file_info_fragment.xml | 171 ++++++++++++------ app/src/main/res/values/strings.xml | 2 + 5 files changed, 122 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt index 3eddf0c3e0ac..c9d24a608348 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt @@ -46,6 +46,7 @@ class FileInfoFragment : file?.let { imageDetailInfo.init(it, binding) } } + val governanceDetailInfo = GovernanceDetailInfo(binding, viewThemeUtils, this) governanceDetailInfo.init() diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt index 90c2a1e35be2..ab2203252530 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt @@ -25,6 +25,8 @@ class GovernanceDetailInfo( ) { fun init() { + viewThemeUtils.material.themeCardView(binding.governanceLayout) + val items = listOf( SensitivityLabel("Option 1", R.drawable.outline_camera_24), SensitivityLabel("Option 2", R.drawable.outline_image_24), @@ -69,5 +71,8 @@ class GovernanceDetailInfo( binding.sensitivityLabelAutoComplete.compoundDrawablePadding = fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) } + + viewThemeUtils.material.colorTextInputLayout(binding.sensitivityLabel) + viewThemeUtils.material.colorTextInputLayout(binding.fileDetentionLabel) } } diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt index 2fbb72c2cf28..00baceba14d9 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt @@ -50,6 +50,9 @@ class ImageDetailInfo(private val fragment: FileInfoFragment, private val viewTh } fun init(file: OCFile, binding: FileInfoFragmentBinding) { + viewThemeUtils.material.themeCardView(binding.imageDetailLayout) + binding.imageDetailLayout.visibility = View.VISIBLE + val metadata = gatherMetadata(file) binding.fileInformationTime.text = metadata.date binding.fileDetailsIcon.setImageDrawable( diff --git a/app/src/main/res/layout/file_info_fragment.xml b/app/src/main/res/layout/file_info_fragment.xml index d70d31676595..8b746716a2db 100644 --- a/app/src/main/res/layout/file_info_fragment.xml +++ b/app/src/main/res/layout/file_info_fragment.xml @@ -2,11 +2,13 @@ @@ -17,58 +19,91 @@ android:orientation="vertical" android:padding="@dimen/standard_padding"> - + android:layout_marginBottom="@dimen/standard_margin" + app:cardElevation="0dp" + app:strokeColor="?attr/colorOutline" + app:strokeWidth="1dp" + app:cardCornerRadius="@dimen/card_corner_radius" + app:cardBackgroundColor="?attr/colorSurface"> - + android:orientation="vertical" + android:padding="@dimen/standard_padding"> - + + + android:layout_marginBottom="@dimen/standard_margin" + android:hint="@string/governance_sensitivity_label"> - + - + - + android:hint="@string/governance_file_detention_label"> - + - + + + + - + app:cardElevation="0dp" + app:strokeColor="?attr/colorOutline" + app:strokeWidth="1dp" + app:cardCornerRadius="@dimen/card_corner_radius" + app:cardBackgroundColor="?attr/colorSurface" + android:visibility="gone" + tools:visibility="visible"> + android:orientation="vertical" + android:padding="@dimen/standard_padding"> + + @@ -103,6 +139,8 @@ android:id="@+id/fileInformation_details" android:layout_width="match_parent" android:layout_height="wrap_content" + android:textAppearance="@style/TextAppearance.Material3.BodySmall" + android:textColor="?attr/colorOnSurfaceVariant" tools:text="@string/placeholder_image_details" /> @@ -111,6 +149,7 @@ android:id="@+id/imgTC" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginTop="@dimen/standard_quarter_margin" android:gravity="center_vertical" android:orientation="horizontal" android:visibility="gone" @@ -133,6 +172,7 @@ android:id="@+id/imgTC_makeModel" android:layout_width="match_parent" android:layout_height="wrap_content" + android:textAppearance="@style/TextAppearance.Material3.BodyMedium" android:textStyle="bold" tools:text="@string/placeholder_image_detail_model" /> @@ -140,26 +180,25 @@ android:id="@+id/imgTC_conditions" android:layout_width="match_parent" android:layout_height="wrap_content" + android:textAppearance="@style/TextAppearance.Material3.BodySmall" + android:textColor="?attr/colorOnSurfaceVariant" tools:text="@string/placeholder_image_detail_condition" /> - - - - + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/standard_margin" + android:orientation="vertical" + android:visibility="gone" + tools:visibility="visible"> + + - + android:layout_height="@dimen/image_detail_map_height" + app:cardElevation="0dp" + app:strokeColor="?attr/colorOutlineVariant" + app:strokeWidth="1dp" + app:cardCornerRadius="@dimen/standard_half_padding"> - - - - + android:layout_height="match_parent"> + + + + + + + - - + - + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c0d053118817..8a00de5822ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,6 +8,8 @@ Sensitivity label File detention + Governance + Image details Failed to open text editor All files From 81690f87c83c6661f1d845bef638827cc8584166 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 10:21:01 +0200 Subject: [PATCH 08/11] simplify Signed-off-by: alperozturk96 --- .../nextcloud/ui/fileInfo/FileInfoFragment.kt | 1 - .../ui/fileInfo/GovernanceDetailInfo.kt | 95 +++++++++++-------- ...SensitivityLabel.kt => GovernanceLabel.kt} | 2 +- .../theme/FilesSpecificViewThemeUtils.kt | 11 +++ 4 files changed, 70 insertions(+), 39 deletions(-) rename app/src/main/java/com/nextcloud/ui/fileInfo/model/{SensitivityLabel.kt => GovernanceLabel.kt} (75%) diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt index c9d24a608348..3eddf0c3e0ac 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt @@ -46,7 +46,6 @@ class FileInfoFragment : file?.let { imageDetailInfo.init(it, binding) } } - val governanceDetailInfo = GovernanceDetailInfo(binding, viewThemeUtils, this) governanceDetailInfo.init() diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt index ab2203252530..a1cfad20f3ee 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt @@ -12,8 +12,10 @@ import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.TextView import androidx.core.content.ContextCompat +import com.google.android.material.textfield.MaterialAutoCompleteTextView +import com.google.android.material.textfield.TextInputLayout import com.nextcloud.android.common.ui.theme.utils.ColorRole -import com.nextcloud.ui.fileInfo.model.SensitivityLabel +import com.nextcloud.ui.fileInfo.model.GovernanceLabel import com.owncloud.android.R import com.owncloud.android.databinding.FileInfoFragmentBinding import com.owncloud.android.utils.theme.ViewThemeUtils @@ -23,56 +25,75 @@ class GovernanceDetailInfo( private val viewThemeUtils: ViewThemeUtils, private val fragment: FileInfoFragment ) { + private val context get() = fragment.requireContext() fun init() { viewThemeUtils.material.themeCardView(binding.governanceLayout) + initSensitivityLabel() + initFileDetentionLabel() + } + + private fun initSensitivityLabel() { + initDropdown( + textInputLayout = binding.sensitivityLabel, + autoComplete = binding.sensitivityLabelAutoComplete, + items = listOf( + GovernanceLabel("Sharing restricted", R.drawable.ic_share), + GovernanceLabel("Download restricted", R.drawable.ic_download_grey600), + GovernanceLabel("Upload restricted", R.drawable.uploads) + ) + ) + } - val items = listOf( - SensitivityLabel("Option 1", R.drawable.outline_camera_24), - SensitivityLabel("Option 2", R.drawable.outline_image_24), - SensitivityLabel("Option 3", R.drawable.ic_information_outline) + private fun initFileDetentionLabel() { + initDropdown( + textInputLayout = binding.fileDetentionLabel, + autoComplete = binding.fileDetentionAutoComplete, + items = listOf( + GovernanceLabel("Public", R.drawable.file_link), + GovernanceLabel("Internal use only", R.drawable.ic_group), + GovernanceLabel("Restricted", R.drawable.ic_cancel) + ) ) + } + + private fun initDropdown( + textInputLayout: TextInputLayout, + autoComplete: MaterialAutoCompleteTextView, + items: List + ) { + viewThemeUtils.material.colorTextInputLayout(textInputLayout) + viewThemeUtils.files.themeAutoCompleteTextView(autoComplete) + + autoComplete.setAdapter(buildAdapter(items)) + + items.firstOrNull()?.let { applySelection(autoComplete, it) } - val adapter = object : - ArrayAdapter(fragment.requireContext(), R.layout.item_dropdown_with_icon, items) { + autoComplete.setOnItemClickListener { _, _, position, _ -> + applySelection(autoComplete, items[position]) + } + } + + private fun buildAdapter(items: List) = + object : ArrayAdapter(context, R.layout.item_dropdown_with_icon, items) { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val view = super.getView(position, convertView, parent) as TextView - val item = getItem(position) - if (item != null) { + getItem(position)?.let { item -> view.text = item.text - val drawable = ContextCompat.getDrawable(context, item.iconRes)?.mutate() - drawable?.let { - viewThemeUtils.platform.tintDrawable(fragment.requireContext(), it, ColorRole.ON_SURFACE) - } - view.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) + view.setCompoundDrawablesWithIntrinsicBounds(tintedDrawable(item), null, null, null) } return view } } - binding.sensitivityLabelAutoComplete.setAdapter(adapter) - val defaultSelectedItem = items.firstOrNull() - if (defaultSelectedItem != null) { - binding.sensitivityLabelAutoComplete.setText(defaultSelectedItem.text, false) - val drawable = ContextCompat.getDrawable(fragment.requireContext(), defaultSelectedItem.iconRes)?.mutate() - drawable?.let { - viewThemeUtils.platform.tintDrawable(fragment.requireContext(), it, ColorRole.ON_SURFACE) - } - binding.sensitivityLabelAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) - binding.sensitivityLabelAutoComplete.compoundDrawablePadding = - fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) - } + private fun applySelection(autoComplete: MaterialAutoCompleteTextView, item: GovernanceLabel) { + autoComplete.setText(item.text, false) + autoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(tintedDrawable(item), null, null, null) + autoComplete.compoundDrawablePadding = fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) + } - binding.sensitivityLabelAutoComplete.setOnItemClickListener { _, _, position, _ -> - val selected = items[position] - binding.sensitivityLabelAutoComplete.setText(selected.text, false) - val drawable = ContextCompat.getDrawable(fragment.requireContext(), selected.iconRes)?.mutate() - binding.sensitivityLabelAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) - binding.sensitivityLabelAutoComplete.compoundDrawablePadding = - fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) + private fun tintedDrawable(item: GovernanceLabel) = + ContextCompat.getDrawable(context, item.iconRes)?.mutate()?.also { + viewThemeUtils.platform.tintDrawable(context, it, ColorRole.ON_SURFACE) } - - viewThemeUtils.material.colorTextInputLayout(binding.sensitivityLabel) - viewThemeUtils.material.colorTextInputLayout(binding.fileDetentionLabel) - } } diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/model/SensitivityLabel.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/model/GovernanceLabel.kt similarity index 75% rename from app/src/main/java/com/nextcloud/ui/fileInfo/model/SensitivityLabel.kt rename to app/src/main/java/com/nextcloud/ui/fileInfo/model/GovernanceLabel.kt index d9895ee6fe3a..1904c790c40d 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/model/SensitivityLabel.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/model/GovernanceLabel.kt @@ -7,4 +7,4 @@ package com.nextcloud.ui.fileInfo.model -data class SensitivityLabel(val text: String, val iconRes: Int) +data class GovernanceLabel(val text: String, val iconRes: Int) diff --git a/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt b/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt index c52e6c509773..ccf8152a8f8d 100644 --- a/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt @@ -28,6 +28,7 @@ import androidx.appcompat.widget.SearchView import androidx.core.content.res.ResourcesCompat import com.google.android.material.card.MaterialCardView import com.google.android.material.navigation.NavigationView +import com.google.android.material.textfield.MaterialAutoCompleteTextView import com.nextcloud.android.common.ui.color.ColorUtil import com.nextcloud.android.common.ui.theme.MaterialSchemes import com.nextcloud.android.common.ui.theme.ViewThemeUtilsBase @@ -312,6 +313,16 @@ class FilesSpecificViewThemeUtils @Inject constructor( } } + fun themeAutoCompleteTextView(autoCompleteTextView: MaterialAutoCompleteTextView) { + withScheme(autoCompleteTextView) { scheme -> + autoCompleteTextView.setTextColor(dynamicColor.onSurface().getArgb(scheme)) + autoCompleteTextView.setHintTextColor(dynamicColor.onSurfaceVariant().getArgb(scheme)) + autoCompleteTextView.setDropDownBackgroundTintList( + ColorStateList.valueOf(dynamicColor.surfaceContainer().getArgb(scheme)) + ) + } + } + companion object { private val TAG = FilesSpecificViewThemeUtils::class.simpleName From 43b91a7f7819ec5bf42409d37108f3cbaaa9db87 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 10:35:20 +0200 Subject: [PATCH 09/11] wip Signed-off-by: alperozturk96 --- .../com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt | 8 ++++---- app/src/main/res/layout/file_info_fragment.xml | 6 +++--- app/src/main/res/values/strings.xml | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt index a1cfad20f3ee..d951c0f55ab5 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt @@ -30,7 +30,7 @@ class GovernanceDetailInfo( fun init() { viewThemeUtils.material.themeCardView(binding.governanceLayout) initSensitivityLabel() - initFileDetentionLabel() + initFileRetentionLabel() } private fun initSensitivityLabel() { @@ -45,10 +45,10 @@ class GovernanceDetailInfo( ) } - private fun initFileDetentionLabel() { + private fun initFileRetentionLabel() { initDropdown( - textInputLayout = binding.fileDetentionLabel, - autoComplete = binding.fileDetentionAutoComplete, + textInputLayout = binding.fileRetentionLabel, + autoComplete = binding.fileRetentionAutoComplete, items = listOf( GovernanceLabel("Public", R.drawable.file_link), GovernanceLabel("Internal use only", R.drawable.ic_group), diff --git a/app/src/main/res/layout/file_info_fragment.xml b/app/src/main/res/layout/file_info_fragment.xml index 8b746716a2db..3ae7570c8462 100644 --- a/app/src/main/res/layout/file_info_fragment.xml +++ b/app/src/main/res/layout/file_info_fragment.xml @@ -61,14 +61,14 @@ + android:hint="@string/governance_file_retention_label"> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8a00de5822ff..0bb61387bec3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,7 +7,7 @@ --> Sensitivity label - File detention + File retention Governance Image details From 7d0fd5942274b4e6531c81e96aedfffaa5b39f5b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 11:03:09 +0200 Subject: [PATCH 10/11] wip Signed-off-by: alperozturk96 --- .../main/res/drawable/ic_dashboard_filled.xml | 17 ++++------------- .../main/res/drawable/ic_dashboard_outlined.xml | 17 ++++------------- .../main/res/layout/item_dropdown_with_icon.xml | 6 ++++++ 3 files changed, 14 insertions(+), 26 deletions(-) diff --git a/app/src/main/res/drawable/ic_dashboard_filled.xml b/app/src/main/res/drawable/ic_dashboard_filled.xml index 2d5b26893695..111b1e97f44a 100644 --- a/app/src/main/res/drawable/ic_dashboard_filled.xml +++ b/app/src/main/res/drawable/ic_dashboard_filled.xml @@ -1,18 +1,9 @@ + ~ SPDX-FileCopyrightText: 2018-2025 Google LLC + ~ SPDX-License-Identifier: Apache-2.0 +--> + ~ SPDX-FileCopyrightText: 2018-2025 Google LLC + ~ SPDX-License-Identifier: Apache-2.0 +--> + Date: Tue, 5 May 2026 11:09:18 +0200 Subject: [PATCH 11/11] wip Signed-off-by: alperozturk96 --- app/src/main/res/layout/item_dropdown_with_icon.xml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/layout/item_dropdown_with_icon.xml b/app/src/main/res/layout/item_dropdown_with_icon.xml index 4df877f93896..ac31e3f898fd 100644 --- a/app/src/main/res/layout/item_dropdown_with_icon.xml +++ b/app/src/main/res/layout/item_dropdown_with_icon.xml @@ -6,10 +6,9 @@ ~ SPDX-License-Identifier: AGPL-3.0-or-later -->