..
This commit is contained in:
parent
406e7820bc
commit
3f0a7d0d5c
@ -55,6 +55,36 @@ port.onMessage.addListener(response => {
|
||||
var type= response["type"];
|
||||
|
||||
switch (type) {
|
||||
case "CLICK_PREV_CHAPTER" : {
|
||||
const links = document.querySelectorAll("a");
|
||||
let found = false;
|
||||
for (let link of links) {
|
||||
if (link.textContent.includes("이전화 보기")) {
|
||||
link.click();
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
port.postMessage({ type: "MSG", msg: "이전화 버튼을 찾을 수 없습니다." });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "CLICK_NEXT_CHAPTER" : {
|
||||
const links = document.querySelectorAll("a");
|
||||
let found = false;
|
||||
for (let link of links) {
|
||||
if (link.textContent.includes("다음화 보기")) {
|
||||
link.click();
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
port.postMessage({ type: "MSG", msg: "다음화 버튼을 찾을 수 없습니다." });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "fetchAllImages": {
|
||||
const targetSrc = response["targetSrc"];
|
||||
const imageUrls = findAllRelatedImages(targetSrc);
|
||||
|
||||
@ -3,10 +3,10 @@ package bums.lunatic.launcher.apps
|
||||
import android.app.Dialog
|
||||
import android.app.SearchManager
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@ -21,23 +21,17 @@ import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import bums.lunatic.launcher.BuildConfig
|
||||
import bums.lunatic.launcher.R
|
||||
import bums.lunatic.launcher.apps.AppMenu
|
||||
import bums.lunatic.launcher.databinding.BottomSheetAppDrawerBinding // XML 이름에 맞춰 바인딩 클래스 생성됨
|
||||
import bums.lunatic.launcher.helpers.PrefBoolean
|
||||
import bums.lunatic.launcher.helpers.PrefLong
|
||||
import bums.lunatic.launcher.databinding.BottomSheetAppDrawerBinding
|
||||
import bums.lunatic.launcher.model.AppInfo
|
||||
import bums.lunatic.launcher.model.SimpleContact
|
||||
import bums.lunatic.launcher.utils.Blog
|
||||
import bums.lunatic.launcher.utils.CategoryGrouper
|
||||
import bums.lunatic.launcher.utils.CategoryManualMapper
|
||||
import bums.lunatic.launcher.utils.EnToKo
|
||||
import bums.lunatic.launcher.utils.JamoUtils
|
||||
import bums.lunatic.launcher.utils.SimpleTransliterater
|
||||
import bums.lunatic.launcher.workers.UsageLogType
|
||||
import bums.lunatic.launcher.workers.UsageUpdateType
|
||||
import bums.lunatic.launcher.workers.WorkersDb
|
||||
import com.google.android.gms.common.wrappers.PackageManagerWrapper
|
||||
import com.google.android.gms.common.wrappers.Wrappers.packageManager
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
@ -47,7 +41,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.URLEncoder
|
||||
import kotlin.math.min
|
||||
|
||||
class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
|
||||
@ -252,23 +245,31 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
filterAppsList(keyword)
|
||||
|
||||
}
|
||||
|
||||
|
||||
binding.searchTaxi.setOnClickListener {
|
||||
//// val destName = Uri.encode("서울역")
|
||||
//// val url = "kakaot://taxi?dest_lat=${URLEncoder.encode("37.467696")}&dest_lng=${URLEncoder.encode("127.101063")}&dest_name=세곡동557"
|
||||
// val url = "kakaomap://search?q=세곡동 557"
|
||||
// val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
// startActivity(intent)
|
||||
openSearchApps("kakaot://taxi?goalx=${URLEncoder.encode("37.467696")}&goaly=${URLEncoder.encode("127.101063")}","com.kakao.taxi")
|
||||
}
|
||||
binding.searchMusic.setOnClickListener {
|
||||
try {
|
||||
// 스포티파이는 별도의 URL 인코딩 없이도 잘 동작하지만, 안전하게 인코딩 추천
|
||||
val encodedKeyword = URLEncoder.encode(keyword, "UTF-8")
|
||||
val uri = Uri.parse("spotify:search:$encodedKeyword")
|
||||
val intent: Intent = Intent(MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH)
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri)
|
||||
// 명시적으로 스포티파이 앱 지정 (웹 브라우저로 빠지는 것 방지)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
intent.putExtra(MediaStore.EXTRA_MEDIA_FOCUS, "vnd.android.cursor.item/*")
|
||||
intent.putExtra(SearchManager.QUERY, keyword) // 검색하고자 하는 곡명이나 아티스트명
|
||||
|
||||
requireContext().startActivity(intent)
|
||||
intent.setPackage("com.apple.android.music") // 애플 뮤직 패키지 명시
|
||||
|
||||
if (intent.resolveActivity(requireContext().getPackageManager()) != null) {
|
||||
startActivity(intent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 앱이 없을 경우 플레이스토어로 이동
|
||||
val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.spotify.music"))
|
||||
val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.apple.android.music"))
|
||||
marketIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
requireContext().startActivity(marketIntent)
|
||||
}
|
||||
@ -298,7 +299,7 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
|
||||
binding.searchNaver.setOnClickListener {
|
||||
openSearchApps("https://search.naver.com/search.naver?where=nexearch&query=${keyword}", "com.nhn.android.search")
|
||||
}
|
||||
// ... 나머지 버튼들도 동일하게 추가 ...
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ package bums.lunatic.launcher.home
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
@ -22,7 +23,6 @@ import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import bums.lunatic.launcher.R
|
||||
import bums.lunatic.launcher.utils.NaturalOrderComparator
|
||||
import com.bumptech.glide.Glide
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -34,6 +34,36 @@ enum class FileFilterType(val label : String) { ALL("전체"), IMAGE("이미지"
|
||||
enum class FileSortType(val label : String) { NAME("파일명") , DOWNLOAD_DATE("다운로드"), LAST_USED("최근 사용"), FREQUENTLY_USED("자주 사용"), SIZE("용량") }
|
||||
enum class FileViewMode { LIST_TEXT, LIST_THUMB, GRID_LARGE, GRID_SMALL }
|
||||
|
||||
// 💡 숫자 크기를 인식하는 정렬 비교자
|
||||
class NaturalOrderComparator : Comparator<File> {
|
||||
override fun compare(f1: File, f2: File): Int {
|
||||
val splitRegex = Regex("(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)")
|
||||
val parts1 = f1.name.split(splitRegex)
|
||||
val parts2 = f2.name.split(splitRegex)
|
||||
|
||||
val limit = minOf(parts1.size, parts2.size)
|
||||
for (i in 0 until limit) {
|
||||
val p1 = parts1[i]
|
||||
val p2 = parts2[i]
|
||||
if (p1 == p2) continue
|
||||
|
||||
val isNum1 = p1.all { it.isDigit() }
|
||||
val isNum2 = p2.all { it.isDigit() }
|
||||
|
||||
if (isNum1 && isNum2) {
|
||||
val num1 = p1.trimStart('0')
|
||||
val num2 = p2.trimStart('0')
|
||||
val cmp = if (num1.length != num2.length) num1.length.compareTo(num2.length) else num1.compareTo(num2)
|
||||
if (cmp != 0) return cmp
|
||||
} else {
|
||||
val strCmp = p1.compareTo(p2, ignoreCase = true)
|
||||
if (strCmp != 0) return strCmp
|
||||
}
|
||||
}
|
||||
return parts1.size.compareTo(parts2.size)
|
||||
}
|
||||
}
|
||||
|
||||
class CompletedFilesFragment : Fragment() {
|
||||
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
@ -48,12 +78,16 @@ class CompletedFilesFragment : Fragment() {
|
||||
private var currentViewMode = FileViewMode.LIST_TEXT
|
||||
private var isDescending = true
|
||||
|
||||
// 💡 멀티 선택 모드 상태 관리 변수
|
||||
private var isSelectionMode = false
|
||||
private val selectedFiles = mutableSetOf<File>()
|
||||
|
||||
private val extImages = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
|
||||
private val extVideos = setOf("mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "ts")
|
||||
private val extDocs = setOf("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "hwp")
|
||||
|
||||
private lateinit var backPressedCallback: OnBackPressedCallback
|
||||
|
||||
fun backPress() = backPressedCallback.handleOnBackPressed()
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
@ -70,7 +104,8 @@ class CompletedFilesFragment : Fragment() {
|
||||
|
||||
backPressedCallback = object : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
navigateUp()
|
||||
// 선택 모드일 땐 선택 모드 먼저 해제, 아닐 땐 상위 폴더 이동
|
||||
if (isSelectionMode) toggleSelectionMode(false) else navigateUp()
|
||||
}
|
||||
}
|
||||
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backPressedCallback)
|
||||
@ -80,40 +115,61 @@ class CompletedFilesFragment : Fragment() {
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
// 💡 선택 모드 진입/해제 UI 토글
|
||||
private fun toggleSelectionMode(enable: Boolean) {
|
||||
isSelectionMode = enable
|
||||
if (!enable) selectedFiles.clear()
|
||||
|
||||
adapter.updateSelection(selectedFiles)
|
||||
|
||||
// 추가한 XML 뷰 보이기/숨기기
|
||||
view?.findViewById<View>(R.id.layoutSelectionActions)?.visibility = if (enable) View.VISIBLE else View.GONE
|
||||
|
||||
// 뒤로가기 버튼 활성화 기준 업데이트
|
||||
backPressedCallback.isEnabled = isSelectionMode || currentDir.absolutePath != rootDir.absolutePath
|
||||
}
|
||||
|
||||
private fun setupRecyclerView(view: View) {
|
||||
recyclerView = view.findViewById(R.id.recyclerViewCompletedFiles)
|
||||
updateRecyclerViewLayoutManager()
|
||||
|
||||
// 💡 어댑터 생성 시 getAccessCount 함수를 넘겨주어 어댑터 내부에서 조회수를 그릴 수 있게 합니다.
|
||||
adapter = CompletedFilesAdapter(
|
||||
onItemClick = { file ->
|
||||
if (file.name == "..") {
|
||||
navigateUp()
|
||||
} else if (file.isDirectory) {
|
||||
currentDir = file
|
||||
loadFiles()
|
||||
} else {
|
||||
openPrivateFile(requireContext(), file)
|
||||
if (!isSelectionMode) navigateUp()
|
||||
return@CompletedFilesAdapter
|
||||
}
|
||||
|
||||
// 💡 멀티 선택 모드일 때의 클릭 로직
|
||||
if (isSelectionMode) {
|
||||
if (selectedFiles.contains(file)) selectedFiles.remove(file) else selectedFiles.add(file)
|
||||
|
||||
if (selectedFiles.isEmpty()) toggleSelectionMode(false)
|
||||
else adapter.updateSelection(selectedFiles)
|
||||
}
|
||||
// 일반 모드일 때의 클릭 로직
|
||||
else {
|
||||
if (file.isDirectory) {
|
||||
currentDir = file
|
||||
loadFiles()
|
||||
} else {
|
||||
openPrivateFile(requireContext(), file)
|
||||
}
|
||||
}
|
||||
},
|
||||
onItemLongClick = { file ->
|
||||
val type = if (file.isDirectory) "폴더" else "파일"
|
||||
android.app.AlertDialog.Builder(context)
|
||||
.setTitle("$type 삭제")
|
||||
.setMessage("해당 ${type}를 삭제하시겠습니까?\n(폴더일 경우 내부 파일도 모두 삭제됩니다)")
|
||||
.setPositiveButton("확인") { _, _ ->
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val success = if (file.isDirectory) file.deleteRecursively() else file.delete()
|
||||
if (success) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(requireContext(), "삭제되었습니다.", Toast.LENGTH_SHORT).show()
|
||||
loadFiles()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton("취소", null)
|
||||
.show()
|
||||
if (file.name == "..") return@CompletedFilesAdapter
|
||||
|
||||
// 💡 폴더를 길게 누르면 -> 폴더 상세 삭제 다이얼로그 호출
|
||||
if (file.isDirectory && !isSelectionMode) {
|
||||
showFolderDeleteDialog(file)
|
||||
}
|
||||
// 💡 파일을 길게 누르면 -> 멀티 선택 모드 시작
|
||||
else if (!isSelectionMode) {
|
||||
toggleSelectionMode(true)
|
||||
selectedFiles.add(file)
|
||||
adapter.updateSelection(selectedFiles)
|
||||
}
|
||||
},
|
||||
getAccessCount = { fileName -> getAccessCount(fileName) }
|
||||
)
|
||||
@ -121,13 +177,223 @@ class CompletedFilesFragment : Fragment() {
|
||||
recyclerView.adapter = adapter
|
||||
}
|
||||
|
||||
private fun navigateUp() {
|
||||
if (currentDir.absolutePath != rootDir.absolutePath) {
|
||||
currentDir = currentDir.parentFile ?: rootDir
|
||||
loadFiles()
|
||||
// 💡 폴더 내부 확인 및 개별 삭제 다이얼로그
|
||||
private fun showFolderDeleteDialog(folder: File) {
|
||||
val files = folder.listFiles()?.filter { it.isFile }?.sortedWith(NaturalOrderComparator()) ?: emptyList()
|
||||
|
||||
if (files.isEmpty()) {
|
||||
android.app.AlertDialog.Builder(requireContext())
|
||||
.setTitle("폴더 삭제")
|
||||
.setMessage("빈 폴더입니다. 삭제하시겠습니까?")
|
||||
.setPositiveButton("확인") { _, _ ->
|
||||
folder.delete()
|
||||
Toast.makeText(context, "폴더가 삭제되었습니다.", Toast.LENGTH_SHORT).show()
|
||||
loadFiles()
|
||||
}
|
||||
.setNegativeButton("취소", null)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
val fileNames = files.map { file ->
|
||||
val sizeMb = file.length() / (1024.0 * 1024.0)
|
||||
val accessCount = getAccessCount(file.name)
|
||||
String.format("%s\n(%.2f MB, 조회 %d회)", file.name, sizeMb, accessCount)
|
||||
}.toTypedArray()
|
||||
|
||||
val checkedItems = BooleanArray(files.size) { false }
|
||||
|
||||
android.app.AlertDialog.Builder(requireContext())
|
||||
.setTitle("📁 ${folder.name} 정리")
|
||||
.setMultiChoiceItems(fileNames, checkedItems) { _, which, isChecked ->
|
||||
checkedItems[which] = isChecked
|
||||
}
|
||||
.setPositiveButton("선택 항목 삭제") { _, _ ->
|
||||
var deletedCount = 0
|
||||
files.forEachIndexed { index, file ->
|
||||
if (checkedItems[index] && file.delete()) {
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
Toast.makeText(context, "${deletedCount}개의 파일이 삭제되었습니다.", Toast.LENGTH_SHORT).show()
|
||||
loadFiles()
|
||||
}
|
||||
.setNeutralButton("폴더 전체 삭제") { _, _ ->
|
||||
// 💡 여기서도 다이얼로그 안에서 다이얼로그를 부릅니다! (안전한 패턴)
|
||||
android.app.AlertDialog.Builder(requireContext())
|
||||
.setMessage("정말로 폴더와 내부의 모든 파일을 삭제하시겠습니까?")
|
||||
.setPositiveButton("예") { _, _ ->
|
||||
folder.deleteRecursively()
|
||||
loadFiles()
|
||||
}
|
||||
.setNegativeButton("아니오", null)
|
||||
.show()
|
||||
}
|
||||
// 💡 기존의 '취소' 자리를 '선택 이동'으로 사용
|
||||
.setNegativeButton("선택 항목 이동") { _, _ ->
|
||||
// 체크된 파일들만 필터링해서 리스트로 만듦
|
||||
val selectedFilesToMove = files.filterIndexed { index, _ -> checkedItems[index] }
|
||||
|
||||
if (selectedFilesToMove.isNotEmpty()) {
|
||||
// 두 번째 다이얼로그(이동 창) 호출
|
||||
showMoveDialogForFiles(selectedFilesToMove)
|
||||
} else {
|
||||
Toast.makeText(context, "선택된 항목이 없습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
.setCancelable(true) // 빈 화면을 터치하거나 뒤로가기 키를 누르면 다이얼로그가 취소(닫힘)됨
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showUnorganizedFilesDialog(filterType: FileFilterType) {
|
||||
// 사용자가 제외하고 싶어 하는 타겟 목록
|
||||
val excludedDirs = listOf(rootDir.name, "이미지", "비디오", "문서", "기타")
|
||||
val unorganizedFiles = mutableListOf<File>()
|
||||
|
||||
rootDir.walkTopDown().onEnter { dir ->
|
||||
// 1. 루트 폴더 자체는 통과해야 하위 폴더를 검사할 수 있음
|
||||
if (dir == rootDir) return@onEnter true
|
||||
|
||||
// 2. 루트 바로 아래에 있는 '이미지, 비디오, 문서, 기타' 폴더는 검사 진입 거부!
|
||||
if (dir.parentFile == rootDir && excludedDirs.contains(dir.name)) {
|
||||
return@onEnter false
|
||||
}
|
||||
true
|
||||
}.forEach { file ->
|
||||
if (file.isFile) {
|
||||
// 3. 루트 폴더 바로 아래에 널브러진 파일도 제외하고 싶다면 (rootDir.name 이 target에 있으므로)
|
||||
if (file.parentFile == rootDir && excludedDirs.contains(rootDir.name)) return@forEach
|
||||
|
||||
val ext = file.extension.lowercase()
|
||||
val isMatch = when (filterType) {
|
||||
FileFilterType.ALL -> true
|
||||
FileFilterType.IMAGE -> extImages.contains(ext)
|
||||
FileFilterType.VIDEO -> extVideos.contains(ext)
|
||||
FileFilterType.DOCUMENT -> extDocs.contains(ext)
|
||||
FileFilterType.OTHER -> !extImages.contains(ext) && !extVideos.contains(ext) && !extDocs.contains(ext)
|
||||
}
|
||||
if (isMatch) unorganizedFiles.add(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (unorganizedFiles.isEmpty()) {
|
||||
Toast.makeText(context, "정리할 ${filterType.label} 파일이 없습니다.", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
// 파일 정렬 (내추럴 정렬 사용)
|
||||
val sortedFiles = unorganizedFiles.sortedWith(NaturalOrderComparator())
|
||||
val checkedItems = BooleanArray(sortedFiles.size) { false }
|
||||
|
||||
// 리스트에 보여줄 텍스트 가공 (어느 폴더에 있던 파일인지 알기 쉽게 [폴더명] 표시)
|
||||
val fileNames = sortedFiles.map { file ->
|
||||
val sizeMb = file.length() / (1024.0 * 1024.0)
|
||||
"📁 [${file.parentFile?.name}]\n📄 ${file.name} (%.2f MB)".format(sizeMb)
|
||||
}.toTypedArray()
|
||||
|
||||
android.app.AlertDialog.Builder(requireContext())
|
||||
.setTitle("흩어진 ${filterType.label} 파일 모아보기")
|
||||
.setMultiChoiceItems(fileNames, checkedItems) { _, which, isChecked ->
|
||||
checkedItems[which] = isChecked
|
||||
}
|
||||
.setPositiveButton("선택 항목 삭제") { _, _ ->
|
||||
val filesToDelete = sortedFiles.filterIndexed { index, _ -> checkedItems[index] }
|
||||
if (filesToDelete.isEmpty()) return@setPositiveButton
|
||||
|
||||
var delCount = 0
|
||||
filesToDelete.forEach { if (it.delete()) delCount++ }
|
||||
Toast.makeText(context, "${delCount}개의 파일이 삭제되었습니다.", Toast.LENGTH_SHORT).show()
|
||||
loadFiles()
|
||||
}
|
||||
.setNegativeButton("선택 항목 이동") { _, _ ->
|
||||
val filesToMove = sortedFiles.filterIndexed { index, _ -> checkedItems[index] }
|
||||
if (filesToMove.isNotEmpty()) {
|
||||
// 이전 답변에서 만든 다이얼로그 재활용!
|
||||
showMoveDialogForFiles(filesToMove)
|
||||
} else {
|
||||
Toast.makeText(context, "선택된 항목이 없습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
.setNeutralButton("취소", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
|
||||
private fun showMoveDialogForFiles(filesToMove: List<File>) {
|
||||
// 이동 가능한 디렉토리 목록 수집 (현재 폴더는 뺄 필요가 있다면 필터링 로직 추가 가능)
|
||||
var target = arrayListOf<String>(rootDir.name,"이미지",
|
||||
"비디오",
|
||||
"문서",
|
||||
"기타")
|
||||
val folders = rootDir.listFiles()?.filter { it.isDirectory && target.contains(it.name) }?.toMutableList() ?: mutableListOf()
|
||||
|
||||
// 루트 폴더로 꺼내는 옵션도 추가
|
||||
folders.add(0, rootDir)
|
||||
|
||||
if (folders.isEmpty()) {
|
||||
Toast.makeText(context, "이동할 대상 폴더가 없습니다.", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
val folderNames = folders.map {
|
||||
if (it.absolutePath == rootDir.absolutePath) "📁 [최상위 폴더로 꺼내기]" else "📁 ${it.name}"
|
||||
}.toTypedArray()
|
||||
|
||||
android.app.AlertDialog.Builder(requireContext())
|
||||
.setTitle("${filesToMove.size}개 항목 이동 대상 선택")
|
||||
.setItems(folderNames) { _, which ->
|
||||
val targetFolder = folders[which]
|
||||
var moveCount = 0
|
||||
|
||||
filesToMove.forEach { file ->
|
||||
val targetFile = File(targetFolder, file.name)
|
||||
// 이름이 겹치지 않고 이동에 성공했다면 카운트 증가
|
||||
if (file.renameTo(targetFile)) {
|
||||
moveCount++
|
||||
}
|
||||
}
|
||||
|
||||
Toast.makeText(context, "${moveCount}개 항목 이동 완료", Toast.LENGTH_SHORT).show()
|
||||
loadFiles()
|
||||
}
|
||||
.setNegativeButton("취소", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
// 💡 선택한 파일 이동 다이얼로그
|
||||
private fun showMoveDialog() {
|
||||
if (selectedFiles.isEmpty()) return
|
||||
|
||||
// 이동 가능한 디렉토리 목록 수집 (루트 폴더 + 자식 폴더들)
|
||||
val folders = rootDir.listFiles()?.filter { it.isDirectory }?.toMutableList() ?: mutableListOf()
|
||||
if (currentDir.absolutePath != rootDir.absolutePath) {
|
||||
folders.add(0, rootDir) // 하위 폴더일 경우 루트로 뺄 수 있게 옵션 추가
|
||||
}
|
||||
folders.removeAll { it.absolutePath == currentDir.absolutePath } // 현재 폴더는 타겟에서 제외
|
||||
|
||||
if (folders.isEmpty()) {
|
||||
Toast.makeText(context, "이동할 대상 폴더가 없습니다.", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
val folderNames = folders.map { if (it.absolutePath == rootDir.absolutePath) "📁 [최상위 폴더로 꺼내기]" else "📁 ${it.name}" }.toTypedArray()
|
||||
|
||||
android.app.AlertDialog.Builder(requireContext())
|
||||
.setTitle("${selectedFiles.size}개 항목 이동 대상 선택")
|
||||
.setItems(folderNames) { _, which ->
|
||||
val targetFolder = folders[which]
|
||||
var moveCount = 0
|
||||
selectedFiles.forEach { file ->
|
||||
val targetFile = File(targetFolder, file.name)
|
||||
if (file.renameTo(targetFile)) moveCount++
|
||||
}
|
||||
Toast.makeText(context, "${moveCount}개 항목 이동 완료", Toast.LENGTH_SHORT).show()
|
||||
toggleSelectionMode(false)
|
||||
loadFiles()
|
||||
}
|
||||
.setNegativeButton("취소", null)
|
||||
.show()
|
||||
}
|
||||
private fun getViewModeSymbolName(mode: FileViewMode): String {
|
||||
return when (mode) {
|
||||
FileViewMode.LIST_TEXT -> "view_headline"
|
||||
@ -136,19 +402,36 @@ class CompletedFilesFragment : Fragment() {
|
||||
FileViewMode.GRID_SMALL -> "apps"
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupControls(view: View) {
|
||||
val spinnerFilter = view.findViewById<Spinner>(R.id.spinnerFilter)
|
||||
val spinnerSort = view.findViewById<Spinner>(R.id.spinnerSort)
|
||||
val tvSortOrder = view.findViewById<TextView>(R.id.tvSortOrder)
|
||||
val btnViewMode = view.findViewById<TextView>(R.id.btnViewMode)
|
||||
|
||||
// 💡 XML에 추가된 자동 정리 버튼 연동 (ID를 btnOrganize로 만들었다고 가정)
|
||||
val btnOrganize = view.findViewById<TextView>(R.id.btnOrganize)
|
||||
btnOrganize?.setOnClickListener {
|
||||
organizeRootFiles()
|
||||
|
||||
// 💡 선택 액션 바 버튼 이벤트
|
||||
view.findViewById<View>(R.id.btnCancelSelection)?.setOnClickListener { toggleSelectionMode(false) }
|
||||
view.findViewById<View>(R.id.btnMoveSelected)?.setOnClickListener { showMoveDialog() }
|
||||
view.findViewById<View>(R.id.btnDeleteSelected)?.setOnClickListener {
|
||||
if (selectedFiles.isEmpty()) return@setOnClickListener
|
||||
android.app.AlertDialog.Builder(requireContext())
|
||||
.setMessage("선택한 ${selectedFiles.size}개 항목을 삭제하시겠습니까?")
|
||||
.setPositiveButton("삭제") { _, _ ->
|
||||
var delCount = 0
|
||||
selectedFiles.forEach { file ->
|
||||
if (file.isDirectory) { file.deleteRecursively(); delCount++ }
|
||||
else if (file.delete()) { delCount++ }
|
||||
}
|
||||
Toast.makeText(context, "${delCount}개 항목 삭제됨", Toast.LENGTH_SHORT).show()
|
||||
toggleSelectionMode(false)
|
||||
loadFiles()
|
||||
}
|
||||
.setNegativeButton("취소", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
btnOrganize?.setOnClickListener { organizeRootFiles() }
|
||||
|
||||
btnViewMode.text = getViewModeSymbolName(currentViewMode)
|
||||
btnViewMode.setOnClickListener {
|
||||
currentViewMode = when (currentViewMode) {
|
||||
@ -163,10 +446,29 @@ class CompletedFilesFragment : Fragment() {
|
||||
}
|
||||
|
||||
val filterOptions = FileFilterType.values().map { it.label }
|
||||
spinnerFilter.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, filterOptions).apply {
|
||||
setDropDownViewResource(R.layout.spinner_item_dark)
|
||||
}
|
||||
// spinnerFilter.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, filterOptions)
|
||||
spinnerFilter.adapter = object : ArrayAdapter<String>(requireContext(), android.R.layout.simple_spinner_dropdown_item, filterOptions) {
|
||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = super.getDropDownView(position, convertView, parent)
|
||||
|
||||
// 💡 드롭다운 안의 아이템에 롱클릭 리스너 장착
|
||||
view.setOnLongClickListener {
|
||||
val selectedFilterType = FileFilterType.values()[position]
|
||||
|
||||
// 롱클릭 시 방금 만든 다이얼로그 호출!
|
||||
showUnorganizedFilesDialog(selectedFilterType)
|
||||
|
||||
// 다이얼로그가 뜨면 뒤에 열려있는 스피너 드롭다운 목록을 닫기 위해
|
||||
// 시스템 뒤로가기 키 이벤트를 가짜로 발생시킵니다.
|
||||
val root = spinnerFilter.rootView
|
||||
root.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK))
|
||||
root.dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_BACK))
|
||||
|
||||
true // true를 반환해야 일반 클릭(필터 적용)이 무시됩니다.
|
||||
}
|
||||
return view
|
||||
}
|
||||
}
|
||||
spinnerFilter.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) {
|
||||
currentFilter = FileFilterType.values()[position]
|
||||
@ -175,11 +477,8 @@ class CompletedFilesFragment : Fragment() {
|
||||
override fun onNothingSelected(p0: AdapterView<*>?) {}
|
||||
}
|
||||
|
||||
|
||||
val sortOptions = FileSortType.values().map { it.label }
|
||||
spinnerSort.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, sortOptions).apply {
|
||||
setDropDownViewResource(R.layout.spinner_item_dark)
|
||||
}
|
||||
spinnerSort.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, sortOptions)
|
||||
spinnerSort.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) {
|
||||
currentSort = FileSortType.values()[position]
|
||||
@ -196,17 +495,13 @@ class CompletedFilesFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
// 💡 루트 경로에 있는 파일들을 각각의 폴더로 이동시키는 자동 정리 함수
|
||||
private fun organizeRootFiles() {
|
||||
val filesInRoot = rootDir.listFiles()?.filter { it.isFile } ?: emptyList()
|
||||
|
||||
if (filesInRoot.isEmpty()) {
|
||||
Toast.makeText(requireContext(), "루트 폴더에 정리할 파일이 없습니다.", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
var movedCount = 0
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
// 1. 루트 폴더의 파일 목록 가져오기
|
||||
val filesInRoot = rootDir.listFiles()?.filter { it.isFile } ?: emptyList()
|
||||
var movedCount = 0
|
||||
|
||||
// 2. 널브러진 파일들을 확장자별 폴더로 이동
|
||||
filesInRoot.forEach { file ->
|
||||
val ext = file.extension.lowercase()
|
||||
val folderName = when {
|
||||
@ -215,38 +510,59 @@ class CompletedFilesFragment : Fragment() {
|
||||
extDocs.contains(ext) -> "문서"
|
||||
else -> "기타"
|
||||
}
|
||||
|
||||
val targetDir = File(rootDir, folderName)
|
||||
if (!targetDir.exists()) targetDir.mkdirs()
|
||||
|
||||
val targetFile = File(targetDir, file.name)
|
||||
// 동일한 이름의 파일이 이미 있다면 덮어쓰거나 이동 실패할 수 있으므로 주의 (필요시 중복 네이밍 처리)
|
||||
if (file.renameTo(targetFile)) {
|
||||
if (file.renameTo(File(targetDir, file.name))) {
|
||||
movedCount++
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (movedCount > 0) {
|
||||
Toast.makeText(requireContext(), "${movedCount}개의 파일을 자동 정리했습니다.", Toast.LENGTH_SHORT).show()
|
||||
// 만약 사용자가 지금 루트 폴더를 보고 있다면 리스트를 즉시 갱신
|
||||
if (currentDir.absolutePath == rootDir.absolutePath) {
|
||||
loadFiles()
|
||||
// 💡 3. 빈 폴더 싹쓸이 기능 추가 (Bottom-Up 방식)
|
||||
var deletedFolderCount = 0
|
||||
|
||||
// walkBottomUp()을 사용하면 가장 깊은 하위 폴더부터 위로 올라오면서 검사합니다.
|
||||
rootDir.walkBottomUp().forEach { dir ->
|
||||
// 최상위 루트 폴더 자체는 지우면 안 되므로 통과
|
||||
if (dir != rootDir && dir.isDirectory) {
|
||||
// 폴더 내부에 아무것도 없다면 삭제
|
||||
if (dir.listFiles()?.isEmpty() == true) {
|
||||
if (dir.delete()) {
|
||||
deletedFolderCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 메인 스레드에서 결과 메시지 출력 및 리스트 갱신
|
||||
withContext(Dispatchers.Main) {
|
||||
if (movedCount > 0 || deletedFolderCount > 0) {
|
||||
val msg = buildString {
|
||||
if (movedCount > 0) append("${movedCount}개의 파일을 폴더로 정리했습니다.\n")
|
||||
if (deletedFolderCount > 0) append("${deletedFolderCount}개의 빈 폴더를 삭제했습니다.")
|
||||
}.trim()
|
||||
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
||||
|
||||
// 파일 이동이나 폴더 삭제가 하나라도 일어났다면 현재 화면 새로고침
|
||||
loadFiles()
|
||||
} else {
|
||||
Toast.makeText(requireContext(), "파일 정리에 실패했습니다.", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(requireContext(), "정리할 파일이나 빈 폴더가 없습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFiles() {
|
||||
if (currentDir.exists()) {
|
||||
allFiles = currentDir.listFiles()?.toList() ?: emptyList()
|
||||
} else {
|
||||
allFiles = emptyList()
|
||||
private fun navigateUp() {
|
||||
if (currentDir.absolutePath != rootDir.absolutePath) {
|
||||
currentDir = currentDir.parentFile ?: rootDir
|
||||
loadFiles()
|
||||
}
|
||||
backPressedCallback.isEnabled = currentDir.absolutePath != rootDir.absolutePath
|
||||
}
|
||||
|
||||
private fun loadFiles() {
|
||||
allFiles = if (currentDir.exists()) currentDir.listFiles()?.toList() ?: emptyList() else emptyList()
|
||||
backPressedCallback.isEnabled = isSelectionMode || currentDir.absolutePath != rootDir.absolutePath
|
||||
applyFilterAndSort()
|
||||
}
|
||||
|
||||
@ -269,37 +585,19 @@ class CompletedFilesFragment : Fragment() {
|
||||
FileFilterType.OTHER -> !extImages.contains(ext) && !extVideos.contains(ext) && !extDocs.contains(ext)
|
||||
}
|
||||
}
|
||||
|
||||
val naturalComparator = NaturalOrderComparator()
|
||||
val sortedFolders = folders.sortedBy { it.name.lowercase() }
|
||||
val sortedFolders = folders.sortedWith(naturalComparator)
|
||||
val sortedFiles = when (currentSort) {
|
||||
FileSortType.DOWNLOAD_DATE -> {
|
||||
if (isDescending) files.sortedByDescending { it.lastModified() }
|
||||
else files.sortedBy { it.lastModified() }
|
||||
}
|
||||
FileSortType.LAST_USED -> {
|
||||
if (isDescending) files.sortedByDescending { getLastAccessedTime(it.name) }
|
||||
else files.sortedBy { getLastAccessedTime(it.name) }
|
||||
}
|
||||
FileSortType.SIZE -> {
|
||||
if (isDescending) files.sortedByDescending { it.length() }
|
||||
else files.sortedBy { it.length() }
|
||||
}
|
||||
FileSortType.FREQUENTLY_USED -> {
|
||||
if (isDescending) files.sortedByDescending { getAccessCount(it.name) }
|
||||
else files.sortedBy { getAccessCount(it.name) }
|
||||
}
|
||||
FileSortType.NAME -> {
|
||||
if (isDescending) files.sortedWith(naturalComparator.reversed())
|
||||
else files.sortedWith(naturalComparator)
|
||||
}
|
||||
FileSortType.DOWNLOAD_DATE -> if (isDescending) files.sortedByDescending { it.lastModified() } else files.sortedBy { it.lastModified() }
|
||||
FileSortType.LAST_USED -> if (isDescending) files.sortedByDescending { getLastAccessedTime(it.name) } else files.sortedBy { getLastAccessedTime(it.name) }
|
||||
FileSortType.SIZE -> if (isDescending) files.sortedByDescending { it.length() } else files.sortedBy { it.length() }
|
||||
FileSortType.FREQUENTLY_USED -> if (isDescending) files.sortedByDescending { getAccessCount(it.name) } else files.sortedBy { getAccessCount(it.name) }
|
||||
FileSortType.NAME -> if (isDescending) files.sortedWith(naturalComparator.reversed()) else files.sortedWith(naturalComparator)
|
||||
}
|
||||
|
||||
val finalItems = mutableListOf<File>()
|
||||
|
||||
if (currentDir.absolutePath != rootDir.absolutePath) {
|
||||
finalItems.add(File(currentDir, ".."))
|
||||
}
|
||||
|
||||
if (currentDir.absolutePath != rootDir.absolutePath) finalItems.add(File(currentDir, ".."))
|
||||
finalItems.addAll(sortedFolders)
|
||||
finalItems.addAll(sortedFiles)
|
||||
|
||||
@ -308,24 +606,13 @@ class CompletedFilesFragment : Fragment() {
|
||||
|
||||
private fun trackFileAccess(fileName: String) {
|
||||
val prefs = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE)
|
||||
val currentCount = prefs.getInt("${fileName}_count", 0)
|
||||
|
||||
prefs.edit()
|
||||
.putLong(fileName, System.currentTimeMillis())
|
||||
.putInt("${fileName}_count", currentCount + 1)
|
||||
.putInt("${fileName}_count", prefs.getInt("${fileName}_count", 0) + 1)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun getLastAccessedTime(fileName: String): Long {
|
||||
val prefs = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE)
|
||||
return prefs.getLong(fileName, 0L)
|
||||
}
|
||||
|
||||
private fun getAccessCount(fileName: String): Int {
|
||||
val prefs = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE)
|
||||
return prefs.getInt("${fileName}_count", 0)
|
||||
}
|
||||
|
||||
private fun getLastAccessedTime(fileName: String) = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE).getLong(fileName, 0L)
|
||||
private fun getAccessCount(fileName: String) = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE).getInt("${fileName}_count", 0)
|
||||
private fun getMimeType(file: File): String {
|
||||
val extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(file).toString()) ?: file.extension
|
||||
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()) ?: "*/*"
|
||||
@ -334,20 +621,13 @@ class CompletedFilesFragment : Fragment() {
|
||||
private fun openPrivateFile(context: Context, file: File) {
|
||||
try {
|
||||
trackFileAccess(file.name)
|
||||
|
||||
// 💡 파일을 열고나서 즉시 접근 횟수 및 뷰 반영을 위해 다시 정렬/로드합니다.
|
||||
loadFiles()
|
||||
|
||||
val uri: Uri = FileProvider.getUriForFile(context, "bums.lunatic.launcher.fileprovider", file)
|
||||
val mimeType = getMimeType(file)
|
||||
val uri = FileProvider.getUriForFile(context, "bums.lunatic.launcher.fileprovider", file)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mimeType)
|
||||
setDataAndType(uri, getMimeType(file))
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Toast.makeText(context, "파일을 공유할 수 없습니다.", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(context, "이 파일을 열 수 있는 앱이 설치되어 있지 않습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@ -355,12 +635,9 @@ class CompletedFilesFragment : Fragment() {
|
||||
|
||||
private fun updateRecyclerViewLayoutManager() {
|
||||
recyclerView.layoutManager = when (currentViewMode) {
|
||||
FileViewMode.LIST_TEXT, FileViewMode.LIST_THUMB ->
|
||||
LinearLayoutManager(requireContext())
|
||||
FileViewMode.GRID_LARGE ->
|
||||
GridLayoutManager(requireContext(), 2)
|
||||
FileViewMode.GRID_SMALL ->
|
||||
GridLayoutManager(requireContext(), 4)
|
||||
FileViewMode.LIST_TEXT, FileViewMode.LIST_THUMB -> LinearLayoutManager(requireContext())
|
||||
FileViewMode.GRID_LARGE -> GridLayoutManager(requireContext(), 2)
|
||||
FileViewMode.GRID_SMALL -> GridLayoutManager(requireContext(), 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -369,12 +646,15 @@ class CompletedFilesFragment : Fragment() {
|
||||
class CompletedFilesAdapter(
|
||||
private val onItemClick: (File) -> Unit,
|
||||
private val onItemLongClick: (File) -> Unit,
|
||||
private val getAccessCount: (String) -> Int // 💡 접근 빈도를 가져오기 위한 람다 함수 추가
|
||||
private val getAccessCount: (String) -> Int
|
||||
) : RecyclerView.Adapter<CompletedFilesAdapter.FileViewHolder>() {
|
||||
|
||||
private var fileList: List<File> = emptyList()
|
||||
private var viewMode: FileViewMode = FileViewMode.LIST_THUMB
|
||||
|
||||
// 💡 어댑터 내부에 선택된 파일 정보 저장
|
||||
private var selectedFiles: Set<File> = emptySet()
|
||||
|
||||
fun submitList(files: List<File>) {
|
||||
fileList = files
|
||||
notifyDataSetChanged()
|
||||
@ -385,25 +665,28 @@ class CompletedFilesAdapter(
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return viewMode.ordinal
|
||||
// 💡 선택 상태가 바뀔 때 UI 갱신을 위해 호출
|
||||
fun updateSelection(selection: Set<File>) {
|
||||
selectedFiles = selection
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int) = viewMode.ordinal
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FileViewHolder {
|
||||
val layoutRes = when (FileViewMode.values()[viewType]) {
|
||||
FileViewMode.LIST_TEXT -> R.layout.item_file_list_text
|
||||
FileViewMode.LIST_THUMB -> R.layout.item_file_list_thumb
|
||||
FileViewMode.GRID_LARGE, FileViewMode.GRID_SMALL -> R.layout.item_file_grid
|
||||
}
|
||||
val view = LayoutInflater.from(parent.context).inflate(layoutRes, parent, false)
|
||||
return FileViewHolder(view)
|
||||
return FileViewHolder(LayoutInflater.from(parent.context).inflate(layoutRes, parent, false))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: FileViewHolder, position: Int) {
|
||||
holder.bind(fileList[position], viewMode)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = fileList.size
|
||||
override fun getItemCount() = fileList.size
|
||||
|
||||
inner class FileViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val tvFileName: TextView? = itemView.findViewById(R.id.tvFileName)
|
||||
@ -411,10 +694,16 @@ class CompletedFilesAdapter(
|
||||
private val ivThumb: ImageView? = itemView.findViewById(R.id.ivThumb)
|
||||
|
||||
fun bind(file: File, mode: FileViewMode) {
|
||||
// 💡 선택된 아이템이면 배경색을 푸른색 반투명 효과로 표시
|
||||
if (selectedFiles.contains(file)) {
|
||||
itemView.setBackgroundColor(Color.parseColor("#440099FF"))
|
||||
} else {
|
||||
itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
}
|
||||
|
||||
if (file.name == "..") {
|
||||
tvFileName?.text = "📁 .. (상위 폴더로)"
|
||||
tvFileSize?.text = "이전 경로"
|
||||
|
||||
if (ivThumb != null) {
|
||||
Glide.with(itemView.context).clear(ivThumb)
|
||||
ivThumb.setImageResource(android.R.drawable.ic_menu_revert)
|
||||
@ -423,7 +712,6 @@ class CompletedFilesAdapter(
|
||||
tvFileName?.text = "📁 ${file.name}"
|
||||
val count = file.listFiles()?.size ?: 0
|
||||
tvFileSize?.text = "항목 $count 개"
|
||||
|
||||
if (ivThumb != null) {
|
||||
Glide.with(itemView.context).clear(ivThumb)
|
||||
ivThumb.setImageResource(android.R.drawable.ic_menu_gallery)
|
||||
@ -431,18 +719,11 @@ class CompletedFilesAdapter(
|
||||
} else {
|
||||
tvFileName?.text = file.name
|
||||
val sizeMb = file.length() / (1024.0 * 1024.0)
|
||||
|
||||
// 💡 접근 빈도를 가져와 텍스트에 포함시킵니다.
|
||||
val accessCount = getAccessCount(file.name)
|
||||
val countText = if (accessCount > 0) "조회: ${accessCount}회" else "새 파일"
|
||||
|
||||
tvFileSize?.text = String.format("%.2f MB • %s • %s", sizeMb, file.extension.uppercase(), countText)
|
||||
|
||||
if (ivThumb != null) {
|
||||
Glide.with(itemView.context)
|
||||
.load(file)
|
||||
.placeholder(android.R.drawable.ic_menu_report_image)
|
||||
.into(ivThumb)
|
||||
Glide.with(itemView.context).load(file).placeholder(android.R.drawable.ic_menu_report_image).into(ivThumb)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -68,6 +68,7 @@ import org.mozilla.geckoview.WebExtension
|
||||
import org.mozilla.geckoview.WebExtension.MessageDelegate
|
||||
import org.mozilla.geckoview.WebExtension.PortDelegate
|
||||
import org.mozilla.geckoview.WebExtensionController.AddonManagerDelegate
|
||||
import org.mozilla.geckoview.WebRequestError
|
||||
import org.mozilla.geckoview.WebResponse
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
@ -343,11 +344,31 @@ open class GeckoWeb @JvmOverloads constructor(
|
||||
// scrollState = 0
|
||||
}
|
||||
|
||||
override fun onLoadError(
|
||||
session: GeckoSession,
|
||||
uri: String?,
|
||||
error: WebRequestError
|
||||
): GeckoResult<String?>? {
|
||||
|
||||
Blog.LOGE("Gecko 에러 발생!! URI: $uri, Code: ${error.code}, Category: ${error.category}")
|
||||
if (uri?.contains("booktoki") == true) {
|
||||
when(lastArrow) {
|
||||
1->{sendJsonMsg("CLICK_NEXT_CHAPTER")}
|
||||
-1->{sendJsonMsg("CLICK_PREV_CHAPTER")}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
} else {
|
||||
session.reload()
|
||||
}
|
||||
return null
|
||||
|
||||
}
|
||||
override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
|
||||
this@GeckoWeb.canGoBack = canGoBack
|
||||
}
|
||||
}
|
||||
|
||||
var lastArrow = 0
|
||||
fun goBack() {
|
||||
if (true == canGoBack)
|
||||
session?.goBack()
|
||||
@ -708,7 +729,7 @@ open class GeckoWeb @JvmOverloads constructor(
|
||||
private fun View.post(action: () -> Unit) = this.post(Runnable(action))
|
||||
fun onPause() {
|
||||
saveCurrentSessionState()
|
||||
session?.stop()
|
||||
// session?.stop()
|
||||
session?.setActive(false)
|
||||
Blog.LOGE("called onstop $lastedUrl")
|
||||
}
|
||||
|
||||
@ -310,7 +310,8 @@ open class NeoRssActivity : CommonActivity() {
|
||||
}
|
||||
|
||||
override fun dispatchGenericMotionEvent(ev: MotionEvent?): Boolean {
|
||||
Blog.LOGE("keyEvent >>>>> dispatchGenericMotionEvent ${ev}")
|
||||
if ("Virtual".equals(ev?.device?.name)) return true
|
||||
Blog.LOGE("keyEvent >>>>> dispatchGenericMotionEvent ${ev?.device?.name ?: ""}: ${ev} ")
|
||||
if (ev?.device?.name?.contains("BLE-M3") == true) {
|
||||
ev?.action?.let { action ->
|
||||
when(action) {
|
||||
@ -354,6 +355,8 @@ open class NeoRssActivity : CommonActivity() {
|
||||
Blog.LOGE("Arrow Up Click")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// if (onExit) return true
|
||||
return false
|
||||
}
|
||||
@ -615,9 +618,9 @@ open class NeoRssActivity : CommonActivity() {
|
||||
is TokiFragment -> {
|
||||
currentFragment.back()
|
||||
}
|
||||
// is Novels -> {
|
||||
// currentFragment.actionNextEvent(false)
|
||||
// }
|
||||
is CompletedFilesFragment -> {
|
||||
currentFragment.backPress()
|
||||
}
|
||||
else -> {
|
||||
// showContents(R.id.close)
|
||||
}
|
||||
|
||||
@ -414,6 +414,8 @@ object HistoryManager {
|
||||
url = url.replace("//","/").trim()
|
||||
}
|
||||
}
|
||||
Blog.LOGE("CURENTPATH $url")
|
||||
|
||||
var contentsPageInfo =
|
||||
this.query(ContentsPageInfo::class).query("contentsType == $0", contentsType).query("pathUrl == $0", url).find()
|
||||
if (contentsPageInfo.size > 0) {
|
||||
|
||||
@ -307,7 +307,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
||||
}
|
||||
|
||||
override fun onKeyEvent(event: KeyEvent): Boolean {
|
||||
if (!isbooktoki()) return false // 조건부 처리
|
||||
// if (!isbooktoki()) return false // 조건부 처리
|
||||
|
||||
val isUp = event.action == MotionEvent.ACTION_UP
|
||||
return when (event.keyCode) {
|
||||
@ -393,16 +393,16 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
||||
|
||||
override fun onLongClick() {
|
||||
if (!enableGestures) return
|
||||
if (contentsType.contains("book") &&
|
||||
currentPage?.bookTitle?.isNotEmpty() == true &&
|
||||
currentPage?.chapterTitle?.isNotEmpty() == true &&
|
||||
binding.pagedLayer.text.isNotEmpty() == true && (binding.pagedLayer.text.length > 100)) {
|
||||
currentPage?.bookTitle?.let { title ->
|
||||
currentPage?.contents?.let { contents ->
|
||||
saveTextToPrivateVault(title, "${this.currentChapter}_${this.currentTitle}",binding.pagedLayer.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
// if (contentsType.contains("book") &&
|
||||
// currentPage?.bookTitle?.isNotEmpty() == true &&
|
||||
// currentPage?.chapterTitle?.isNotEmpty() == true &&
|
||||
// binding.pagedLayer.text.isNotEmpty() == true && (binding.pagedLayer.text.length > 100)) {
|
||||
// currentPage?.bookTitle?.let { title ->
|
||||
// currentPage?.contents?.let { contents ->
|
||||
// saveTextToPrivateVault(title, "${this.currentChapter}_${this.currentTitle}",binding.pagedLayer.text)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
Blog.LOGD(log = "onLongClick")
|
||||
}
|
||||
|
||||
@ -1045,6 +1045,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
||||
if (pathUrl != null && pathUrl.length > 6) {
|
||||
HistoryManager.getNextPage(contentsType,pathUrl) {
|
||||
if (it != null && (it.pathUrl?.length ?: 0) > 6) {
|
||||
binding.lunaticBrowser.geckoWeb.lastArrow = 1
|
||||
moveTo(it)
|
||||
} else {
|
||||
showToast("다음 편이 없다요.")
|
||||
@ -1057,6 +1058,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
||||
if (pathUrl != null && pathUrl.length > 6) {
|
||||
HistoryManager.getPrevPage(contentsType,pathUrl) {
|
||||
if (it != null && (it.pathUrl?.length ?: 0) > 6) {
|
||||
binding.lunaticBrowser.geckoWeb.lastArrow = -1
|
||||
moveTo(it)
|
||||
} else {
|
||||
showToast("이전 편이 없다요.")
|
||||
@ -1099,12 +1101,46 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
||||
|
||||
|
||||
fun actionNextEvent(fast: Boolean = false) {
|
||||
if (binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && (binding.pagedLayer.current() < binding.pagedLayer!!.size() - 1)) {
|
||||
binding.pagedLayer.doNext(fast)
|
||||
binding.lunaticBrowser.geckoWeb.pageDown()
|
||||
updateLastInfo(binding.pagedLayer!!)
|
||||
if (contentsType.contains("book")) {
|
||||
if (binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && (binding.pagedLayer.current() < binding.pagedLayer!!.size() - 1)) {
|
||||
binding.pagedLayer.doNext(fast)
|
||||
binding.lunaticBrowser.geckoWeb.pageDown()
|
||||
updateLastInfo(binding.pagedLayer)
|
||||
} else {
|
||||
binding.pagedLayer.visibility = View.GONE
|
||||
binding.lunaticBrowser.visibility = View.VISIBLE
|
||||
moveToNext(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)
|
||||
}
|
||||
} else if (contentsType.contains("comics")) {
|
||||
sendViewerTouch("right")
|
||||
} else {
|
||||
moveToNext(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)
|
||||
if (binding.lunaticBrowser.geckoWeb.scrollState > 0) {
|
||||
|
||||
} else {
|
||||
binding.lunaticBrowser.geckoWeb.pageDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun actionPrevEvent(fast: Boolean = false) {
|
||||
if(contentsType.contains("book")) {
|
||||
if (binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && binding.pagedLayer.current() > 0) {
|
||||
binding.pagedLayer.doPrev(fast)
|
||||
binding.lunaticBrowser.geckoWeb.pageUp()
|
||||
updateLastInfo(binding.pagedLayer)
|
||||
} else {
|
||||
binding.pagedLayer.visibility = View.GONE
|
||||
binding.lunaticBrowser.visibility = View.VISIBLE
|
||||
moveToPrev(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)
|
||||
}
|
||||
} else if (contentsType.contains("comics")) {
|
||||
sendViewerTouch("left")
|
||||
} else {
|
||||
if (binding.lunaticBrowser.geckoWeb.scrollState < 0) {
|
||||
|
||||
} else {
|
||||
binding.lunaticBrowser.geckoWeb.pageUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1135,16 +1171,6 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
||||
}
|
||||
}
|
||||
|
||||
fun actionPrevEvent(fast: Boolean = false) {
|
||||
if (binding.pagedLayer.isVisible && binding.pagedLayer.size() > 0 && binding.pagedLayer.current() > 0) {
|
||||
binding.pagedLayer.doPrev(fast)
|
||||
binding.lunaticBrowser.geckoWeb.pageUp()
|
||||
updateLastInfo(binding.pagedLayer)
|
||||
} else {
|
||||
moveToPrev(currentPage?.pathUrl ?: lastedUrl?.toUri()?.path)
|
||||
}
|
||||
}
|
||||
|
||||
fun showAlert(alert: String) {
|
||||
Log.i(TAG, "showAlert >> " + alert)
|
||||
}
|
||||
@ -1239,6 +1265,7 @@ class TokiFragment : RemoteGestureFragment(), PagedTextViewInterface,KeyEventHan
|
||||
view.visibility = if (!saveContinuation) VISIBLE else GONE
|
||||
binding.lunaticBrowser.visibility = if (saveContinuation) VISIBLE else GONE
|
||||
}
|
||||
binding.lunaticBrowser.geckoWeb.lastArrow = 0
|
||||
// view.forceUpdateUI()
|
||||
lastedUrl?.let {
|
||||
Uri.parse(it)?.let { uri ->
|
||||
|
||||
@ -152,7 +152,7 @@
|
||||
style="@style/SearchAccs"
|
||||
app:autoSizeTextType="uniform"
|
||||
android:id="@+id/search_music"
|
||||
android:text="SPOTIFY"
|
||||
android:text="MUSIC"
|
||||
/>
|
||||
<TextView
|
||||
style="@style/SearchAccs"
|
||||
|
||||
@ -75,6 +75,47 @@
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerViewCompletedFiles"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:padding="8dp"/>
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutSelectionActions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="#EEEEEE"
|
||||
android:padding="8dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnCancelSelection"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="취소"
|
||||
android:textStyle="bold"
|
||||
android:gravity="center"
|
||||
android:padding="12dp"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnMoveSelected"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="폴더 이동"
|
||||
android:textStyle="bold"
|
||||
android:gravity="center"
|
||||
android:padding="12dp"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnDeleteSelected"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="선택 삭제"
|
||||
android:textColor="#FF0000"
|
||||
android:textStyle="bold"
|
||||
android:gravity="center"
|
||||
android:padding="12dp"/>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
Loading…
x
Reference in New Issue
Block a user