...
This commit is contained in:
parent
5670ece56d
commit
f794e24f24
@ -154,8 +154,17 @@ dependencies {
|
|||||||
implementation("io.github.junkfood02.youtubedl-android:library:0.17.4")
|
implementation("io.github.junkfood02.youtubedl-android:library:0.17.4")
|
||||||
implementation("io.github.junkfood02.youtubedl-android:ffmpeg:0.17.4")
|
implementation("io.github.junkfood02.youtubedl-android:ffmpeg:0.17.4")
|
||||||
implementation("io.github.junkfood02.youtubedl-android:aria2c:0.17.4")
|
implementation("io.github.junkfood02.youtubedl-android:aria2c:0.17.4")
|
||||||
|
implementation("io.coil-kt:coil:2.5.0")
|
||||||
implementation ("com.squareup.okhttp3:logging-interceptor:4.11.0")
|
implementation ("com.squareup.okhttp3:logging-interceptor:4.11.0")
|
||||||
|
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3")
|
||||||
|
|
||||||
|
// Fragment KTX: Fragment에서 by viewModels() 델리게이트를 사용하기 위해 필수
|
||||||
|
implementation("androidx.fragment:fragment-ktx:1.8.1")
|
||||||
|
|
||||||
|
// Lifecycle KTX: viewLifecycleOwner.lifecycleScope 등을 사용하기 위해 필요
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
|
||||||
|
|
||||||
// implementation ("com.arthenica:ffmpeg-kit-full:4.5.LTS")
|
// implementation ("com.arthenica:ffmpeg-kit-full:4.5.LTS")
|
||||||
// implementation ("com.arthenica:ffmpeg-kit-full:6.0-2")
|
// implementation ("com.arthenica:ffmpeg-kit-full:6.0-2")
|
||||||
|
|
||||||
|
|||||||
@ -55,6 +55,16 @@ port.onMessage.addListener(response => {
|
|||||||
var type= response["type"];
|
var type= response["type"];
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case "fetchAllImages": {
|
||||||
|
const targetSrc = response["targetSrc"];
|
||||||
|
const imageUrls = findAllRelatedImages(targetSrc);
|
||||||
|
// 찾은 이미지 URL 목록을 'allImagesFound' 타입으로 네이티브 앱에 다시 전송
|
||||||
|
sendMessage({
|
||||||
|
type: "allImagesFound",
|
||||||
|
urls: imageUrls
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "search" : {
|
case "search" : {
|
||||||
var keyword = response["keyword"];
|
var keyword = response["keyword"];
|
||||||
if (location.href.search("bigkinds.or.kr") > -1) {
|
if (location.href.search("bigkinds.or.kr") > -1) {
|
||||||
@ -154,6 +164,45 @@ port.onMessage.addListener(response => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [신규 추가] 특정 이미지와 관련된 모든 이미지의 URL을 찾는 함수
|
||||||
|
* @param {string} targetSrc - 사용자가 길게 누른 이미지의 소스 URL
|
||||||
|
* @returns {string[]} - 찾아낸 이미지 URL의 배열
|
||||||
|
*/
|
||||||
|
function findAllRelatedImages(targetSrc) {
|
||||||
|
// 1. 페이지 내의 모든 'img' 태그를 찾습니다.
|
||||||
|
const allImages = Array.from(document.querySelectorAll('img'));
|
||||||
|
|
||||||
|
// 2. 사용자가 클릭한 이미지 엘리먼트를 찾습니다.
|
||||||
|
const targetImage = allImages.find(img => img.src === targetSrc);
|
||||||
|
|
||||||
|
if (!targetImage) {
|
||||||
|
// 클릭한 이미지를 찾지 못하면, 보이는 모든 이미지라도 반환
|
||||||
|
return allImages.map(img => img.src).filter(src => src && src.startsWith('http'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 가장 가까운 공통 부모 컨테이너를 찾습니다. (예: article, div.post-body 등)
|
||||||
|
// 이 로직은 웹사이트의 구조에 따라 고도화될 수 있습니다.
|
||||||
|
let parent = targetImage.parentElement;
|
||||||
|
for (let i = 0; i < 5; i++) { // 최대 5단계 위로 탐색
|
||||||
|
if (parent.querySelectorAll('img').length > 1) {
|
||||||
|
break; // 다른 이미지를 포함하는 부모를 찾으면 중단
|
||||||
|
}
|
||||||
|
if (parent.tagName.toLowerCase() === 'body') break;
|
||||||
|
parent = parent.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 해당 컨테이너 내의 모든 이미지 URL을 수집합니다.
|
||||||
|
const relatedImages = Array.from(parent.querySelectorAll('img'));
|
||||||
|
const validUrls = relatedImages
|
||||||
|
.map(img => img.src)
|
||||||
|
.filter(src => src && src.startsWith('http')); // 유효한 http(s) URL만 필터링
|
||||||
|
|
||||||
|
// 중복 제거 후 반환
|
||||||
|
return [...new Set(validUrls)];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (document.querySelector(".show_viewer") !== null) {
|
if (document.querySelector(".show_viewer") !== null) {
|
||||||
document.querySelector(".show_viewer").click();
|
document.querySelector(".show_viewer").click();
|
||||||
sendMessage({type: "SHOWVIEWER"});
|
sendMessage({type: "SHOWVIEWER"});
|
||||||
|
|||||||
@ -2,10 +2,18 @@ package bums.lunatic.launcher
|
|||||||
|
|
||||||
// ui/bookmark/BookmarkUploader.kt
|
// ui/bookmark/BookmarkUploader.kt
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import bums.lunatic.launcher.home.adapters.VoteResponse
|
||||||
|
import bums.lunatic.launcher.home.adapters.WebBookmark
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
@ -233,4 +241,227 @@ object BookmarkUploader {
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private val saveBookmarkWithContentUrl = "$baseUrl/api/bookmarks/with-content"
|
||||||
|
|
||||||
|
// ... loginAndGetToken, saveBookmarkWithOkHttp 함수 ...
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [신규 추가] 여러 이미지와 함께 북마크를 저장하는 함수
|
||||||
|
* @param pageUrl 원본 페이지의 URL
|
||||||
|
* @param imageUrls 업로드할 이미지들의 URL 목록
|
||||||
|
* @param comment 사용자가 작성한 코멘트
|
||||||
|
* @param visibility 공개 범위 (PUBLIC, MEMBERS, PRIVATE)
|
||||||
|
*/
|
||||||
|
fun saveBookmarkWithContent(
|
||||||
|
pageUrl: String,
|
||||||
|
imageUrls: List<String>,
|
||||||
|
comment: String,
|
||||||
|
visibility: String
|
||||||
|
) {
|
||||||
|
if (userJwtToken == null) {
|
||||||
|
println("🛑 저장을 위해 로그인이 필요합니다.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
// --- 1단계: URL 목록의 모든 이미지를 병렬로 다운로드 ---
|
||||||
|
val downloadedImages = imageUrls.map { imageUrl ->
|
||||||
|
async { downloadImage(imageUrl, pageUrl) }
|
||||||
|
}.awaitAll().filterNotNull() // 다운로드 성공한 것만 필터링
|
||||||
|
|
||||||
|
if (downloadedImages.isEmpty()) {
|
||||||
|
println("❌ 모든 이미지 다운로드에 실패했습니다.")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2단계: Multipart 요청 생성 ---
|
||||||
|
val bookmarkDataMap = mapOf(
|
||||||
|
"url" to pageUrl,
|
||||||
|
"bookmarkType" to "IMAGE", // 북마크 타입을 IMAGE로 지정
|
||||||
|
"userComment" to comment,
|
||||||
|
"visibility" to visibility
|
||||||
|
)
|
||||||
|
val bookmarkDataJson = Gson().toJson(bookmarkDataMap)
|
||||||
|
|
||||||
|
val multipartBodyBuilder = MultipartBody.Builder()
|
||||||
|
.setType(MultipartBody.FORM)
|
||||||
|
.addFormDataPart("bookmarkData", bookmarkDataJson)
|
||||||
|
|
||||||
|
// 다운로드된 각 이미지를 'files'라는 파트 이름으로 추가
|
||||||
|
downloadedImages.forEachIndexed { index, imageData ->
|
||||||
|
multipartBodyBuilder.addFormDataPart(
|
||||||
|
"files", // 서버의 @RequestPart("files")와 일치해야 함
|
||||||
|
"image_$index.jpg",
|
||||||
|
imageData.toRequestBody("image/jpeg".toMediaTypeOrNull())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val multipartBody = multipartBodyBuilder.build()
|
||||||
|
|
||||||
|
// --- 3단계: 서버에 업로드 ---
|
||||||
|
val formattedToken = "Bearer $userJwtToken"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(saveBookmarkWithContentUrl)
|
||||||
|
.addHeader("Authorization", formattedToken)
|
||||||
|
.post(multipartBody)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
OkHttpClientInstance.client.newCall(request).execute().use { response ->
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
println("✅ 여러 이미지와 함께 북마크 저장 성공: ${response.body?.string()}")
|
||||||
|
} else {
|
||||||
|
println("❌ 여러 이미지와 함께 북마크 저장 실패: ${response.code} ${response.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("🔥 북마크 저장(여러 이미지 포함) 중 오류 발생: ${e.message}")
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun login(userId: String, userPw: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val loginDataMap = mapOf("userId" to userId, "userPw" to userPw)
|
||||||
|
val json = Gson().toJson(loginDataMap)
|
||||||
|
val requestBody = json.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||||
|
val request = Request.Builder().url(loginUrl).post(requestBody).build()
|
||||||
|
|
||||||
|
val responseJson = OkHttpClientInstance.client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
println("❌ 로그인 실패: ${response.code} ${response.message}")
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
response.body?.string()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseJson != null) {
|
||||||
|
val type = object : TypeToken<Map<String, String>>() {}.type
|
||||||
|
val resultMap: Map<String, String> = Gson().fromJson(responseJson, type)
|
||||||
|
userJwtToken = resultMap["token"]
|
||||||
|
|
||||||
|
if (userJwtToken != null) {
|
||||||
|
println("✅ 로그인 성공! 토큰을 발급받았습니다.")
|
||||||
|
return@withContext true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println("❌ 로그인 실패: 응답에 토큰이 없습니다.")
|
||||||
|
return@withContext false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("🔥 로그인 중 오류 발생: ${e.message}")
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun getJwtToken(): String? = userJwtToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spring Page<> 응답과 일치하는 데이터 클래스
|
||||||
|
data class Page<T>(
|
||||||
|
val content: List<T>,
|
||||||
|
val totalElements: Long,
|
||||||
|
val number: Int,
|
||||||
|
val size: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
class BookmarkApiService {
|
||||||
|
|
||||||
|
private val baseUrl = "https://lunaticbum.kr"
|
||||||
|
private val bookmarksUrl = "$baseUrl/api/bookmarks/list?page=0&size=20" // 20개씩 가져오도록 설정
|
||||||
|
|
||||||
|
private val bookmarkLikeUrl = "$baseUrl/bookmarks/{bookmarkId}/like"
|
||||||
|
private val bookmarkUnlikeUrl = "$baseUrl/bookmarks/{bookmarkId}/unlike"
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버에서 북마크 목록을 가져오는 suspend 함수
|
||||||
|
* @return 성공 시 WebBookmark 리스트, 실패 시 null 반환
|
||||||
|
*/
|
||||||
|
suspend fun fetchBookmarks(): List<WebBookmark>? = withContext(Dispatchers.IO) {
|
||||||
|
val token = BookmarkUploader.getJwtToken() // 인증 토큰 가져오기
|
||||||
|
if (token == null) {
|
||||||
|
println("❌ 북마크 로딩 실패: 토큰이 없습니다.")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(bookmarksUrl)
|
||||||
|
.addHeader("Authorization", "Bearer $token")
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val responseJson = OkHttpClientInstance.client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
println("❌ 북마크 API 호출 실패: ${response.code} ${response.message}")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
response.body?.string()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseJson != null) {
|
||||||
|
val pageType = object : TypeToken<Page<WebBookmark>>() {}.type
|
||||||
|
val page: Page<WebBookmark> = Gson().fromJson(responseJson, pageType)
|
||||||
|
println("✅ 북마크 ${page.content.size}개 로딩 성공!")
|
||||||
|
return@withContext page.content // Page 객체에서 실제 북마크 목록만 반환
|
||||||
|
}
|
||||||
|
return@withContext null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("🔥 북마크 로딩 중 오류 발생: ${e.message}")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun likeBookmark(bookmarkId: String): VoteResponse? = withContext(Dispatchers.IO) {
|
||||||
|
val token = BookmarkUploader.getJwtToken() ?: return@withContext null
|
||||||
|
try {
|
||||||
|
// POST 요청 시 빈 RequestBody가 필요합니다.
|
||||||
|
val requestBody = "".toRequestBody("application/json".toMediaTypeOrNull())
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(bookmarkLikeUrl.replace("{bookmarkId}", bookmarkId))
|
||||||
|
.addHeader("Authorization", "Bearer $token")
|
||||||
|
.post(requestBody)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val responseJson = OkHttpClientInstance.client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@withContext null
|
||||||
|
response.body?.string()
|
||||||
|
}
|
||||||
|
return@withContext Gson().fromJson(responseJson, VoteResponse::class.java)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("🔥 좋아요 요청 중 오류 발생: ${e.message}")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 북마크에 '싫어요'를 요청하는 함수
|
||||||
|
*/
|
||||||
|
suspend fun unlikeBookmark(bookmarkId: String): VoteResponse? = withContext(Dispatchers.IO) {
|
||||||
|
// ... likeBookmark와 거의 동일한 로직, URL만 다름 ...
|
||||||
|
val token = BookmarkUploader.getJwtToken() ?: return@withContext null
|
||||||
|
try {
|
||||||
|
val requestBody = "".toRequestBody("application/json".toMediaTypeOrNull())
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(bookmarkUnlikeUrl.replace("{bookmarkId}", bookmarkId))
|
||||||
|
.addHeader("Authorization", "Bearer $token")
|
||||||
|
.post(requestBody)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val responseJson = OkHttpClientInstance.client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@withContext null
|
||||||
|
response.body?.string()
|
||||||
|
}
|
||||||
|
return@withContext Gson().fromJson(responseJson, VoteResponse::class.java)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("🔥 싫어요 요청 중 오류 발생: ${e.message}")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@ -57,6 +57,8 @@ import bums.lunatic.launcher.helpers.ForeGroundService
|
|||||||
import bums.lunatic.launcher.helpers.HeadsetActionButtonReceiver
|
import bums.lunatic.launcher.helpers.HeadsetActionButtonReceiver
|
||||||
import bums.lunatic.launcher.home.GeckoWeb
|
import bums.lunatic.launcher.home.GeckoWeb
|
||||||
import bums.lunatic.launcher.home.RssHome
|
import bums.lunatic.launcher.home.RssHome
|
||||||
|
import bums.lunatic.launcher.home.adapters.BookmarkDetailFragment
|
||||||
|
import bums.lunatic.launcher.home.adapters.BookmarkPagerFragment
|
||||||
import bums.lunatic.launcher.model.RssData
|
import bums.lunatic.launcher.model.RssData
|
||||||
import bums.lunatic.launcher.model.RssDataType
|
import bums.lunatic.launcher.model.RssDataType
|
||||||
import bums.lunatic.launcher.receiver.NLService
|
import bums.lunatic.launcher.receiver.NLService
|
||||||
@ -506,9 +508,10 @@ open class LauncherActivity : CommonActivity() {
|
|||||||
.replace(R.id.fragment_container, Novels())
|
.replace(R.id.fragment_container, Novels())
|
||||||
.commit()
|
.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.webtoons ->{
|
R.id.webtoons ->{
|
||||||
supportFragmentManager.beginTransaction()
|
supportFragmentManager.beginTransaction()
|
||||||
.replace(R.id.fragment_container, Webtoons())
|
.replace(R.id.fragment_container, BookmarkPagerFragment())
|
||||||
.commit()
|
.commit()
|
||||||
}
|
}
|
||||||
R.id.comics ->{
|
R.id.comics ->{
|
||||||
|
|||||||
@ -490,124 +490,21 @@ class GeckoWeb : BWebview {
|
|||||||
super.onExternalResponse(session, response)
|
super.onExternalResponse(session, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startBookmarkSaveProcess(pageUrl: String, mediaUrl: String) {
|
// private fun startBookmarkSaveProcess(pageUrl: String, mediaUrl: String) {
|
||||||
// 1. 로그인 상태 확인
|
// // 1. 로그인 상태 확인
|
||||||
if (BookmarkUploader.isUserLoggedIn) {
|
// if (BookmarkUploader.isUserLoggedIn) {
|
||||||
// 이미 로그인 되어 있으면, 바로 북마크 정보 입력창 표시
|
// // 이미 로그인 되어 있으면, 바로 북마크 정보 입력창 표시
|
||||||
showBookmarkDetailsDialog(pageUrl, mediaUrl)
|
// showBookmarkDetailsDialog(pageUrl, mediaUrl)
|
||||||
} else {
|
// } else {
|
||||||
// 로그인이 안 되어 있으면, 로그인 창을 먼저 표시
|
// // 로그인이 안 되어 있으면, 로그인 창을 먼저 표시
|
||||||
showLoginDialog {
|
// showLoginDialog {
|
||||||
// 로그인 성공 시 콜백으로 북마크 정보 입력창 표시
|
// // 로그인 성공 시 콜백으로 북마크 정보 입력창 표시
|
||||||
showBookmarkDetailsDialog(pageUrl, mediaUrl)
|
// showBookmarkDetailsDialog(pageUrl, mediaUrl)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자에게 로그인을 요청하는 다이얼로그를 표시하는 함수
|
|
||||||
* @param onLoginSuccess 로그인 성공 시 실행될 콜백 함수
|
|
||||||
*/
|
|
||||||
private fun showLoginDialog(onLoginSuccess: () -> Unit) {
|
|
||||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_login, null) // dialog_login.xml 레이아웃 필요
|
|
||||||
val userIdInput = view.findViewById<TextInputEditText>(R.id.et_user_id)
|
|
||||||
val userPwInput = view.findViewById<TextInputEditText>(R.id.et_user_pw)
|
|
||||||
|
|
||||||
AlertDialog.Builder(context)
|
|
||||||
.setTitle("로그인 필요")
|
|
||||||
.setMessage("북마크를 저장하려면 로그인이 필요합니다.")
|
|
||||||
.setView(view)
|
|
||||||
.setPositiveButton("로그인") { dialog, _ ->
|
|
||||||
val userId = userIdInput.text.toString().trim()
|
|
||||||
val userPw = userPwInput.text.toString().trim()
|
|
||||||
|
|
||||||
if (userId.isNotEmpty() && userPw.isNotEmpty()) {
|
|
||||||
// 입력된 정보로 로그인 시도
|
|
||||||
BookmarkUploader.loginAndGetToken(
|
|
||||||
userId = userId,
|
|
||||||
userPw = userPw,
|
|
||||||
onSuccess = {
|
|
||||||
Toast.makeText(context, "로그인 성공!", Toast.LENGTH_SHORT).show()
|
|
||||||
onLoginSuccess() // 로그인 성공 콜백 실행
|
|
||||||
},
|
|
||||||
onError = { errorMessage ->
|
|
||||||
Toast.makeText(context, "로그인 실패: $errorMessage", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Toast.makeText(context, "아이디와 비밀번호를 모두 입력해주세요.", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.setNegativeButton("취소", null)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 북마크 저장을 위해 사용자로부터 코멘트와 공개 여부를 입력받는 다이얼로그 (수정된 버전)
|
|
||||||
*/
|
|
||||||
private fun showBookmarkDetailsDialog(pageUrl: String, mediaUrl: String) {
|
|
||||||
val context = this@GeckoWeb.context ?: return
|
|
||||||
val visibilityOptions = arrayOf("PUBLIC", "MEMBERS", "PRIVATE")
|
|
||||||
|
|
||||||
// --- UI 요소들을 담을 컨테이너 레이아웃 ---
|
|
||||||
val containerLayout = LinearLayout(context).apply {
|
|
||||||
orientation = LinearLayout.VERTICAL
|
|
||||||
val padding = (20 * resources.displayMetrics.density).toInt() // 패딩 값 조정
|
|
||||||
setPadding(padding, padding, padding, padding)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 1. 코멘트 입력 EditText ---
|
|
||||||
val commentInput = EditText(context).apply {
|
|
||||||
hint = "코멘트를 입력하세요 (선택)"
|
|
||||||
}
|
|
||||||
containerLayout.addView(commentInput) // 컨테이너에 추가
|
|
||||||
|
|
||||||
// --- 2. 공개 범위 선택 RadioGroup ---
|
|
||||||
val radioGroup = RadioGroup(context).apply {
|
|
||||||
orientation = RadioGroup.VERTICAL
|
|
||||||
val marginTop = (16 * resources.displayMetrics.density).toInt()
|
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
|
||||||
).apply {
|
|
||||||
this.topMargin = marginTop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// visibilityOptions 배열을 기반으로 라디오 버튼 동적 생성
|
|
||||||
visibilityOptions.forEachIndexed { index, option ->
|
|
||||||
val radioButton = RadioButton(context).apply {
|
|
||||||
text = option
|
|
||||||
id = index // 각 라디오 버튼에 고유 ID 부여
|
|
||||||
}
|
|
||||||
radioGroup.addView(radioButton)
|
|
||||||
}
|
|
||||||
radioGroup.check(0) // 첫 번째 옵션(PUBLIC)을 기본값으로 선택
|
|
||||||
|
|
||||||
containerLayout.addView(radioGroup) // 컨테이너에 RadioGroup 추가
|
|
||||||
|
|
||||||
|
|
||||||
// --- 다이얼로그 생성 ---
|
|
||||||
AlertDialog.Builder(context)
|
|
||||||
.setTitle("북마크 저장")
|
|
||||||
.setView(containerLayout) // 직접 만든 레이아웃을 View로 설정
|
|
||||||
.setPositiveButton("저장") { _, _ ->
|
|
||||||
val comment = commentInput.text.toString()
|
|
||||||
|
|
||||||
// 선택된 라디오 버튼의 텍스트를 가져와 visibility로 사용
|
|
||||||
val checkedRadioButtonId = radioGroup.checkedRadioButtonId
|
|
||||||
val visibility = (radioGroup.findViewById<RadioButton>(checkedRadioButtonId))?.text.toString()
|
|
||||||
|
|
||||||
if (visibility.isNotEmpty()) {
|
|
||||||
BookmarkUploader.saveBookmarkWithImageUpload(pageUrl, mediaUrl, comment, visibility)
|
|
||||||
Toast.makeText(context, "[$visibility] 북마크로 저장했습니다.", Toast.LENGTH_SHORT).show()
|
|
||||||
} else {
|
|
||||||
Toast.makeText(context, "공개 범위가 선택되지 않았습니다.", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.setNegativeButton("취소", null)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onContextMenu(
|
override fun onContextMenu(
|
||||||
session: GeckoSession,
|
session: GeckoSession,
|
||||||
@ -621,32 +518,41 @@ class GeckoWeb : BWebview {
|
|||||||
var mediaUrl = element.srcUri ?: return // 이미지 또는 비디오 URL
|
var mediaUrl = element.srcUri ?: return // 이미지 또는 비디오 URL
|
||||||
|
|
||||||
// 컨텍스트 메뉴에 '북마크 저장' 옵션 추가
|
// 컨텍스트 메뉴에 '북마크 저장' 옵션 추가
|
||||||
val menuItems = mutableListOf<String>()
|
val menuItems = listOf("이 미디어만 북마크", "페이지의 모든 이미지 북마크", "다운로드")
|
||||||
|
|
||||||
val isMediaElement = element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_IMAGE ||
|
val isMediaElement = element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_IMAGE ||
|
||||||
element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_VIDEO
|
element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_VIDEO
|
||||||
|
if (!isMediaElement) {
|
||||||
if (isMediaElement) {
|
|
||||||
menuItems.add("이 미디어 북마크하기")
|
|
||||||
menuItems.add("이 미디어 다운로드하기")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (menuItems.isEmpty()) {
|
|
||||||
super.onContextMenu(session, screenX, screenY, element)
|
super.onContextMenu(session, screenX, screenY, element)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// if (isMediaElement) {
|
||||||
|
// menuItems.add("이 미디어 북마크하기")
|
||||||
|
// menuItems.add("이 미디어 다운로드하기")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (menuItems.isEmpty()) {
|
||||||
|
// super.onContextMenu(session, screenX, screenY, element)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
fun show(pageUrl : String,mediaUrl : String) {
|
fun show(pageUrl : String,mediaUrl : String) {
|
||||||
AlertDialog.Builder(context)
|
AlertDialog.Builder(context)
|
||||||
.setTitle("작업 선택")
|
.setTitle("작업 선택")
|
||||||
.setItems(menuItems.toTypedArray()) { _, which ->
|
.setItems(menuItems.toTypedArray()) { _, which ->
|
||||||
when (menuItems[which]) {
|
when (menuItems[which]) {
|
||||||
"이 미디어 북마크하기" -> {
|
"이 미디어만 북마크" -> {
|
||||||
// 북마크 저장 프로세스 시작
|
// 기존 로직: 단일 이미지 북마크 프로세스 시작
|
||||||
startBookmarkSaveProcess(pageUrl, mediaUrl)
|
startBookmarkSaveProcessForSingleImage(pageUrl, mediaUrl)
|
||||||
}
|
}
|
||||||
"이 미디어 다운로드하기" -> {
|
"페이지의 모든 이미지 북마크" -> {
|
||||||
// 기존의 파일 다운로드 로직 실행
|
// [신규 로직] WebExtension에 모든 이미지 URL 요청
|
||||||
val finalMediaUrl = if (mediaUrl.contains("dcimg")) replaceDcUrl(mediaUrl) else mediaUrl
|
val message = JSONObject()
|
||||||
CommonUtils.downloadFileWithOkHttp(context, Uri.parse(pageUrl), finalMediaUrl)
|
message.put("type", "fetchAllImages")
|
||||||
|
message.put("targetSrc", mediaUrl) // 기준이 되는 이미지 URL 전달
|
||||||
|
mPort?.postMessage(message)
|
||||||
|
}
|
||||||
|
"다운로드" -> {
|
||||||
|
CommonUtils.downloadFileWithOkHttp(context, Uri.parse(pageUrl), mediaUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -681,7 +587,138 @@ class GeckoWeb : BWebview {
|
|||||||
}
|
}
|
||||||
super.onContextMenu(session, screenX, screenY, element)
|
super.onContextMenu(session, screenX, screenY, element)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자에게 로그인을 요청하는 다이얼로그를 표시하는 함수
|
||||||
|
* @param onLoginSuccess 로그인 성공 시 실행될 콜백 함수
|
||||||
|
*/
|
||||||
|
private fun showLoginDialog(onLoginSuccess: () -> Unit) {
|
||||||
|
val view = LayoutInflater.from(context).inflate(R.layout.dialog_login, null) // dialog_login.xml 레이아웃 필요
|
||||||
|
val userIdInput = view.findViewById<TextInputEditText>(R.id.et_user_id)
|
||||||
|
val userPwInput = view.findViewById<TextInputEditText>(R.id.et_user_pw)
|
||||||
|
|
||||||
|
AlertDialog.Builder(context)
|
||||||
|
.setTitle("로그인 필요")
|
||||||
|
.setMessage("북마크를 저장하려면 로그인이 필요합니다.")
|
||||||
|
.setView(view)
|
||||||
|
.setPositiveButton("로그인") { dialog, _ ->
|
||||||
|
val userId = userIdInput.text.toString().trim()
|
||||||
|
val userPw = userPwInput.text.toString().trim()
|
||||||
|
|
||||||
|
if (userId.isNotEmpty() && userPw.isNotEmpty()) {
|
||||||
|
// 입력된 정보로 로그인 시도
|
||||||
|
BookmarkUploader.loginAndGetToken(
|
||||||
|
userId = userId,
|
||||||
|
userPw = userPw,
|
||||||
|
onSuccess = {
|
||||||
|
Toast.makeText(context, "로그인 성공!", Toast.LENGTH_SHORT).show()
|
||||||
|
onLoginSuccess() // 로그인 성공 콜백 실행
|
||||||
|
},
|
||||||
|
onError = { errorMessage ->
|
||||||
|
Toast.makeText(context, "로그인 실패: $errorMessage", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "아이디와 비밀번호를 모두 입력해주세요.", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.setNegativeButton("취소", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 북마크 저장을 위해 사용자로부터 코멘트와 공개 여부를 입력받는 다이얼로그 (수정된 버전)
|
||||||
|
*/
|
||||||
|
private fun showBookmarkDetailsDialog(pageUrl: String, mediaUrls: List<String>) {
|
||||||
|
val context = this@GeckoWeb.context ?: return
|
||||||
|
val visibilityOptions = arrayOf("PUBLIC", "MEMBERS", "PRIVATE")
|
||||||
|
|
||||||
|
// --- UI 요소들을 담을 컨테이너 레이아웃 ---
|
||||||
|
val containerLayout = LinearLayout(context).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val padding = (20 * resources.displayMetrics.density).toInt() // 패딩 값 조정
|
||||||
|
setPadding(padding, padding, padding, padding)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1. 코멘트 입력 EditText ---
|
||||||
|
val commentInput = EditText(context).apply {
|
||||||
|
hint = "코멘트를 입력하세요 (선택)"
|
||||||
|
}
|
||||||
|
containerLayout.addView(commentInput) // 컨테이너에 추가
|
||||||
|
|
||||||
|
// --- 2. 공개 범위 선택 RadioGroup ---
|
||||||
|
val radioGroup = RadioGroup(context).apply {
|
||||||
|
orientation = RadioGroup.VERTICAL
|
||||||
|
val marginTop = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply {
|
||||||
|
this.topMargin = marginTop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// visibilityOptions 배열을 기반으로 라디오 버튼 동적 생성
|
||||||
|
visibilityOptions.forEachIndexed { index, option ->
|
||||||
|
val radioButton = RadioButton(context).apply {
|
||||||
|
text = option
|
||||||
|
id = index // 각 라디오 버튼에 고유 ID 부여
|
||||||
|
}
|
||||||
|
radioGroup.addView(radioButton)
|
||||||
|
}
|
||||||
|
radioGroup.check(0) // 첫 번째 옵션(PUBLIC)을 기본값으로 선택
|
||||||
|
|
||||||
|
containerLayout.addView(radioGroup) // 컨테이너에 RadioGroup 추가
|
||||||
|
|
||||||
|
|
||||||
|
// --- 다이얼로그 생성 ---
|
||||||
|
AlertDialog.Builder(context)
|
||||||
|
.setTitle("북마크 저장")
|
||||||
|
.setView(containerLayout) // 직접 만든 레이아웃을 View로 설정
|
||||||
|
.setPositiveButton("저장") { _, _ ->
|
||||||
|
val comment = commentInput.text.toString()
|
||||||
|
|
||||||
|
// 선택된 라디오 버튼의 텍스트를 가져와 visibility로 사용
|
||||||
|
val checkedRadioButtonId = radioGroup.checkedRadioButtonId
|
||||||
|
val visibility = (radioGroup.findViewById<RadioButton>(checkedRadioButtonId))?.text.toString()
|
||||||
|
|
||||||
|
if (visibility.isNotEmpty()) {
|
||||||
|
BookmarkUploader.saveBookmarkWithContent(pageUrl, mediaUrls, comment, visibility)
|
||||||
|
Toast.makeText(context, "[$visibility] 북마크로 ${mediaUrls.size}개 이미지를 저장했습니다.", Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "공개 범위가 선택되지 않았습니다.", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.setNegativeButton("취소", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startBookmarkSaveProcessForSingleImage(pageUrl: String, mediaUrl: String) {
|
||||||
|
if (BookmarkUploader.isUserLoggedIn) {
|
||||||
|
showBookmarkDetailsDialog(pageUrl, listOf(mediaUrl)) // URL을 리스트로 감싸서 전달
|
||||||
|
} else {
|
||||||
|
showLoginDialog {
|
||||||
|
showBookmarkDetailsDialog(pageUrl, listOf(mediaUrl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [신규 추가] 여러 이미지 북마크 저장 프로세스를 시작하는 함수
|
||||||
|
private fun startBookmarkSaveProcessForMultipleImages(pageUrl: String, mediaUrls: List<String>) {
|
||||||
|
if (BookmarkUploader.isUserLoggedIn) {
|
||||||
|
showBookmarkDetailsDialog(pageUrl, mediaUrls)
|
||||||
|
} else {
|
||||||
|
showLoginDialog {
|
||||||
|
showBookmarkDetailsDialog(pageUrl, mediaUrls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// [신규 추가] 단일 이미지 북마크 저장 프로세스를 시작하는 함수
|
||||||
|
|
||||||
|
|
||||||
val progressDelegate = object : GeckoSession.ProgressDelegate {
|
val progressDelegate = object : GeckoSession.ProgressDelegate {
|
||||||
override fun onSecurityChange(
|
override fun onSecurityChange(
|
||||||
session: GeckoSession,
|
session: GeckoSession,
|
||||||
@ -888,6 +925,11 @@ class GeckoWeb : BWebview {
|
|||||||
var lPortMessage =
|
var lPortMessage =
|
||||||
Gson().fromJson<PortMessage>(message, PortMessage::class.java)
|
Gson().fromJson<PortMessage>(message, PortMessage::class.java)
|
||||||
when(lPortMessage.type) {
|
when(lPortMessage.type) {
|
||||||
|
"allImagesFound" -> {
|
||||||
|
lastedUrl?.let {
|
||||||
|
startBookmarkSaveProcessForMultipleImages(it, lPortMessage.urls)
|
||||||
|
}
|
||||||
|
}
|
||||||
"SINGLE_IMAGE_DATA"-> {
|
"SINGLE_IMAGE_DATA"-> {
|
||||||
if (lPortMessage.imgSrc != null && lPortMessage.base64Data != null) {
|
if (lPortMessage.imgSrc != null && lPortMessage.base64Data != null) {
|
||||||
// *** 핵심: 수신한 Base64 데이터를 커스텀 캐시에 저장 ***
|
// *** 핵심: 수신한 Base64 데이터를 커스텀 캐시에 저장 ***
|
||||||
|
|||||||
@ -0,0 +1,400 @@
|
|||||||
|
package bums.lunatic.launcher.home.adapters
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||||
|
import bums.lunatic.launcher.BookmarkApiService
|
||||||
|
import bums.lunatic.launcher.BookmarkUploader
|
||||||
|
import bums.lunatic.launcher.R
|
||||||
|
import coil.load
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
// bums.lunatic.launcher/ui/BookmarkPagerFragment.kt
|
||||||
|
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
|
import bums.lunatic.launcher.utils.Blog
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class BookmarkPagerFragment : Fragment() {
|
||||||
|
|
||||||
|
// by viewModels() 델리게이트를 사용해 ViewModel 인스턴스 생성
|
||||||
|
private val viewModel: BookmarkViewModel by viewModels()
|
||||||
|
|
||||||
|
private lateinit var viewPager: ViewPager2
|
||||||
|
private lateinit var loginLayout: LinearLayout
|
||||||
|
private lateinit var progressBar: ProgressBar
|
||||||
|
private lateinit var errorTextView: TextView
|
||||||
|
private lateinit var userIdInput: EditText
|
||||||
|
private lateinit var userPwInput: EditText
|
||||||
|
private lateinit var loginButton: Button
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
return inflater.inflate(R.layout.fragment_bookmark_pager, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
// 뷰 초기화
|
||||||
|
viewPager = view.findViewById(R.id.viewPager)
|
||||||
|
loginLayout = view.findViewById(R.id.loginLayout)
|
||||||
|
progressBar = view.findViewById(R.id.progressBar)
|
||||||
|
errorTextView = view.findViewById(R.id.errorTextView)
|
||||||
|
userIdInput = view.findViewById(R.id.userIdInput)
|
||||||
|
userPwInput = view.findViewById(R.id.userPwInput)
|
||||||
|
loginButton = view.findViewById(R.id.loginButton)
|
||||||
|
|
||||||
|
// 로그인 버튼 클릭 리스너 설정
|
||||||
|
loginButton.setOnClickListener {
|
||||||
|
val userId = userIdInput.text.toString().trim()
|
||||||
|
val userPw = userPwInput.text.toString().trim()
|
||||||
|
if (userId.isNotEmpty() && userPw.isNotEmpty()) {
|
||||||
|
viewModel.login(userId, userPw)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "아이디와 비밀번호를 입력해주세요.", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewModel의 UI 상태를 관찰하고, 상태에 따라 UI 업데이트
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
viewModel.uiState.collect { state ->
|
||||||
|
updateUi(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateUi(state: UiState) {
|
||||||
|
// 모든 뷰를 일단 숨김
|
||||||
|
loginLayout.isVisible = false
|
||||||
|
viewPager.isVisible = false
|
||||||
|
progressBar.isVisible = false
|
||||||
|
errorTextView.isVisible = false
|
||||||
|
|
||||||
|
// 상태에 따라 필요한 뷰만 표시
|
||||||
|
when (state) {
|
||||||
|
is UiState.LoggedOut -> {
|
||||||
|
loginLayout.isVisible = true
|
||||||
|
}
|
||||||
|
is UiState.Loading -> {
|
||||||
|
progressBar.isVisible = true
|
||||||
|
}
|
||||||
|
is UiState.Success -> {
|
||||||
|
viewPager.isVisible = true
|
||||||
|
setupViewPager(state.bookmarks)
|
||||||
|
}
|
||||||
|
is UiState.Error -> {
|
||||||
|
errorTextView.isVisible = true
|
||||||
|
errorTextView.text = state.message
|
||||||
|
// 에러 발생 시 잠시 후 다시 로그인 폼을 보여줄 수 있음
|
||||||
|
if(state.message.contains("아이디")) {
|
||||||
|
loginLayout.isVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupViewPager(bookmarks: List<WebBookmark>) {
|
||||||
|
viewPager.adapter = BookmarkPagerAdapter(this, bookmarks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Visibility {
|
||||||
|
PUBLIC, // 전체 공개
|
||||||
|
MEMBERS, // 회원 공개
|
||||||
|
PRIVATE // 비공개 (나만 보기)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class MetadataStatus {
|
||||||
|
PENDING, // 처리 대기 중
|
||||||
|
COMPLETED, // 처리 완료
|
||||||
|
FAILED // 처리 실패
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class BookmarkType {
|
||||||
|
URL, // 기존 웹 페이지 링크
|
||||||
|
IMAGE, // 하나 이상의 이미지
|
||||||
|
VIDEO // 하나 이상의 비디오
|
||||||
|
}
|
||||||
|
data class WebBookmark(
|
||||||
|
var id: String? = null,
|
||||||
|
var userId: String, // 누가 저장했는지
|
||||||
|
var url: String?, // 원본 페이지 URL
|
||||||
|
// [신규] 북마크 타입 (URL, IMAGE, VIDEO 등)
|
||||||
|
var bookmarkType: String = BookmarkType.URL.name,
|
||||||
|
// [신규] 콘텐츠 URL 목록 (웹페이지는 1개, 이미지는 여러 개 가능)
|
||||||
|
var contentUrls: List<String> = emptyList(),
|
||||||
|
var title: String? = null, // 페이지 제목
|
||||||
|
var description: String? = null, // 페이지 요약 (메타 태그)
|
||||||
|
var thumbnailUrl: String? = null, // 페이지 썸네일 (메타 태그)
|
||||||
|
var userComment: String? = null, // 사용자가 남긴 짧은 의견
|
||||||
|
var tags: List<String>? = null, // 태그 (예: #kotlin, #spring)
|
||||||
|
var savedAt: Long = System.currentTimeMillis(), // 저장 시간
|
||||||
|
// [신규 추가] 공개 범위 필드. 기본값은 PRIVATE.
|
||||||
|
var visibility: String = Visibility.PRIVATE.name,
|
||||||
|
// [신규 추가] 좋아요/싫어요 카운트 필드
|
||||||
|
var voteCount: Long = 0,
|
||||||
|
var unlikeCount: Long = 0,
|
||||||
|
var userSelectedImageUrl: String? = null,
|
||||||
|
var metadataStatus: String = MetadataStatus.PENDING.name,
|
||||||
|
|
||||||
|
// [추가] 카테고리 필드 (하나만 가질 수 있도록 String으로 설정)
|
||||||
|
var category: String? = null,
|
||||||
|
val displayImageUrl: String
|
||||||
|
)
|
||||||
|
class BookmarkPagerAdapter(fragment: Fragment, private val bookmarks: List<WebBookmark>) : FragmentStateAdapter(fragment) {
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = bookmarks.size
|
||||||
|
|
||||||
|
override fun createFragment(position: Int): Fragment {
|
||||||
|
// 각 포지션에 맞는 북마크 데이터를 담아
|
||||||
|
// BookmarkDetailFragment 인스턴스를 생성하여 반환합니다.
|
||||||
|
return BookmarkDetailFragment.newInstance(bookmarks[position])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BookmarkImageAdapter(private val imageUrls: List<String>) : RecyclerView.Adapter<BookmarkImageAdapter.ImageViewHolder>() {
|
||||||
|
init {
|
||||||
|
Blog.LOGE("imageUrls >>> ${imageUrls}")
|
||||||
|
}
|
||||||
|
class ImageViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
val imageView: ImageView = view.findViewById(R.id.bookmarkImageView)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageViewHolder {
|
||||||
|
val view = LayoutInflater.from(parent.context)
|
||||||
|
.inflate(R.layout.item_bookmark_image, parent, false)
|
||||||
|
return ImageViewHolder(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ImageViewHolder, position: Int) {
|
||||||
|
val imageUrl = imageUrls[position]
|
||||||
|
// Coil을 사용해 URL로부터 이미지를 로드하고 ImageView에 표시합니다.
|
||||||
|
Blog.LOGE("imageUrl >>> ${imageUrl}")
|
||||||
|
holder.imageView.load("https://lunaticbum.kr$imageUrl") {
|
||||||
|
crossfade(true)
|
||||||
|
placeholder(R.drawable.ic_launcher) // 로딩 중 보여줄 이미지 (ic_placeholder.xml 같은 drawable 필요)
|
||||||
|
error(R.drawable.ic_news) // 에러 시 보여줄 이미지 (ic_error.xml 같은 drawable 필요)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = imageUrls.size
|
||||||
|
}
|
||||||
|
|
||||||
|
class BookmarkDetailFragment : Fragment() {
|
||||||
|
|
||||||
|
private lateinit var bookmark: WebBookmark
|
||||||
|
private val viewModel: BookmarkViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
// newInstance를 통해 전달된 북마크 데이터를 arguments에서 꺼냅니다.
|
||||||
|
arguments?.getString(ARG_BOOKMARK)?.let {
|
||||||
|
bookmark = Gson().fromJson(it, WebBookmark::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
// XML 레이아웃을 inflate 합니다.
|
||||||
|
return inflater.inflate(R.layout.fragment_bookmark_detail, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
// View들을 초기화하고 북마크 데이터를 바인딩합니다.
|
||||||
|
val titleTextView = view.findViewById<TextView>(R.id.bookmarkTitle)
|
||||||
|
val commentTextView = view.findViewById<TextView>(R.id.bookmarkComment)
|
||||||
|
val recyclerView = view.findViewById<RecyclerView>(R.id.contentRecyclerView)
|
||||||
|
|
||||||
|
titleTextView.text = bookmark.title ?: "제목 없음"
|
||||||
|
commentTextView.text = bookmark.userComment ?: ""
|
||||||
|
commentTextView.visibility = if (bookmark.userComment.isNullOrBlank()) View.GONE else View.VISIBLE
|
||||||
|
|
||||||
|
val likeButton = view.findViewById<Button>(R.id.likeButton)
|
||||||
|
val unlikeButton = view.findViewById<Button>(R.id.unlikeButton)
|
||||||
|
val commentButton = view.findViewById<Button>(R.id.commentButton)
|
||||||
|
val likeCountText = view.findViewById<TextView>(R.id.likeCountText)
|
||||||
|
val unlikeCountText = view.findViewById<TextView>(R.id.unlikeCountText)
|
||||||
|
|
||||||
|
// 초기 카운트 설정
|
||||||
|
likeCountText.text = bookmark.voteCount.toString()
|
||||||
|
unlikeCountText.text = bookmark.unlikeCount.toString()
|
||||||
|
|
||||||
|
// 버튼 클릭 리스너 설정
|
||||||
|
likeButton.setOnClickListener {
|
||||||
|
bookmark.id?.let { viewModel.likeBookmark(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
unlikeButton.setOnClickListener {
|
||||||
|
bookmark.id?.let { viewModel.unlikeBookmark(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
commentButton.setOnClickListener {
|
||||||
|
// TODO: 댓글 화면(BottomSheetDialog 또는 새 Fragment)을 띄우는 로직 구현
|
||||||
|
Toast.makeText(context, "댓글 기능 구현 예정", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI 상태가 변경될 때마다(예: 다른 사용자가 좋아요를 눌렀을 때) 카운트 업데이트
|
||||||
|
// 이 코드는 ViewPager 스와이프 시에도 현재 북마크의 최신 카운트를 반영해줍니다.
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
viewModel.uiState.collect { state ->
|
||||||
|
if (state is UiState.Success) {
|
||||||
|
// 현재 프래그먼트의 북마크 ID와 일치하는 최신 데이터를 찾음
|
||||||
|
val latestBookmark = state.bookmarks.find { it.id == bookmark.id }
|
||||||
|
latestBookmark?.let {
|
||||||
|
likeCountText.text = it.voteCount.toString()
|
||||||
|
unlikeCountText.text = it.unlikeCount.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecyclerView 설정
|
||||||
|
recyclerView.layoutManager = LinearLayoutManager(context)
|
||||||
|
// contentUrls에 있는 이미지 목록으로 어댑터를 생성하고 RecyclerView에 연결합니다.
|
||||||
|
recyclerView.adapter = BookmarkImageAdapter(bookmark.contentUrls)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_BOOKMARK = "bookmark_json"
|
||||||
|
|
||||||
|
// Fragment 생성 시 데이터를 안전하게 전달하기 위한 newInstance 패턴
|
||||||
|
@JvmStatic
|
||||||
|
fun newInstance(bookmark: WebBookmark) =
|
||||||
|
BookmarkDetailFragment().apply {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
// 북마크 객체를 JSON 문자열로 변환하여 Bundle에 담습니다.
|
||||||
|
putString(ARG_BOOKMARK, Gson().toJson(bookmark))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI 상태를 나타내는 sealed class
|
||||||
|
sealed class UiState {
|
||||||
|
object LoggedOut : UiState() // 로그아웃 상태 (로그인 폼 보여줌)
|
||||||
|
object Loading : UiState() // 로딩 중 (프로그레스바 보여줌)
|
||||||
|
data class Success(val bookmarks: List<WebBookmark>) : UiState() // 성공 (ViewPager 보여줌)
|
||||||
|
data class Error(val message: String) : UiState() // 에러 (에러 메시지 보여줌)
|
||||||
|
}
|
||||||
|
|
||||||
|
class BookmarkViewModel : ViewModel() {
|
||||||
|
|
||||||
|
private val apiService = BookmarkApiService()
|
||||||
|
|
||||||
|
// UI 상태를 관찰할 수 있는 StateFlow
|
||||||
|
private val _uiState = MutableStateFlow<UiState>(UiState.LoggedOut)
|
||||||
|
val uiState: StateFlow<UiState> = _uiState
|
||||||
|
|
||||||
|
init {
|
||||||
|
// ViewModel 생성 시 로그인 상태 즉시 확인
|
||||||
|
checkLoginStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkLoginStatus() {
|
||||||
|
if (BookmarkUploader.isUserLoggedIn) {
|
||||||
|
fetchBookmarks() // 이미 로그인 되어있으면 바로 데이터 로딩
|
||||||
|
} else {
|
||||||
|
_uiState.value = UiState.LoggedOut // 로그아웃 상태 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login(userId: String, userPw: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = UiState.Loading // 로딩 상태로 변경
|
||||||
|
val success = BookmarkUploader.login(userId, userPw)
|
||||||
|
if (success) {
|
||||||
|
fetchBookmarks() // 로그인 성공 시 북마크 로딩
|
||||||
|
} else {
|
||||||
|
_uiState.value = UiState.Error("아이디 또는 비밀번호를 확인해주세요.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchBookmarks() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = UiState.Loading // 로딩 상태로 변경
|
||||||
|
val bookmarks = apiService.fetchBookmarks()
|
||||||
|
if (bookmarks != null) {
|
||||||
|
if (bookmarks.isEmpty()){
|
||||||
|
_uiState.value = UiState.Error("저장된 북마크가 없습니다.")
|
||||||
|
} else {
|
||||||
|
_uiState.value = UiState.Success(bookmarks) // 성공 상태와 데이터 전달
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.value = UiState.Error("북마크를 불러오는데 실패했습니다.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun likeBookmark(bookmarkId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val response = apiService.likeBookmark(bookmarkId)
|
||||||
|
response?.let {
|
||||||
|
updateBookmarkCounts(bookmarkId, it.voteCount, it.unlikeCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unlikeBookmark(bookmarkId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val response = apiService.unlikeBookmark(bookmarkId)
|
||||||
|
response?.let {
|
||||||
|
updateBookmarkCounts(bookmarkId, it.voteCount, it.unlikeCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 북마크 리스트에서 특정 북마크의 카운트를 업데이트하고 UI를 갱신하는 함수
|
||||||
|
*/
|
||||||
|
private fun updateBookmarkCounts(bookmarkId: String, newVoteCount: Long, newUnlikeCount: Long) {
|
||||||
|
val currentState = _uiState.value
|
||||||
|
if (currentState is UiState.Success) {
|
||||||
|
// 현재 리스트에서 해당 북마크를 찾습니다.
|
||||||
|
val updatedList = currentState.bookmarks.map { bookmark ->
|
||||||
|
if (bookmark.id == bookmarkId) {
|
||||||
|
// 카운트가 변경된 새 객체를 생성하여 교체합니다.
|
||||||
|
bookmark.copy(voteCount = newVoteCount, unlikeCount = newUnlikeCount)
|
||||||
|
} else {
|
||||||
|
bookmark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 업데이트된 리스트로 UI 상태를 갱신합니다.
|
||||||
|
_uiState.value = UiState.Success(updatedList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
data class VoteResponse(
|
||||||
|
val voteCount: Long,
|
||||||
|
val unlikeCount: Long
|
||||||
|
)
|
||||||
@ -15,7 +15,7 @@ class PortMessage {
|
|||||||
var privates : ArrayList<RssData>? = null
|
var privates : ArrayList<RssData>? = null
|
||||||
var currentPage : String? = null
|
var currentPage : String? = null
|
||||||
var cookies : String? = null
|
var cookies : String? = null
|
||||||
|
var urls : List<String> = emptyList()
|
||||||
var imgSrc: String? = null
|
var imgSrc: String? = null
|
||||||
var base64Data: String? = null
|
var base64Data: String? = null
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
app/src/main/res/drawable/ic_comment.png
Normal file
BIN
app/src/main/res/drawable/ic_comment.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 231 B |
BIN
app/src/main/res/drawable/ic_thumb_down.png
Normal file
BIN
app/src/main/res/drawable/ic_thumb_down.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 309 B |
BIN
app/src/main/res/drawable/ic_thumb_up.png
Normal file
BIN
app/src/main/res/drawable/ic_thumb_up.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 310 B |
73
app/src/main/res/layout/fragment_bookmark_detail.xml
Normal file
73
app/src/main/res/layout/fragment_bookmark_detail.xml
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bookmarkTitle"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceHeadline5"
|
||||||
|
tools:text="북마크 제목" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bookmarkComment"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceBody1"
|
||||||
|
tools:text="사용자가 남긴 코멘트입니다." />
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/contentRecyclerView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginTop="16dp" />
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingVertical="8dp"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:background="?attr/colorSurfaceContainer"> <com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/likeButton"
|
||||||
|
style="?attr/materialIconButtonStyle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:icon="@drawable/ic_thumb_up" /> <TextView
|
||||||
|
android:id="@+id/likeCountText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
tools:text="12" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/unlikeButton"
|
||||||
|
style="?attr/materialIconButtonStyle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
app:icon="@drawable/ic_thumb_down" /> <TextView
|
||||||
|
android:id="@+id/unlikeCountText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
tools:text="3" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/commentButton"
|
||||||
|
style="?attr/materialIconButtonStyle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:icon="@drawable/ic_comment" /> </LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
63
app/src/main/res/layout/fragment_bookmark_pager.xml
Normal file
63
app/src/main/res/layout/fragment_bookmark_pager.xml
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<androidx.viewpager2.widget.ViewPager2
|
||||||
|
android:id="@+id/viewPager"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible"/>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/loginLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:padding="24dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="gone">
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/userIdInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="아이디"
|
||||||
|
android:inputType="text"/>
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/userPwInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:hint="비밀번호"
|
||||||
|
android:inputType="textPassword"/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/loginButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="로그인"/>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressBar"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="gone"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/errorTextView"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:text="에러 메시지"
|
||||||
|
tools:visibility="gone"/>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
8
app/src/main/res/layout/item_bookmark_image.xml
Normal file
8
app/src/main/res/layout/item_bookmark_image.xml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<ImageView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/bookmarkImageView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:adjustViewBounds="true"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:scaleType="fitCenter" />
|
||||||
Loading…
x
Reference in New Issue
Block a user