This commit is contained in:
lunaticbum 2026-03-23 12:05:55 +09:00
parent 996a75fc3e
commit a8c7641e69
3 changed files with 404 additions and 38 deletions

View File

@ -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<File> {
}
}
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<File>()
@ -403,15 +416,59 @@ class CompletedFilesFragment : Fragment() {
}
}
private fun setupControls(view: View) {
val layoutTitleDefault = view.findViewById<View>(R.id.layoutTitleDefault)
val layoutTitleSearch = view.findViewById<View>(R.id.layoutTitleSearch)
val etSearch = view.findViewById<EditText>(R.id.etSearch)
val btnSearch = view.findViewById<TextView>(R.id.btnSearch)
val btnCloseSearch = view.findViewById<TextView>(R.id.btnCloseSearch)
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)
val btnOrganize = view.findViewById<TextView>(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<View>(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<View>(R.id.btnSelectAll)?.setOnClickListener {
selectedFiles.clear()
selectedFiles.addAll(adapter.getAll())
adapter.updateSelection(selectedFiles)
}
view.findViewById<View>(R.id.btnCancelSelection)?.setOnClickListener { toggleSelectionMode(false) }
view.findViewById<View>(R.id.btnMoveSelected)?.setOnClickListener { showMoveDialog() }
view.findViewById<View>(R.id.btnRenameSelected)?.setOnClickListener { showBatchRenameDialog() }
view.findViewById<View>(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<File>, 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<Spinner>(R.id.spinnerRenameMode)
val et1 = view.findViewById<EditText>(R.id.etInput1)
val et2 = view.findViewById<EditText>(R.id.etInput2)
val tvPreview = view.findViewById<TextView>(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<File>()
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<File> = emptySet()
fun getAll() = fileList
fun submitList(files: List<File>) {
fileList = files

View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="변경 모드 선택"
android:textSize="12sp"
android:textColor="#888888"
android:layout_marginBottom="4dp"/>
<androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/spinnerRenameMode"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginBottom="16dp"
android:background="@android:drawable/btn_dropdown"
android:spinnerMode="dropdown" />
<EditText
android:id="@+id/etInput1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="입력 1"
android:textSize="16sp"
android:layout_marginBottom="8dp" />
<EditText
android:id="@+id/etInput2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="입력 2"
android:textSize="16sp"
android:layout_marginBottom="16dp" />
<TextView
android:id="@+id/tvRenamePreview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#F5F5F5"
android:padding="12dp"
android:text="미리보기 결과가 여기에 표시됩니다."
android:textColor="#555555"
android:textSize="13sp"
android:lineSpacingExtra="4dp" />
</LinearLayout>

View File

@ -4,28 +4,69 @@
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?android:attr/windowBackground">
<LinearLayout
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingBottom="8dp"
android:gravity="center_vertical">
<TextView
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/layoutTitleDefault"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="다운로드 보관함"
android:textSize="20sp"
android:textStyle="bold"
android:padding="16dp"/>
<TextView
android:id="@+id/btnOrganize"
style="@style/MaterialIconButtonStyle"
android:layout_width="48dp"
android:layout_height="48dp"
android:text="auto_awesome" />
</LinearLayout>
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingBottom="8dp"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvTitle"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="다운로드 보관함"
android:textSize="20sp"
android:textStyle="bold"
android:padding="16dp"/>
<TextView
android:id="@+id/btnSearch"
style="@style/MaterialIconButtonStyle"
android:layout_width="48dp"
android:layout_height="48dp"
android:text="search" />
<TextView
android:id="@+id/btnOrganize"
style="@style/MaterialIconButtonStyle"
android:layout_width="48dp"
android:layout_height="48dp"
android:text="auto_awesome" />
</LinearLayout>
<LinearLayout
android:id="@+id/layoutTitleSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingBottom="8dp"
android:gravity="center_vertical"
android:visibility="gone">
<EditText
android:id="@+id/etSearch"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="파일명 키워드 검색..."
android:background="@null"
android:padding="16dp"
android:singleLine="true"
android:imeOptions="actionSearch"/>
<TextView
android:id="@+id/btnCloseSearch"
style="@style/MaterialIconButtonStyle"
android:layout_width="48dp"
android:layout_height="48dp"
android:text="close" />
</LinearLayout>
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
@ -34,50 +75,43 @@
android:paddingHorizontal="8dp"
android:paddingBottom="8dp"
android:gravity="center_vertical">
<TextView
android:id="@+id/btnViewMode"
style="@style/MaterialIconButtonStyle"
android:layout_width="48dp"
android:layout_height="48dp"
android:text="view_headline" />
<Space
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="wrap_content"/>
<androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/spinnerFilter"
android:layout_width="90dp"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:spinnerMode="dropdown"
/>
android:spinnerMode="dropdown" />
<androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/spinnerSort"
android:layout_width="90dp"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:spinnerMode="dropdown"
/>
android:spinnerMode="dropdown" />
<TextView
android:id="@+id/tvSortOrder"
style="@style/MaterialIconButtonStyle"
android:layout_width="48dp"
android:layout_height="48dp"
android:text="arrow_downward" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerViewCompletedFiles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="0dp"
android:layout_weight="1"
android:padding="8dp"/>
<LinearLayout
android:id="@+id/layoutSelectionActions"
android:layout_width="match_parent"
@ -86,7 +120,15 @@
android:background="#EEEEEE"
android:padding="8dp"
android:visibility="gone">
<TextView
android:id="@+id/btnSelectAll"
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/btnCancelSelection"
android:layout_width="0dp"
@ -96,7 +138,15 @@
android:textStyle="bold"
android:gravity="center"
android:padding="12dp"/>
<TextView
android:id="@+id/btnRenameSelected"
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"
@ -106,7 +156,6 @@
android:textStyle="bold"
android:gravity="center"
android:padding="12dp"/>
<TextView
android:id="@+id/btnDeleteSelected"
android:layout_width="0dp"