diff --git a/components/build.gradle.kts b/components/build.gradle.kts index b17d841..e043f75 100644 --- a/components/build.gradle.kts +++ b/components/build.gradle.kts @@ -50,13 +50,23 @@ dependencies { implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.9.0") implementation("org.orbit-mvi:orbit-viewmodel:6.1.0") - implementation("dev.arkbuilders:arklib:0.3.2") + implementation("dev.arkbuilders:arklib:0.3.5") implementation("com.mikepenz:fastadapter:5.6.0") implementation("com.mikepenz:fastadapter-extensions-binding:5.6.0") + implementation("com.mikepenz:fastadapter-extensions-diff:5.6.0") + + implementation("com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.8") + implementation("androidx.fragment:fragment-ktx:1.6.1") implementation("com.github.skydoves:balloon:1.4.8") implementation("com.google.android.flexbox:flexbox:3.0.0") + val coilVersion = "2.4.0" + implementation("io.coil-kt:coil:$coilVersion") + implementation("io.coil-kt:coil-gif:$coilVersion") + implementation("io.coil-kt:coil-svg:$coilVersion") + implementation("io.coil-kt:coil-video:$coilVersion") + testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") diff --git a/components/src/main/java/dev/arkbuilders/components/filepicker/ArkFilePickerConfig.kt b/components/src/main/java/dev/arkbuilders/components/filepicker/ArkFilePickerConfig.kt new file mode 100644 index 0000000..79d8fe3 --- /dev/null +++ b/components/src/main/java/dev/arkbuilders/components/filepicker/ArkFilePickerConfig.kt @@ -0,0 +1,30 @@ +package dev.arkbuilders.components.filepicker + +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import androidx.annotation.StyleRes +import dev.arkbuilders.components.R +import java.nio.file.Path + +class ArkFilePickerConfig( + @StringRes + val titleStringId: Int = R.string.ark_file_picker_pick_title, + @StringRes + val pickButtonStringId: Int = R.string.ark_file_picker_pick, + @StringRes + val cancelButtonStringId: Int = R.string.ark_file_picker_cancel, + @StringRes + val internalStorageStringId: Int = R.string.ark_file_picker_internal_storage, + @StringRes + val accessDeniedStringId: Int = R.string.ark_file_picker_access_denied, + @PluralsRes + val itemsPluralId: Int = R.plurals.ark_file_picker_items, + @StyleRes + val themeId: Int = com.google.android.material.R.style.Theme_MaterialComponents_Light_Dialog, + + val showRoots: Boolean = false, + val rootsFirstPage: Boolean = false, + val initialPath: Path? = null, + val mode: ArkFilePickerMode = ArkFilePickerMode.FOLDER, + val pathPickedRequestKey: String? = null, +) \ No newline at end of file diff --git a/components/src/main/java/dev/arkbuilders/components/filepicker/ArkFilePickerFragment.kt b/components/src/main/java/dev/arkbuilders/components/filepicker/ArkFilePickerFragment.kt new file mode 100644 index 0000000..414526d --- /dev/null +++ b/components/src/main/java/dev/arkbuilders/components/filepicker/ArkFilePickerFragment.kt @@ -0,0 +1,476 @@ +package dev.arkbuilders.components.filepicker + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Build +import android.os.Bundle +import android.util.TypedValue +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast +import androidx.core.view.isVisible +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import by.kirich1409.viewbindingdelegate.viewBinding +import coil.ImageLoader +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder +import coil.decode.SvgDecoder +import coil.decode.VideoFrameDecoder +import coil.dispose +import coil.load +import coil.memory.MemoryCache +import coil.request.CachePolicy +import com.google.android.material.tabs.TabLayoutMediator +import com.mikepenz.fastadapter.FastAdapter +import com.mikepenz.fastadapter.GenericItem +import com.mikepenz.fastadapter.adapters.ItemAdapter +import com.mikepenz.fastadapter.binding.AbstractBindingItem +import dev.arkbuilders.arklib.utils.DeviceStorageUtils +import dev.arkbuilders.arklib.utils.INTERNAL_STORAGE +import org.orbitmvi.orbit.viewmodel.observe +import dev.arkbuilders.components.R +import dev.arkbuilders.components.databinding.ArkFilePickerHostFragmentBinding +import dev.arkbuilders.components.databinding.ArkFilePickerItemFileBinding +import dev.arkbuilders.components.databinding.ArkFilePickerItemFilesRootsPageBinding +import dev.arkbuilders.components.folderstree.FolderTreeView +import dev.arkbuilders.components.utils.args +import dev.arkbuilders.components.utils.dpToPx +import dev.arkbuilders.components.utils.formatSize +import dev.arkbuilders.components.utils.iconForExtension +import dev.arkbuilders.components.utils.setDragSensitivity +import java.lang.Exception +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.extension +import kotlin.io.path.fileSize +import kotlin.io.path.isDirectory +import kotlin.io.path.isHidden +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name + +open class ArkFilePickerFragment : + DialogFragment(R.layout.ark_file_picker_host_fragment) { + + var titleStringId by args() + var pickButtonStringId by args() + var cancelButtonStringId by args() + var internalStorageStringId by args() + var itemsPluralId by args() + var themeId by args() + var accessDeniedStringId by args() + var mode by args() + var initialPath by args() + var showRoots by args() + var pathPickedRequestKey by args() + var rootsFirstPage by args() + + var currentFolder: Path? = null + val binding by viewBinding(ArkFilePickerHostFragmentBinding::bind) + private val viewModel by viewModels { + ArkFilePickerViewModelFactory( + DeviceStorageUtils(requireContext().applicationContext), + ArkFilePickerMode.values()[mode!!], + initialPath?.let { Path(it) } + ) + } + + private val pagesAdapter = ItemAdapter() + + open fun onFolderChanged(currentFolder: Path) {} + open fun onPick(pickedPath: Path) {} + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initUI() + initBackButtonListener() + viewModel.observe( + this, + state = ::render, + sideEffect = ::handleSideEffect + ) + } + + override fun onResume() { + super.onResume() + dialog?.window?.let { + it.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + it.setLayout( + requireContext().dpToPx(DIALOG_WIDTH), + WindowManager.LayoutParams.MATCH_PARENT + ) + } + } + + private fun initUI() = with(binding) { + btnPick.text = getString(pickButtonStringId!!) + btnCancel.text = getString(cancelButtonStringId!!) + tvTitle.text = getString(titleStringId!!) + if (mode == ArkFilePickerMode.FILE.ordinal) + btnPick.isVisible = false + + btnCancel.setOnClickListener { + dismiss() + } + btnPick.setOnClickListener { + viewModel.onPickBtnClick() + } + vp.adapter = FastAdapter.with(pagesAdapter) + vp.offscreenPageLimit = 2 + if (!showRoots!!) { + vp.getChildAt(0).apply { + overScrollMode = View.OVER_SCROLL_NEVER + } + vp.setDragSensitivity(10) + } + val pages = getPages() + + pagesAdapter.set(pages) + val tabsTitle = resources + .getStringArray(R.array.ark_file_picker_tabs) + .apply { + if (!rootsFirstPage!!) + reverse() + } + + if (showRoots!!) { + TabLayoutMediator(tabs, vp) { tab, pos -> + tab.text = tabsTitle[pos] + }.attach() + } else { + tabs.isVisible = false + } + } + + private fun render(state: FilePickerState) = binding.apply { + displayPath(state) + + val deviceText = if (state.currentDevice == INTERNAL_STORAGE) + getString(internalStorageStringId!!) + else + state.currentDevice.last().toString() + + if (state.currentPath.isDirectory()) { + if (state.currentPath != currentFolder) { + currentFolder = state.currentPath + onFolderChanged(currentFolder!!) + } + } + + tvDevice.text = deviceText + if (state.currentPath == state.currentDevice) + tvDevice.setTextColor( + resources.getColor( + R.color.black, + null + ) + ) + else + tvDevice.setTextColor( + resources.getColor( + R.color.ark_file_picker_gray, + null + ) + ) + + + tvDevice.setOnClickListener { + if (state.devices.size == 1) + viewModel.onItemClick(state.currentDevice) + else + DevicesPopup( + requireContext(), + state.devices, + viewModel + ).showBelow(it) + } + } + + private fun handleSideEffect(effect: FilePickerSideEffect) = when (effect) { + FilePickerSideEffect.DismissDialog -> dismiss() + FilePickerSideEffect.ToastAccessDenied -> Toast.makeText( + requireContext(), + accessDeniedStringId!!, + Toast.LENGTH_SHORT + ).show() + + is FilePickerSideEffect.NotifyPathPicked -> { + onPick(effect.path) + setFragmentResult( + pathPickedRequestKey ?: PATH_PICKED_REQUEST_KEY, + Bundle().apply { + putString( + PATH_PICKED_PATH_BUNDLE_KEY, + effect.path.toString() + ) + }) + } + } + + + private fun displayPath(state: FilePickerState) = binding.apply { + layoutPath.removeViews(1, layoutPath.childCount - 1) + val pathWithoutDevice = + state.currentDevice.relativize(state.currentPath) + + val padding = requireContext().dpToPx(PATH_PART_PADDING) + var tmpPath = state.currentDevice + pathWithoutDevice + .filter { it.toString().isNotEmpty() } + .forEach { part -> + tmpPath = tmpPath.resolve(part) + val fullPathToPart = tmpPath + val tv = TextView(requireContext()) + val text = "/$part" + val outValue = TypedValue() + requireContext().theme.resolveAttribute( + android.R.attr.selectableItemBackground, + outValue, + true + ) + tv.setBackgroundResource(outValue.resourceId) + tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f) + tv.setPadding(padding, 0, 0, 0) + tv.isClickable = true + tv.text = text + if (pathWithoutDevice.last() == part) + tv.setTextColor( + resources.getColor( + R.color.black, + null + ) + ) + else { + tv.setTextColor( + resources.getColor( + R.color.ark_file_picker_gray, + null + ) + ) + tv.setOnClickListener { + viewModel.onItemClick(fullPathToPart) + } + } + layoutPath.addView(tv) + } + scrollPath.post { + scrollPath.fullScroll(ScrollView.FOCUS_RIGHT) + } + } + + override fun getTheme() = themeId!! + + private fun initBackButtonListener() { + requireDialog().setOnKeyListener { _, keyCode, keyEvent -> + if (keyCode == KeyEvent.KEYCODE_BACK && + keyEvent.action == KeyEvent.ACTION_UP + ) { + viewModel.onBackClick() + } + return@setOnKeyListener true + } + } + + private fun getPages() = if (showRoots!!) { + if (rootsFirstPage!!) { + listOf( + RootsPage(this, viewModel), + FilesPage(this, viewModel, itemsPluralId!!) + ) + } else { + listOf( + FilesPage(this, viewModel, itemsPluralId!!), + RootsPage(this, viewModel) + ) + } + } else { + listOf( + FilesPage(this, viewModel, itemsPluralId!!) + ) + } + + + fun setConfig(config: ArkFilePickerConfig) { + titleStringId = config.titleStringId + pickButtonStringId = config.pickButtonStringId + cancelButtonStringId = config.cancelButtonStringId + internalStorageStringId = config.internalStorageStringId + accessDeniedStringId = config.accessDeniedStringId + itemsPluralId = config.itemsPluralId + themeId = config.themeId + initialPath = config.initialPath?.toString() + showRoots = config.showRoots + pathPickedRequestKey = config.pathPickedRequestKey + rootsFirstPage = config.rootsFirstPage + mode = config.mode.ordinal + } + + companion object { + const val PATH_PICKED_REQUEST_KEY = "arkFilePickerPathPicked" + const val PATH_PICKED_PATH_BUNDLE_KEY = "arkFilePickerPathPickedPathKey" + + fun newInstance(config: ArkFilePickerConfig) = + ArkFilePickerFragment().apply { + setConfig(config) + } + + private const val DIALOG_WIDTH = 300f + private const val PATH_PART_PADDING = 4f + } +} + +private fun pickerImageLoader(ctx: Context) = ImageLoader.Builder(ctx) + .components { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + add(SvgDecoder.Factory()) + add(VideoFrameDecoder.Factory()) + } + .memoryCache { + MemoryCache.Builder(ctx) + .maxSizePercent(0.10) + .build() + } + .diskCachePolicy(CachePolicy.DISABLED) + .build() + +internal class FilesPage( + private val fragment: Fragment, + private val viewModel: ArkFilePickerViewModel, + private val itemsPluralId: Int +) : AbstractBindingItem() { + private val filesAdapter = ItemAdapter() + private var currentFiles = emptyList() + private val imageLoader = pickerImageLoader(fragment.requireContext()) + + override val type = 0 + + override fun createBinding( + inflater: LayoutInflater, + parent: ViewGroup? + ) = ArkFilePickerItemFilesRootsPageBinding.inflate(inflater, parent, false) + + override fun bindView( + binding: ArkFilePickerItemFilesRootsPageBinding, + payloads: List + ) = with(binding) { + rvFiles.adapter = FastAdapter.with(filesAdapter) + viewModel.observe(fragment, state = ::render) + } + + private fun render(state: FilePickerState) { + if (currentFiles == state.files) return + + filesAdapter.setNewList(state.files.map { + FileItem(it, viewModel, itemsPluralId, imageLoader) + }) + + currentFiles = state.files + } +} + +internal class FileItem( + private val file: Path, + private val viewModel: ArkFilePickerViewModel, + private val itemsPluralId: Int, + private val imageLoader: ImageLoader, +) : AbstractBindingItem() { + override val type = 0 + + override fun createBinding( + inflater: LayoutInflater, + parent: ViewGroup? + ) = ArkFilePickerItemFileBinding.inflate(inflater, parent, false) + + override fun bindView( + binding: ArkFilePickerItemFileBinding, + payloads: List + ) = with(binding) { + root.setOnClickListener { + viewModel.onItemClick(file) + } + binding.tvName.text = file.name + if (file.isDirectory()) bindFolder(file, this) + else bindRegularFile(file, this) + return@with + } + + private fun bindRegularFile( + file: Path, + binding: ArkFilePickerItemFileBinding + ) = with(binding) { + binding.tvDetails.text = file.fileSize().formatSize() + binding.iv.load(file.toFile(), imageLoader) { + size(200) + placeholder(binding.iv.iconForExtension(file.extension.lowercase())) + crossfade(true) + } + } + + private fun bindFolder( + folder: Path, + binding: ArkFilePickerItemFileBinding + ) = with(binding) { + val childrenCount = try { + folder.listDirectoryEntries().filter { !it.isHidden() }.size + } catch (e: Exception) { + 0 + } + binding.tvDetails.text = binding.root.context.resources.getQuantityString( + itemsPluralId, + childrenCount, + childrenCount + ) + + binding.iv.dispose() + binding.iv.setImageResource(R.drawable.ark_file_picker_ic_folder) + } +} + +internal class RootsPage( + private val fragment: Fragment, + private val viewModel: ArkFilePickerViewModel +) : AbstractBindingItem() { + private lateinit var folderTreeView: FolderTreeView + private var currentRootsWithFavs = mapOf>() + + override val type = 1 + + override fun createBinding( + inflater: LayoutInflater, + parent: ViewGroup? + ) = ArkFilePickerItemFilesRootsPageBinding.inflate(inflater, parent, false) + + override fun bindView( + binding: ArkFilePickerItemFilesRootsPageBinding, + payloads: List + ) = with(binding) { + folderTreeView = FolderTreeView( + rvFiles, + onNavigateClick = { node -> viewModel.onItemClick(node.path) }, + onAddClick = {}, + onForgetClick = {}, + showOptions = false + ) + viewModel.observe(fragment, state = ::render) + } + + private fun render(state: FilePickerState) { + if (currentRootsWithFavs == state.rootsWithFavs) return + + folderTreeView.set(state.devices, state.rootsWithFavs) + + currentRootsWithFavs = state.rootsWithFavs + } +} \ No newline at end of file diff --git a/components/src/main/java/dev/arkbuilders/components/filepicker/ArkFilePickerViewModel.kt b/components/src/main/java/dev/arkbuilders/components/filepicker/ArkFilePickerViewModel.kt new file mode 100644 index 0000000..564f134 --- /dev/null +++ b/components/src/main/java/dev/arkbuilders/components/filepicker/ArkFilePickerViewModel.kt @@ -0,0 +1,148 @@ +package dev.arkbuilders.components.filepicker + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.postSideEffect +import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.viewmodel.container +import dev.arkbuilders.arklib.data.folders.FoldersRepo +import dev.arkbuilders.arklib.utils.DeviceStorageUtils +import dev.arkbuilders.arklib.utils.listChildren +import java.nio.file.Path +import kotlin.io.path.isDirectory + +enum class ArkFilePickerMode { + FILE, FOLDER, ANY +} + +internal data class FilePickerState( + val devices: List, + val selectedDevicePos: Int, + val currentPath: Path, + val files: List, + val rootsWithFavs: Map> +) { + val currentDevice get() = devices[selectedDevicePos] +} + +internal sealed class FilePickerSideEffect { + object DismissDialog : FilePickerSideEffect() + object ToastAccessDenied : FilePickerSideEffect() + class NotifyPathPicked(val path: Path) : FilePickerSideEffect() +} + +internal class ArkFilePickerViewModel( + private val deviceStorageUtils: DeviceStorageUtils, + private val mode: ArkFilePickerMode, + private val initialPath: Path? +): ViewModel(), ContainerHost { + + private val foldersRepo = FoldersRepo.instance + + override val container: Container = + container(initialState()) + + init { + viewModelScope.launch { + val rootsWithFavs = foldersRepo.provideFolders() + intent { + reduce { + state.copy(rootsWithFavs = rootsWithFavs) + } + } + } + } + + fun onItemClick(path: Path) = intent { + if (path.isDirectory()) { + try { + reduce { + state.copy( + currentPath = path, + files = formatChildren(path) + ) + } + } catch (e: Exception) { + postSideEffect(FilePickerSideEffect.ToastAccessDenied) + } + return@intent + } + + if (mode != ArkFilePickerMode.FOLDER) + onPathPicked(path) + } + + fun onPickBtnClick() = intent { onPathPicked(state.currentPath) } + + fun onDeviceSelected(selectedDevicePos: Int) = intent { + val selectedDevice = state.devices[selectedDevicePos] + reduce { + state.copy( + selectedDevicePos = selectedDevicePos, + currentPath = selectedDevice, + files = formatChildren(selectedDevice) + ) + } + } + + fun onBackClick() = intent { + val isDevice = state.devices.any { device -> device == state.currentPath } + if (isDevice) { + postSideEffect(FilePickerSideEffect.DismissDialog) + return@intent + } + val parent = state.currentPath.parent + + reduce { + state.copy( + currentPath = parent, + files = formatChildren(parent) + ) + } + } + + private fun onPathPicked(path: Path) = intent { + postSideEffect(FilePickerSideEffect.NotifyPathPicked(path)) + postSideEffect(FilePickerSideEffect.DismissDialog) + } + + private fun initialState(): FilePickerState { + val devices = deviceStorageUtils.listStorages() + val currentPath = initialPath ?: devices[0] + val selectedDevice = + devices.find { currentPath.startsWith(it) } ?: devices[0] + val selectedDevicePos = devices.indexOf(selectedDevice) + return FilePickerState( + devices, + selectedDevicePos, + currentPath, + formatChildren(currentPath), + emptyMap() + ) + } + + private fun formatChildren(path: Path): List { + val (dirs, files) = listChildren(path) + + val children = mutableListOf() + children.addAll(dirs.sorted()) + children.addAll(files.sorted()) + + return children + } +} + +internal class ArkFilePickerViewModelFactory( + private val deviceStorageUtils: DeviceStorageUtils, + private val mode: ArkFilePickerMode, + private val initialPath: Path? +): ViewModelProvider.Factory { + + override fun create(modelClass: Class): T = + ArkFilePickerViewModel(deviceStorageUtils, mode, initialPath) as T +} \ No newline at end of file diff --git a/components/src/main/java/dev/arkbuilders/components/filepicker/DevicesPopup.kt b/components/src/main/java/dev/arkbuilders/components/filepicker/DevicesPopup.kt new file mode 100644 index 0000000..4b51f9d --- /dev/null +++ b/components/src/main/java/dev/arkbuilders/components/filepicker/DevicesPopup.kt @@ -0,0 +1,91 @@ +package dev.arkbuilders.components.filepicker + +import android.content.Context +import android.graphics.Rect +import android.view.Gravity +import android.view.View +import android.widget.LinearLayout +import android.widget.PopupWindow +import android.widget.TextView +import androidx.core.content.res.ResourcesCompat +import dev.arkbuilders.arklib.utils.INTERNAL_STORAGE +import dev.arkbuilders.components.R +import dev.arkbuilders.components.utils.dpToPx +import java.nio.file.Path + +internal class DevicesPopup( + val context: Context, + val devices: List, + val viewModel: ArkFilePickerViewModel +) { + val popupWindow: PopupWindow + val layout = createLayout() + + init { + layout.measure( + View.MeasureSpec.UNSPECIFIED, + View.MeasureSpec.UNSPECIFIED + ) + popupWindow = PopupWindow(context) + popupWindow.apply { + contentView = layout + width = LinearLayout.LayoutParams.WRAP_CONTENT + height = LinearLayout.LayoutParams.WRAP_CONTENT + isFocusable = true + animationStyle = R.style.ARKFilePickerFadeAnimation + setBackgroundDrawable(ResourcesCompat.getDrawable( + context.resources, + R.drawable.ark_file_picker_bg_round_16, + null + )) + elevation = context.dpToPx(24f).toFloat() + } + } + + fun showBelow(target: View) { + val targetRect = getViewRectOnScreen(target) + val xOffset = (target.width - layout.measuredWidth) / 2 + val popupLeft = targetRect.left + xOffset + popupWindow.showAtLocation( + target, + Gravity.NO_GRAVITY, + popupLeft, + targetRect.bottom + context.dpToPx(4f) + ) + } + + private fun getViewRectOnScreen(view: View): Rect { + val location = IntArray(2).apply { + view.getLocationInWindow(this) + } + return Rect( + location[0], + location[1], + location[0] + view.width, + location[1] + view.height + ) + } + + private fun createLayout(): View { + val linear = LinearLayout(context) + linear.orientation = LinearLayout.VERTICAL + val padding = context.dpToPx(14f) + devices.forEachIndexed { index, path -> + val tv = TextView(context) + val text = if (path == INTERNAL_STORAGE) + context.getString(R.string.ark_file_picker_internal_storage) + else + path.last().toString() + + tv.text = text + tv.setTextColor(context.resources.getColor(R.color.black, null)) + tv.setPadding(padding, padding, padding, padding) + tv.setOnClickListener { + viewModel.onDeviceSelected(index) + popupWindow.dismiss() + } + linear.addView(tv) + } + return linear + } +} \ No newline at end of file diff --git a/components/src/main/java/dev/arkbuilders/components/filepicker/FragmentManagerUtils.kt b/components/src/main/java/dev/arkbuilders/components/filepicker/FragmentManagerUtils.kt new file mode 100644 index 0000000..a198434 --- /dev/null +++ b/components/src/main/java/dev/arkbuilders/components/filepicker/FragmentManagerUtils.kt @@ -0,0 +1,23 @@ +package dev.arkbuilders.components.filepicker + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.LifecycleOwner +import java.nio.file.Path +import kotlin.io.path.Path + +fun FragmentManager.onArkPathPicked( + lifecycleOwner: LifecycleOwner, + customRequestKey: String? = null, + listener: (Path) -> Unit, +) { + setFragmentResultListener( + customRequestKey ?: ArkFilePickerFragment.PATH_PICKED_REQUEST_KEY, + lifecycleOwner + ) { _, bundle -> + listener( + Path( + bundle.getString(ArkFilePickerFragment.PATH_PICKED_PATH_BUNDLE_KEY)!! + ) + ) + } +} \ No newline at end of file diff --git a/components/src/main/java/dev/arkbuilders/components/folderstree/FolderItemView.kt b/components/src/main/java/dev/arkbuilders/components/folderstree/FolderItemView.kt new file mode 100644 index 0000000..33240b1 --- /dev/null +++ b/components/src/main/java/dev/arkbuilders/components/folderstree/FolderItemView.kt @@ -0,0 +1,203 @@ +package dev.arkbuilders.components.folderstree + +import android.animation.ValueAnimator +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.core.view.isVisible +import androidx.lifecycle.findViewTreeLifecycleOwner +import com.mikepenz.fastadapter.binding.AbstractBindingItem +import com.skydoves.balloon.Balloon +import dev.arkbuilders.arklib.utils.INTERNAL_STORAGE +import dev.arkbuilders.components.R +import dev.arkbuilders.components.databinding.ArkFilePickerItemDeviceBinding +import dev.arkbuilders.components.databinding.ArkFilePickerItemFavoriteBinding +import dev.arkbuilders.components.databinding.ArkFilePickerItemRootBinding +import dev.arkbuilders.components.utils.setMargin + +internal class DeviceFolderItem( + private val node: DeviceNode, + private val onExpandClick: (DeviceNode) -> Unit, +) : AbstractBindingItem() { + override val type = 0 + override var identifier: Long + get() = node.path.hashCode().toLong() + set(value) {} + + private var isExpanded = node.isExpanded + private var animator: ValueAnimator? = null + + override fun createBinding( + inflater: LayoutInflater, + parent: ViewGroup? + ) = ArkFilePickerItemDeviceBinding + .inflate(inflater, parent, false) + .also { binding -> + animator = ValueAnimator().apply { + duration = 500L + addUpdateListener { + binding.ivChevron.rotation = animatedValue as Float + } + } + } + + override fun bindView( + binding: ArkFilePickerItemDeviceBinding, + payloads: List + ) = with(binding) { + val context = root.context + ivChevron.rotation = if (isExpanded) 90f else 0f + tvDeviceName.text = + if (node.path == INTERNAL_STORAGE) + context.getString(R.string.ark_file_picker_internal_storage) + else node.name + root.setOnClickListener { + animateExpanded(!isExpanded) + onExpandClick(node) + } + } + + private fun animateExpanded(expanded: Boolean) { + if (expanded) { + animator?.setFloatValues(0F, 90F) + animator?.start() + } else { + animator?.setFloatValues(90F, 00F) + animator?.start() + } + + isExpanded = expanded + } +} + +internal class RootFolderItem( + private val node: RootNode, + private val onNavigateClick: (RootNode) -> Unit, + private val onExpandClick: (RootNode) -> Unit, + private val onAddClick: (RootNode) -> Unit, + private val onForgetClick: (RootNode) -> Unit, + private val showOptions: Boolean +) : AbstractBindingItem() { + override val type = 1 + override var identifier: Long + get() = node.path.hashCode().toLong() + set(value) {} + + private var chevron: ImageView? = null + private var isExpanded = node.isExpanded + private val animator = ValueAnimator().apply { + duration = 500L + addUpdateListener { + chevron?.rotation = animatedValue as Float + } + } + + override fun createBinding( + inflater: LayoutInflater, + parent: ViewGroup? + ) = ArkFilePickerItemRootBinding + .inflate(inflater, parent, false) + + override fun bindView( + binding: ArkFilePickerItemRootBinding, + payloads: List + ) = with(binding) { + this@RootFolderItem.chevron = ivChevron + ivChevron.rotation = if (isExpanded) 90f else 0f + tvRootName.text = node.name + layoutChevron.setOnClickListener { + animateExpanded(!isExpanded) + onExpandClick(node) + } + root.setOnClickListener { + onNavigateClick(node) + } + if (showOptions) + ivRoot.setMargin(right = 0) + with(layoutMoreOptions) { + isVisible = showOptions + setOnClickListener { + val lifecycleOwner = it.findViewTreeLifecycleOwner() + val balloon = Balloon.Builder(it.context) + .setLayout(R.layout.ark_file_picker_root_options) + .setBackgroundColorResource(R.color.white) + .setArrowSize(0) + .setLifecycleOwner(lifecycleOwner) + .build() + balloon.showAsDropDown(it) + val addRoot: View = balloon.getContentView() + .findViewById(R.id.layout_add) + val forgetRoot: View = balloon.getContentView() + .findViewById(R.id.layout_forget) + addRoot.setOnClickListener { + onAddClick(node) + balloon.dismiss() + } + forgetRoot.setOnClickListener { + onForgetClick(node) + balloon.dismiss() + } + } + } + } + + private fun animateExpanded(expanded: Boolean) { + if (expanded) { + animator.setFloatValues(0F, 90F) + animator.start() + } else { + animator.setFloatValues(90F, 00F) + animator.start() + } + + isExpanded = expanded + } +} + +internal class FavoriteFolderItem( + private val node: FavoriteNode, + private val onNavigateClick: (FavoriteNode) -> Unit, + private val onForgetClick: (FavoriteNode) -> Unit, + private val showOptions: Boolean +) : AbstractBindingItem() { + override val type = 2 + override var identifier: Long + get() = node.path.hashCode().toLong() + set(value) {} + + override fun createBinding( + inflater: LayoutInflater, + parent: ViewGroup? + ) = ArkFilePickerItemFavoriteBinding + .inflate(inflater, parent, false) + + override fun bindView( + binding: ArkFilePickerItemFavoriteBinding, + payloads: List + ) = with(binding) { + tvFavName.text = node.name + root.setOnClickListener { + onNavigateClick(node) + } + if (showOptions) + ivStar.setMargin(right = 0) + layoutMoreOptions.isVisible = showOptions + layoutMoreOptions.setOnClickListener { + val lifecycleOwner = it.findViewTreeLifecycleOwner() + val balloon = Balloon.Builder(it.context) + .setLayout(R.layout.ark_file_picker_favorite_options) + .setBackgroundColorResource(R.color.white) + .setLifecycleOwner(lifecycleOwner) + .setArrowSize(0) + .build() + val forgetFavoriteBtn: View = balloon.getContentView() + .findViewById(R.id.layout_forget) + balloon.showAsDropDown(it) + forgetFavoriteBtn.setOnClickListener { + onForgetClick(node) + balloon.dismiss() + } + } + } +} \ No newline at end of file diff --git a/components/src/main/java/dev/arkbuilders/components/folderstree/FolderNode.kt b/components/src/main/java/dev/arkbuilders/components/folderstree/FolderNode.kt new file mode 100644 index 0000000..cbb3f87 --- /dev/null +++ b/components/src/main/java/dev/arkbuilders/components/folderstree/FolderNode.kt @@ -0,0 +1,19 @@ +package dev.arkbuilders.components.folderstree + +import java.nio.file.Path + +sealed class FolderNode( + val name: String, + val path: Path, + val children: List, + var isExpanded: Boolean = false +) + +class DeviceNode(name: String, path: Path, children: List) : + FolderNode(name, path, children) + +class RootNode(name: String, path: Path, children: List) : + FolderNode(name, path, children) + +class FavoriteNode(name: String, path: Path, val root: Path) : + FolderNode(name, path, emptyList()) \ No newline at end of file diff --git a/components/src/main/java/dev/arkbuilders/components/folderstree/FoldersTreeView.kt b/components/src/main/java/dev/arkbuilders/components/folderstree/FoldersTreeView.kt new file mode 100644 index 0000000..fc63990 --- /dev/null +++ b/components/src/main/java/dev/arkbuilders/components/folderstree/FoldersTreeView.kt @@ -0,0 +1,184 @@ +package dev.arkbuilders.components.folderstree + +import android.util.Log +import androidx.recyclerview.widget.RecyclerView +import com.mikepenz.fastadapter.FastAdapter +import com.mikepenz.fastadapter.GenericItem +import com.mikepenz.fastadapter.adapters.ItemAdapter +import com.mikepenz.fastadapter.diff.FastAdapterDiffUtil +import java.nio.file.Path + +class FolderTreeView( + private val rv: RecyclerView, + private val onNavigateClick: (FolderNode) -> Unit, + private val onAddClick: (FolderNode) -> Unit, + private val onForgetClick: (FolderNode) -> Unit, + private val showOptions: Boolean +) { + private val nodeAdapter = ItemAdapter() + private var nodes = mutableListOf() + + init { + rv.adapter = FastAdapter.with(nodeAdapter) + } + + fun set(devices: List, rootsWithFavs: Map>) { + val deviceNodes = buildDeviceNodes(devices, rootsWithFavs) + if (nodes.isEmpty()) { + deviceNodes.forEachIndexed { index, node -> + if (node is DeviceNode) { + node.isExpanded = true + deviceNodes.addAll( + index + 1, + node.children + ) + } + } + } + val restoredNodes = restoreExpandedState(deviceNodes) + setNodes(restoredNodes) + } + + private fun setNodes(newNodes: MutableList) { + nodes = newNodes + val items = nodes.map { node -> + when (node) { + is DeviceNode -> DeviceFolderItem(node, ::onExpandClick) + is RootNode -> RootFolderItem( + node, + onNavigateClick, + ::onExpandClick, + onAddClick, + onForgetClick, + showOptions + ) + is FavoriteNode -> FavoriteFolderItem( + node, + onNavigateClick, + onForgetClick, + showOptions + ) + } + } + FastAdapterDiffUtil[nodeAdapter] = items + } + + private fun restoreExpandedState( + newNodes: MutableList + ): MutableList { + val tmpNodes = mutableListOf() + newNodes.forEach { newNode -> + tmpNodes.add(newNode) + restoreNode(newNode, tmpNodes) + } + return tmpNodes + } + + private fun restoreNode(node: FolderNode, tmpNodes: MutableList) { + val oldNode = nodes.find { it.path == node.path } + val pos = tmpNodes.indexOf(node) + oldNode?.let { + if (it.isExpanded) { + node.isExpanded = true + tmpNodes.addAll(pos + 1, node.children) + } + } + node.children.forEach { children -> + restoreNode(children, tmpNodes) + } + } + + private fun onExpandClick(node: FolderNode) { + val actualNode = nodes.find { it.path == node.path }!! + + if (actualNode.isExpanded) + removeChildrenCascade(actualNode) + else + insertChildren(actualNode) + + setNodes(nodes) + } + + private fun insertChildren(parent: FolderNode) { + parent.isExpanded = true + val parentPos = nodes.indexOfFirst { it.path == parent.path } + nodes.addAll(parentPos + 1, parent.children) + } + + private fun removeChildrenCascade(parent: FolderNode) { + parent.isExpanded = false + parent.children.forEach { child -> + removeChildrenCascade(child) + nodes + .find { it.path == child.path } + ?.let { nodes.remove(it) } + } + } + + private fun buildDeviceNodes( + devices: List, + rootsWithFavs: Map> + ): MutableList { + Log.d(LOG_TAG, "preparing FoldersTree to display") + Log.d(LOG_TAG, "devices = $devices") + Log.d(LOG_TAG, "folders = $rootsWithFavs") + + return rootsWithFavs + .mapKeys { (root, _) -> + val idx = devices.indexOfFirst { root.startsWith(it) } + if (idx < 0) { + throw IllegalStateException("No device contains $root") + } + + idx to root + } + .toList() + .groupBy { (deviceAndRoot, _) -> + val (device, _) = deviceAndRoot + device + }.map { (idx, folders) -> + val device = devices[idx] + + val roots = folders.map { (deviceAndRoot, _favorites) -> + val (_, root) = deviceAndRoot + + val favorites = _favorites.map { + FavoriteNode( + it.toString(), + root.resolve(it), + root + ) + } + + Log.d( + LOG_TAG, + "root $root contains favorites ${ + favorites.map { it.path } + }" + ) + RootNode( + device.relativize(root).toString(), + root, + favorites + ) + } + + Log.d( + LOG_TAG, + "device $device contains roots ${ + roots.map { it.path } + }" + ) + DeviceNode( + device.getName(1).toString(), + device, + roots + ) + } + .toMutableList() + } + + companion object { + private const val LOG_TAG = "folders-tree" + } +} \ No newline at end of file diff --git a/components/src/main/java/dev/arkbuilders/components/utils/FragmentArgsDelegate.kt b/components/src/main/java/dev/arkbuilders/components/utils/FragmentArgsDelegate.kt new file mode 100644 index 0000000..240d0a2 --- /dev/null +++ b/components/src/main/java/dev/arkbuilders/components/utils/FragmentArgsDelegate.kt @@ -0,0 +1,39 @@ +package dev.arkbuilders.components.utils + +import android.os.Bundle +import androidx.fragment.app.Fragment +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +internal fun args() = FragmentArgDelegate() + +internal class FragmentArgDelegate : ReadWriteProperty { + + override fun setValue(thisRef: Fragment, property: KProperty<*>, value: T?) { + val arguments = thisRef.arguments ?: Bundle().also(thisRef::setArguments) + val key = property.name + value?.let { arguments.put(key, it) } ?: arguments.remove(key) + } + + @Suppress("UNCHECKED_CAST") + override fun getValue(thisRef: Fragment, property: KProperty<*>): T? = + thisRef.arguments?.get(property.name) as? T +} + +internal fun Bundle.put(key: String, value: T) { + when (value) { + is Boolean -> putBoolean(key, value) + is String -> putString(key, value) + is Int -> putInt(key, value) + is Short -> putShort(key, value) + is Long -> putLong(key, value) + is Byte -> putByte(key, value) + is ByteArray -> putByteArray(key, value) + is Char -> putChar(key, value) + is CharArray -> putCharArray(key, value) + is CharSequence -> putCharSequence(key, value) + is Float -> putFloat(key, value) + is Bundle -> putBundle(key, value) + else -> throw IllegalStateException("Type of $key not supported") + } +} \ No newline at end of file diff --git a/components/src/main/java/dev/arkbuilders/components/utils/Utils.kt b/components/src/main/java/dev/arkbuilders/components/utils/Utils.kt new file mode 100644 index 0000000..424f477 --- /dev/null +++ b/components/src/main/java/dev/arkbuilders/components/utils/Utils.kt @@ -0,0 +1,57 @@ +package dev.arkbuilders.components.utils + +import android.content.Context +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import dev.arkbuilders.components.R +import java.text.DecimalFormat +import kotlin.math.pow + +internal fun View.iconForExtension(ext: String): Int { + val drawableID = this.resources + .getIdentifier( + "ark_file_picker_ic_file_$ext", + "drawable", + this.context.packageName + ) + + return if (drawableID > 0) drawableID + else R.drawable.ark_file_picker_ic_file +} + +internal fun Context.dpToPx(dp: Float): Int = + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dp, + this.resources.displayMetrics + ).toInt() + +internal fun ViewPager2.setDragSensitivity(f: Int) { + val recyclerViewField = ViewPager2::class.java.getDeclaredField("mRecyclerView") + recyclerViewField.isAccessible = true + val recyclerView = recyclerViewField.get(this) as RecyclerView + + val touchSlopField = RecyclerView::class.java.getDeclaredField("mTouchSlop") + touchSlopField.isAccessible = true + val touchSlop = touchSlopField.get(recyclerView) as Int + touchSlopField.set(recyclerView, touchSlop*f) +} + +internal fun View.setMargin(left: Int = 0, top: Int = 0, right: Int = 0, bottom: Int = 0) { + val params = layoutParams as ViewGroup.MarginLayoutParams + params.setMargins(left, top, right, bottom) + layoutParams = params +} + +internal fun Long.formatSize(): String { + if (this <= 0) { + return "0 B" + } + + val units = arrayOf("B", "kB", "MB", "GB", "TB") + val digitGroups = (Math.log10(toDouble()) / Math.log10(1024.0)).toInt() + return "${DecimalFormat("#,##0.#").format(this / 1024.0.pow(digitGroups.toDouble()))} ${units[digitGroups]}" +} \ No newline at end of file diff --git a/components/src/main/res/anim/ark_file_picker_fade_in.xml b/components/src/main/res/anim/ark_file_picker_fade_in.xml new file mode 100644 index 0000000..9dc28ae --- /dev/null +++ b/components/src/main/res/anim/ark_file_picker_fade_in.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/components/src/main/res/anim/ark_file_pickerfade_out.xml b/components/src/main/res/anim/ark_file_pickerfade_out.xml new file mode 100644 index 0000000..8bb3844 --- /dev/null +++ b/components/src/main/res/anim/ark_file_pickerfade_out.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/components/src/main/res/drawable/ark_file_picker_bg_round_16.xml b/components/src/main/res/drawable/ark_file_picker_bg_round_16.xml new file mode 100644 index 0000000..9631bc9 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_bg_round_16.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/components/src/main/res/drawable/ark_file_picker_ic_chevron.xml b/components/src/main/res/drawable/ark_file_picker_ic_chevron.xml new file mode 100644 index 0000000..f988e7d --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_chevron.xml @@ -0,0 +1,5 @@ + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_delete.xml b/components/src/main/res/drawable/ark_file_picker_ic_delete.xml new file mode 100644 index 0000000..17754e6 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_delete.xml @@ -0,0 +1,5 @@ + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file.xml b/components/src/main/res/drawable/ark_file_picker_ic_file.xml new file mode 100644 index 0000000..1c74f05 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_3gp.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_3gp.xml new file mode 100644 index 0000000..bbbe976 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_3gp.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_avi.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_avi.xml new file mode 100644 index 0000000..859c303 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_avi.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_bmp.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_bmp.xml new file mode 100644 index 0000000..4317c11 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_bmp.xml @@ -0,0 +1,39 @@ + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_doc.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_doc.xml new file mode 100644 index 0000000..1613d6a --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_doc.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_docx.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_docx.xml new file mode 100644 index 0000000..1484d8f --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_docx.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_flac.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_flac.xml new file mode 100644 index 0000000..77d9891 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_flac.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_flv.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_flv.xml new file mode 100644 index 0000000..f3a7787 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_flv.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_gif.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_gif.xml new file mode 100644 index 0000000..b34a594 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_gif.xml @@ -0,0 +1,39 @@ + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_html.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_html.xml new file mode 100644 index 0000000..cbbe942 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_html.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_jpeg.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_jpeg.xml new file mode 100644 index 0000000..42a285b --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_jpeg.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_jpg.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_jpg.xml new file mode 100644 index 0000000..66a331f --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_jpg.xml @@ -0,0 +1,39 @@ + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_mkv.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_mkv.xml new file mode 100644 index 0000000..56acd78 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_mkv.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_mov.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_mov.xml new file mode 100644 index 0000000..1c5f0d2 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_mov.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_mp3.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_mp3.xml new file mode 100644 index 0000000..966bc40 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_mp3.xml @@ -0,0 +1,36 @@ + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_mp4.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_mp4.xml new file mode 100644 index 0000000..e2b46c5 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_mp4.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_ods.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_ods.xml new file mode 100644 index 0000000..a20a97c --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_ods.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_odt.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_odt.xml new file mode 100644 index 0000000..532c181 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_odt.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_pdf.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_pdf.xml new file mode 100644 index 0000000..8f3b1be --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_pdf.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_png.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_png.xml new file mode 100644 index 0000000..f88efca --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_png.xml @@ -0,0 +1,39 @@ + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_svg.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_svg.xml new file mode 100644 index 0000000..f2ae950 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_svg.xml @@ -0,0 +1,39 @@ + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_txt.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_txt.xml new file mode 100644 index 0000000..ee097bc --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_txt.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_wav.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_wav.xml new file mode 100644 index 0000000..2f3b057 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_wav.xml @@ -0,0 +1,36 @@ + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_wma.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_wma.xml new file mode 100644 index 0000000..9db65c1 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_wma.xml @@ -0,0 +1,36 @@ + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_wmv.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_wmv.xml new file mode 100644 index 0000000..fd9d72d --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_wmv.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_xls.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_xls.xml new file mode 100644 index 0000000..e25be3e --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_xls.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_file_xlsx.xml b/components/src/main/res/drawable/ark_file_picker_ic_file_xlsx.xml new file mode 100644 index 0000000..cbfd48f --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_file_xlsx.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_folder.xml b/components/src/main/res/drawable/ark_file_picker_ic_folder.xml new file mode 100644 index 0000000..dc6b080 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_folder.xml @@ -0,0 +1,10 @@ + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_folder_action_add.xml b/components/src/main/res/drawable/ark_file_picker_ic_folder_action_add.xml new file mode 100644 index 0000000..83a9130 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_folder_action_add.xml @@ -0,0 +1,10 @@ + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_more_vert.xml b/components/src/main/res/drawable/ark_file_picker_ic_more_vert.xml new file mode 100644 index 0000000..6a7f274 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_more_vert.xml @@ -0,0 +1,5 @@ + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_sd_card.xml b/components/src/main/res/drawable/ark_file_picker_ic_sd_card.xml new file mode 100644 index 0000000..15b2fff --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_sd_card.xml @@ -0,0 +1,5 @@ + + + diff --git a/components/src/main/res/drawable/ark_file_picker_ic_star.xml b/components/src/main/res/drawable/ark_file_picker_ic_star.xml new file mode 100644 index 0000000..12a4a50 --- /dev/null +++ b/components/src/main/res/drawable/ark_file_picker_ic_star.xml @@ -0,0 +1,5 @@ + + + diff --git a/components/src/main/res/drawable/ic_delete.xml b/components/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..17754e6 --- /dev/null +++ b/components/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,5 @@ + + + diff --git a/components/src/main/res/layout/ark_file_picker_favorite_options.xml b/components/src/main/res/layout/ark_file_picker_favorite_options.xml new file mode 100644 index 0000000..76a8bb8 --- /dev/null +++ b/components/src/main/res/layout/ark_file_picker_favorite_options.xml @@ -0,0 +1,30 @@ + + + + + + + + \ No newline at end of file diff --git a/components/src/main/res/layout/ark_file_picker_fragment.xml b/components/src/main/res/layout/ark_file_picker_fragment.xml new file mode 100644 index 0000000..62aadbc --- /dev/null +++ b/components/src/main/res/layout/ark_file_picker_fragment.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/components/src/main/res/layout/ark_file_picker_host_fragment.xml b/components/src/main/res/layout/ark_file_picker_host_fragment.xml new file mode 100644 index 0000000..f8ab9fc --- /dev/null +++ b/components/src/main/res/layout/ark_file_picker_host_fragment.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/components/src/main/res/layout/ark_file_picker_item_device.xml b/components/src/main/res/layout/ark_file_picker_item_device.xml new file mode 100644 index 0000000..b423781 --- /dev/null +++ b/components/src/main/res/layout/ark_file_picker_item_device.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/components/src/main/res/layout/ark_file_picker_item_favorite.xml b/components/src/main/res/layout/ark_file_picker_item_favorite.xml new file mode 100644 index 0000000..8f845ef --- /dev/null +++ b/components/src/main/res/layout/ark_file_picker_item_favorite.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/components/src/main/res/layout/ark_file_picker_item_file.xml b/components/src/main/res/layout/ark_file_picker_item_file.xml new file mode 100644 index 0000000..69f0492 --- /dev/null +++ b/components/src/main/res/layout/ark_file_picker_item_file.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/components/src/main/res/layout/ark_file_picker_item_files_roots_page.xml b/components/src/main/res/layout/ark_file_picker_item_files_roots_page.xml new file mode 100644 index 0000000..0c787d9 --- /dev/null +++ b/components/src/main/res/layout/ark_file_picker_item_files_roots_page.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/components/src/main/res/layout/ark_file_picker_item_root.xml b/components/src/main/res/layout/ark_file_picker_item_root.xml new file mode 100644 index 0000000..e69e01d --- /dev/null +++ b/components/src/main/res/layout/ark_file_picker_item_root.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/components/src/main/res/layout/ark_file_picker_root_options.xml b/components/src/main/res/layout/ark_file_picker_root_options.xml new file mode 100644 index 0000000..860ed80 --- /dev/null +++ b/components/src/main/res/layout/ark_file_picker_root_options.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/components/src/main/res/values/colors.xml b/components/src/main/res/values/colors.xml index f8ccf6d..ff4daec 100644 --- a/components/src/main/res/values/colors.xml +++ b/components/src/main/res/values/colors.xml @@ -6,4 +6,5 @@ #FFFFFF #50717171 #ADD8E6 + #FF555555 \ No newline at end of file diff --git a/components/src/main/res/values/strings.xml b/components/src/main/res/values/strings.xml index 51aac6b..fcbe9a1 100644 --- a/components/src/main/res/values/strings.xml +++ b/components/src/main/res/values/strings.xml @@ -3,4 +3,23 @@ Label some resources with tags to enable tags selector Filter Invert + Internal + Cancel + Pick + Pick + Access Denied + [device] + [favorite] + [root] + Add favorite + Forget root + Forget favorite + + %d item + %d items + + + Roots + Files + \ No newline at end of file diff --git a/components/src/main/res/values/styles.xml b/components/src/main/res/values/styles.xml index 55344e5..ecef88f 100644 --- a/components/src/main/res/values/styles.xml +++ b/components/src/main/res/values/styles.xml @@ -1,3 +1,7 @@ + \ No newline at end of file diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 176b32d..7242147 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -33,9 +33,14 @@ android { kotlinOptions { jvmTarget = "1.8" } + buildFeatures { + buildConfig = true + } } dependencies { + implementation(project(":components")) + implementation("dev.arkbuilders:arklib:0.3.5") implementation("androidx.core:core-ktx:1.9.0") implementation("androidx.appcompat:appcompat:1.6.1") diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index b987047..8bdb844 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -2,7 +2,17 @@ + + + + + tools:targetApi="31" > + + + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/java/dev/arkbuilders/sample/App.kt b/sample/src/main/java/dev/arkbuilders/sample/App.kt new file mode 100644 index 0000000..ec5ac32 --- /dev/null +++ b/sample/src/main/java/dev/arkbuilders/sample/App.kt @@ -0,0 +1,17 @@ +package dev.arkbuilders.sample + +import android.app.Application +import dev.arkbuilders.arklib.data.folders.FoldersRepo +import dev.arkbuilders.arklib.initArkLib +import dev.arkbuilders.arklib.initRustLogger + +class App: Application() { + + override fun onCreate() { + super.onCreate() + FoldersRepo.init(this) + System.loadLibrary("arklib") + initArkLib() + initRustLogger() + } +} \ No newline at end of file diff --git a/sample/src/main/java/dev/arkbuilders/sample/MainActivity.kt b/sample/src/main/java/dev/arkbuilders/sample/MainActivity.kt new file mode 100644 index 0000000..a4b39fb --- /dev/null +++ b/sample/src/main/java/dev/arkbuilders/sample/MainActivity.kt @@ -0,0 +1,91 @@ +package dev.arkbuilders.sample + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.provider.Settings +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.google.android.material.button.MaterialButton +import dev.arkbuilders.components.filepicker.ArkFilePickerConfig +import dev.arkbuilders.components.filepicker.ArkFilePickerFragment +import dev.arkbuilders.components.filepicker.ArkFilePickerMode +import dev.arkbuilders.components.filepicker.onArkPathPicked + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + resolvePermissions() + + supportFragmentManager.onArkPathPicked(this) { path -> + Toast.makeText(this, "Path picked $path", Toast.LENGTH_SHORT).show() + } + + findViewById(R.id.btn_open).setOnClickListener { + resolvePermissions() + ArkFilePickerFragment + .newInstance(getFilePickerConfig()) + .show(supportFragmentManager, null) + } + + findViewById(R.id.btn_root_picker).setOnClickListener { + resolvePermissions() + RootFavPickerDialog + .newInstance() + .show(supportFragmentManager, null) + } + } + + private fun getFilePickerConfig() = ArkFilePickerConfig( + mode = ArkFilePickerMode.FOLDER, + titleStringId = R.string.file_picker_title, + showRoots = true, + rootsFirstPage = false + ) + + private fun resolvePermissions() { + if (!isReadPermGranted()) askReadPermissions() + } + + private fun askReadPermissions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val packageUri = + Uri.parse("package:" + BuildConfig.APPLICATION_ID) + val intent = + Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + packageUri + ) + startActivityForResult(intent, REQUEST_CODE_ALL_FILES_ACCESS) + } else { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), + REQUEST_CODE_PERMISSIONS + ) + } + } + + private fun isReadPermGranted(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + ContextCompat.checkSelfPermission( + this, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED + } + } + + companion object { + private const val REQUEST_CODE_ALL_FILES_ACCESS = 0 + private const val REQUEST_CODE_PERMISSIONS = 1 + } +} \ No newline at end of file diff --git a/sample/src/main/java/dev/arkbuilders/sample/RootFavPickerDialog.kt b/sample/src/main/java/dev/arkbuilders/sample/RootFavPickerDialog.kt new file mode 100644 index 0000000..bea52c3 --- /dev/null +++ b/sample/src/main/java/dev/arkbuilders/sample/RootFavPickerDialog.kt @@ -0,0 +1,58 @@ +package dev.arkbuilders.sample + +import android.widget.Toast +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import dev.arkbuilders.arklib.data.folders.FoldersRepo +import dev.arkbuilders.components.filepicker.ArkFilePickerConfig +import dev.arkbuilders.components.filepicker.ArkFilePickerFragment +import java.nio.file.Path + +class RootFavPickerDialog : ArkFilePickerFragment() { + var rootNotFavorite = false + + override fun onFolderChanged(currentFolder: Path) { + lifecycleScope.launch { + val folders = FoldersRepo.instance.provideFolders() + val roots = folders.keys + val favorites = folders.values.flatten() + val root = roots.find { root -> currentFolder.startsWith(root) } + root?.let { + if (root == currentFolder) { + rootNotFavorite = true + binding.btnPick.text = "Root" + binding.btnPick.isEnabled = false + } else { + var foundAsFavorite = false + favorites.forEach { + if (currentFolder.endsWith(it)) { + foundAsFavorite = true + return@forEach + } + } + rootNotFavorite = false + binding.btnPick.text = "Favorite" + binding.btnPick.isEnabled = !foundAsFavorite + } + } ?: let { + rootNotFavorite = true + binding.btnPick.text = "Root" + binding.btnPick.isEnabled = true + } + } + } + + override fun onPick(pickedPath: Path) { + Toast.makeText( + requireContext(), + "rootNotFavorite [$rootNotFavorite]", + Toast.LENGTH_SHORT + ).show() + } + + companion object { + fun newInstance() = RootFavPickerDialog().apply { + setConfig(ArkFilePickerConfig()) + } + } +} \ No newline at end of file diff --git a/sample/src/main/res/layout/activity_main.xml b/sample/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..a11ce7a --- /dev/null +++ b/sample/src/main/res/layout/activity_main.xml @@ -0,0 +1,27 @@ + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index fbedf44..4f10c20 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ Ark Components + Pick favorite folder \ No newline at end of file