diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt index 091e4037..d143046f 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/CompletedFilesFragment.kt @@ -6,17 +6,22 @@ import android.content.Intent import android.graphics.Color import android.net.Uri import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager import android.webkit.MimeTypeMap import android.widget.AdapterView import android.widget.ArrayAdapter +import android.widget.EditText import android.widget.ImageView import android.widget.Spinner import android.widget.TextView import android.widget.Toast import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AlertDialog import androidx.core.content.FileProvider import androidx.fragment.app.Fragment import androidx.recyclerview.widget.GridLayoutManager @@ -64,6 +69,14 @@ class NaturalOrderComparator : Comparator { } } +enum class RenameMode(val label: String) { + REPLACE("문자열 제거/치환"), + TRUNCATE_AFTER("기준 문자열 이후 제거 (앞 남기기)"), + TRUNCATE_BEFORE("기준 문자열 이전 제거 (뒤 남기기)"), // 💡 추가 + BATCH_NUMBERING("전체 변경 + 넘버링"), + SEQUENTIAL("순차적 직접 변경") +} + class CompletedFilesFragment : Fragment() { private lateinit var recyclerView: RecyclerView @@ -77,7 +90,7 @@ class CompletedFilesFragment : Fragment() { private var currentSort = FileSortType.NAME private var currentViewMode = FileViewMode.LIST_TEXT private var isDescending = true - + private var searchQuery = "" // 💡 실시간 검색어 저장용 변수 // 💡 멀티 선택 모드 상태 관리 변수 private var isSelectionMode = false private val selectedFiles = mutableSetOf() @@ -403,15 +416,59 @@ class CompletedFilesFragment : Fragment() { } } private fun setupControls(view: View) { + val layoutTitleDefault = view.findViewById(R.id.layoutTitleDefault) + val layoutTitleSearch = view.findViewById(R.id.layoutTitleSearch) + val etSearch = view.findViewById(R.id.etSearch) + val btnSearch = view.findViewById(R.id.btnSearch) + val btnCloseSearch = view.findViewById(R.id.btnCloseSearch) val spinnerFilter = view.findViewById(R.id.spinnerFilter) val spinnerSort = view.findViewById(R.id.spinnerSort) val tvSortOrder = view.findViewById(R.id.tvSortOrder) val btnViewMode = view.findViewById(R.id.btnViewMode) val btnOrganize = view.findViewById(R.id.btnOrganize) + // 💡 검색 모드 진입 + btnSearch.setOnClickListener { + layoutTitleDefault.visibility = View.GONE + layoutTitleSearch.visibility = View.VISIBLE + etSearch.requestFocus() + // 키보드 보이기 로직(선택사항) + etSearch.postDelayed({ // 뷰가 완전히 그려진 후 키보드를 띄우기 위해 약간의 지연 실행 + val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(etSearch, InputMethodManager.SHOW_IMPLICIT) + }, 100) + + } + view.findViewById(R.id.tvTitle)?.setOnClickListener { btnSearch.performClick() } + + // 💡 검색 모드 종료 + btnCloseSearch.setOnClickListener { + searchQuery = "" + etSearch.setText("") + layoutTitleDefault.visibility = View.VISIBLE + layoutTitleSearch.visibility = View.GONE + applyFilterAndSort() // 리스트 복구 + } + + // 💡 실시간 타이핑 감지 + etSearch.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable?) { + searchQuery = s.toString() + applyFilterAndSort() // 텍스트 바뀔 때마다 즉시 필터링 + } + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + }) // 💡 선택 액션 바 버튼 이벤트 + view.findViewById(R.id.btnSelectAll)?.setOnClickListener { + selectedFiles.clear() + selectedFiles.addAll(adapter.getAll()) + adapter.updateSelection(selectedFiles) + } + view.findViewById(R.id.btnCancelSelection)?.setOnClickListener { toggleSelectionMode(false) } view.findViewById(R.id.btnMoveSelected)?.setOnClickListener { showMoveDialog() } + view.findViewById(R.id.btnRenameSelected)?.setOnClickListener { showBatchRenameDialog() } view.findViewById(R.id.btnDeleteSelected)?.setOnClickListener { if (selectedFiles.isEmpty()) return@setOnClickListener android.app.AlertDialog.Builder(requireContext()) @@ -495,6 +552,188 @@ class CompletedFilesFragment : Fragment() { } } + private fun renamePrefsEntry(oldName: String, newName: String) { + val prefs = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE) + + // 1. 기존 데이터 가져오기 + val lastAccessed = prefs.getLong(oldName, 0L) + val accessCount = prefs.getInt("${oldName}_count", 0) + + // 2. 새 이름으로 데이터 저장 및 기존 데이터 삭제 + if (lastAccessed != 0L || accessCount != 0) { + prefs.edit().apply { + // 새 키로 복사 + putLong(newName, lastAccessed) + putInt("${newName}_count", accessCount) + // 기존 키 삭제 + remove(oldName) + remove("${oldName}_count") + apply() + } + } + } + + private fun generateNewName(file: File, mode: RenameMode, in1: String, in2: String, index: Int): String { + val extension = file.extension + val fileNameOnly = file.nameWithoutExtension + + return when (mode) { + RenameMode.REPLACE -> { + // 특정 문자열 제거(in2가 비어있을 때) 또는 치환 + fileNameOnly.replace(in1, in2) + if (extension.isNotEmpty()) ".$extension" else "" + } + RenameMode.TRUNCATE_AFTER -> { + // 예: "P2024_이미지.jpg"에서 "_" 기준 이후 제거 -> "P2024.jpg" + val idx = fileNameOnly.indexOf(in1) + if (idx != -1) fileNameOnly.substring(0, idx) + if (extension.isNotEmpty()) ".$extension" else "" + else file.name + } + RenameMode.TRUNCATE_BEFORE -> { + // 💡 예: "P2024_이미지.jpg"에서 "_" 기준 이전 제거 -> "이미지.jpg" + val idx = fileNameOnly.indexOf(in1) + if (idx != -1) { + // 키워드 자체도 지우고 싶다면 idx + in1.length 부터 시작 + fileNameOnly.substring(idx + in1.length) + if (extension.isNotEmpty()) ".$extension" else "" + } else file.name + } + RenameMode.BATCH_NUMBERING -> { + // 전체 네이밍 + 넘버링 (예: 여행_01.jpg) + "${in1}_${String.format("%02d", index)}" + if (extension.isNotEmpty()) ".$extension" else "" + } + RenameMode.SEQUENTIAL -> { + // 이 모드는 다이얼로그에서 하나씩 입력받는 구조로 별도 처리가 필요할 수 있음 + "순차변경 대기중" + } + } + } + + private fun showSequentialRename(files: List, index: Int) { + if (index >= files.size) { + toggleSelectionMode(false) + loadFiles() + return + } + + val file = files[index] + val et = EditText(requireContext()).apply { + setText(file.name) + setSelection(file.nameWithoutExtension.length) // 이름 부분만 드래그/포커스 + } + + android.app.AlertDialog.Builder(requireContext()) + .setTitle("이름 직접 수정 (${index + 1}/${files.size})") + .setView(et) + .setCancelable(false) + .setPositiveButton("다음") { _, _ -> + val oldName = file.name + val newName = et.text.toString() + val dest = File(file.parentFile, newName) + + if (oldName != newName && file.renameTo(dest)) { + // 💡 프리퍼런스 키 업데이트 + renamePrefsEntry(oldName, newName) + } + showSequentialRename(files, index + 1) + } + .setNeutralButton("건너뛰기") { _, _ -> showSequentialRename(files, index + 1) } + .setNegativeButton("중단", null) + .show() + } + + private fun showBatchRenameDialog() { + val files = selectedFiles.toList().sortedWith(NaturalOrderComparator()) + if (files.isEmpty()) return + + val view = layoutInflater.inflate(R.layout.dialog_batch_rename, null) + val spinner = view.findViewById(R.id.spinnerRenameMode) + val et1 = view.findViewById(R.id.etInput1) + val et2 = view.findViewById(R.id.etInput2) + val tvPreview = view.findViewById(R.id.tvRenamePreview) + + // 모드 리스트 세팅 + spinner.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, RenameMode.values().map { it.label }) + + // 실시간 미리보기 업데이트 함수 + val updateUI = { + val mode = RenameMode.values()[spinner.selectedItemPosition] + val preview = files.take(3).mapIndexed { i, f -> + "${f.name} → ${generateNewName(f, mode, et1.text.toString(), et2.text.toString(), i + 1)}" + }.joinToString("\n") + tvPreview.text = "미리보기(최대 3개):\n$preview" + } + + var tWatcher = object : TextWatcher { + override fun beforeTextChanged( + s: CharSequence?, + start: Int, + count: Int, + after: Int + ) { + updateUI() + } + + override fun onTextChanged( + s: CharSequence?, + start: Int, + before: Int, + count: Int + ) { + updateUI() + } + + override fun afterTextChanged(s: Editable?) { + updateUI() + } + } + // 리스너 등록 + et1.addTextChangedListener(tWatcher) + et2.addTextChangedListener(tWatcher) + spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(p0: AdapterView<*>?, p1: View?, pos: Int, p3: Long) { + val mode = RenameMode.values()[pos] + // 모드별 가이드 UI 조정 + et1.visibility = if (mode == RenameMode.SEQUENTIAL) View.GONE else View.VISIBLE + et2.visibility = if (mode == RenameMode.REPLACE) View.VISIBLE else View.GONE + et1.hint = when(mode) { + RenameMode.REPLACE -> "찾을 문자열" + RenameMode.TRUNCATE_AFTER, RenameMode.TRUNCATE_BEFORE -> "기준 문자열" + RenameMode.BATCH_NUMBERING -> "새 파일명 공통 부분" + else -> "" + } + updateUI() + } + override fun onNothingSelected(p0: AdapterView<*>?) {} + } + + android.app.AlertDialog.Builder(requireContext()) + .setTitle("${files.size}개 파일 이름 변경") + .setView(view) + .setPositiveButton("적용") { _, _ -> + val mode = RenameMode.values()[spinner.selectedItemPosition] + if (mode == RenameMode.SEQUENTIAL) { + showSequentialRename(files, 0) + } else { + var count = 0 + files.forEachIndexed { i, file -> + val oldName = file.name + val newName = generateNewName(file, mode, et1.text.toString(), et2.text.toString(), i + 1) + val dest = File(file.parentFile, newName) + + if (oldName != newName && !dest.exists() && file.renameTo(dest)) { + // 💡 파일명이 성공적으로 바뀌었을 때 프리퍼런스 정보도 갱신 + renamePrefsEntry(oldName, newName) + count++ + } + } + Toast.makeText(context, "$count 개 파일 이름 변경 완료", Toast.LENGTH_SHORT).show() + toggleSelectionMode(false) + loadFiles() + } + } + .setNegativeButton("취소", null) + .show() + } + private fun organizeRootFiles() { CoroutineScope(Dispatchers.IO).launch { // 1. 루트 폴더의 파일 목록 가져오기 @@ -572,9 +811,27 @@ class CompletedFilesFragment : Fragment() { } private fun applyFilterAndSort() { - val folders = allFiles.filter { it.isDirectory } - var files = allFiles.filter { it.isFile } + // 💡 1. 데이터 소스 결정 + val baseFiles = if (searchQuery.isNotEmpty()) { + // 검색어가 있으면 rootDir부터 모든 하위 파일/폴더를 가져옴 (Recursive) + rootDir.walkTopDown().toList() + } else { + // 검색어가 없으면 현재 폴더(currentDir)의 파일만 대상으로 함 + allFiles + } + // 2. 폴더와 파일 분리 및 검색어 필터링 + var folders = baseFiles.filter { it.isDirectory } + var files = baseFiles.filter { it.isFile } + + if (searchQuery.isNotEmpty()) { + // 파일명에 검색어가 포함된 것만 추출 (ignoreCase로 대소문자 무시) + files = files.filter { it.name.contains(searchQuery, ignoreCase = true) } + // 검색 모드일 때는 폴더 결과는 제외하거나 원하면 아래 주석 해제 + folders = emptyList() + } + + // 3. 기존 카테고리 필터 적용 (IMAGE, VIDEO 등) files = files.filter { file -> val ext = file.extension.lowercase() when (currentFilter) { @@ -586,6 +843,7 @@ class CompletedFilesFragment : Fragment() { } } + // 4. 정렬 로직 (기존 동일) val naturalComparator = NaturalOrderComparator() val sortedFolders = folders.sortedWith(naturalComparator) val sortedFiles = when (currentSort) { @@ -596,8 +854,14 @@ class CompletedFilesFragment : Fragment() { FileSortType.NAME -> if (isDescending) files.sortedWith(naturalComparator.reversed()) else files.sortedWith(naturalComparator) } + // 5. 최종 리스트 구성 val finalItems = mutableListOf() - if (currentDir.absolutePath != rootDir.absolutePath) finalItems.add(File(currentDir, "..")) + + // 💡 검색 중이 아닐 때만 "상위 폴더(..)" 아이템을 추가 + if (searchQuery.isEmpty() && currentDir.absolutePath != rootDir.absolutePath) { + finalItems.add(File(currentDir, "..")) + } + finalItems.addAll(sortedFolders) finalItems.addAll(sortedFiles) @@ -654,6 +918,8 @@ class CompletedFilesAdapter( // 💡 어댑터 내부에 선택된 파일 정보 저장 private var selectedFiles: Set = emptySet() + fun getAll() = fileList + fun submitList(files: List) { fileList = files diff --git a/app/src/main/res/layout/dialog_batch_rename.xml b/app/src/main/res/layout/dialog_batch_rename.xml new file mode 100644 index 00000000..4aa1cd5d --- /dev/null +++ b/app/src/main/res/layout/dialog_batch_rename.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_completed_files.xml b/app/src/main/res/layout/fragment_completed_files.xml index c192c4b8..a39e3ecf 100644 --- a/app/src/main/res/layout/fragment_completed_files.xml +++ b/app/src/main/res/layout/fragment_completed_files.xml @@ -4,28 +4,69 @@ android:layout_height="match_parent" android:orientation="vertical" android:background="?android:attr/windowBackground"> - - + + - - + android:orientation="horizontal" + android:paddingHorizontal="16dp" + android:paddingBottom="8dp" + android:gravity="center_vertical"> + + + + + + + + + + - - - - + android:spinnerMode="dropdown" /> - + android:spinnerMode="dropdown" /> - + - + - + -