diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 56c1e4c..7e99dc8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -85,11 +85,18 @@ dependencies { // Coroutines implementation(libs.kotlinx.coroutines.android) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3") + implementation(libs.kotlinx.coroutines.play.services) //FCM - implementation(platform("com.google.firebase:firebase-bom:33.2.0")) - implementation("com.google.firebase:firebase-messaging-ktx") + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.messaging.ktx) + + // ExoPlayer for video playback + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui) + implementation(libs.androidx.media3.common) + implementation(libs.androidx.media3.datasource) + implementation(libs.androidx.media3.datasource.okhttp) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/navigation/NavigationGraph.kt b/app/src/main/java/com/ssafy/tiggle/presentation/navigation/NavigationGraph.kt index be28ca5..45ce0ab 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/navigation/NavigationGraph.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/navigation/NavigationGraph.kt @@ -18,6 +18,7 @@ import com.ssafy.tiggle.presentation.ui.dutchpay.CreateDutchPayScreen import com.ssafy.tiggle.presentation.ui.piggybank.OpenAccountScreen import com.ssafy.tiggle.presentation.ui.piggybank.PiggyBankScreen import com.ssafy.tiggle.presentation.ui.piggybank.RegisterAccountScreen +import com.ssafy.tiggle.presentation.ui.shorts.ShortsScreen /** * 앱의 메인 네비게이션 @@ -29,7 +30,7 @@ fun NavigationGraph() { Scaffold( bottomBar = { - if (navBackStack.last() is BottomScreen) + if (navBackStack.last() is BottomScreen && navBackStack.last() != BottomScreen.Shorts) BottomNavigation(navBackStack) } ) { innerPadding -> @@ -88,7 +89,7 @@ fun NavigationGraph() { }, onBackClick = { navBackStack.removeLastOrNull() - } + } ) } @@ -135,9 +136,5 @@ private fun GrowthScreen() { Text("성장") } -@Composable -private fun ShortsScreen() { - Text("숏폼") -} diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/shorts/ShortsScreen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/shorts/ShortsScreen.kt new file mode 100644 index 0000000..d5b8326 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/shorts/ShortsScreen.kt @@ -0,0 +1,872 @@ +package com.ssafy.tiggle.presentation.ui.shorts + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.VerticalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.VolunteerActivism +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.common.VideoSize +import androidx.media3.common.C +import androidx.media3.ui.PlayerView +import java.text.NumberFormat +import java.util.Locale +import androidx.media3.ui.AspectRatioFrameLayout +import kotlinx.coroutines.delay + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun ShortsScreen( + viewModel: ShortsViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + + val pagerState = rememberPagerState( + initialPage = 0, + pageCount = { uiState.videos.size } + ) + + // 현재 페이지 변경 시 ViewModel 업데이트 + LaunchedEffect(pagerState.currentPage) { + viewModel.setCurrentVideoIndex(pagerState.currentPage) + + // 마지막 몇 개 동영상에 도달하면 더 많은 동영상 로드 + if (pagerState.currentPage >= uiState.videos.size - 3) { + viewModel.loadMoreVideos() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + VerticalPager( + state = pagerState, + modifier = Modifier.fillMaxSize() + ) { page -> + if (page < uiState.videos.size) { + ShortsVideoItem( + video = uiState.videos[page], + isCurrentItem = page == pagerState.currentPage, + screenHeight = screenHeight, + onLikeClick = { viewModel.toggleLike(uiState.videos[page].id) }, + onShareClick = { /* 공유 기능 */ }, + onMoreClick = { /* 더보기 기능 */ } + ) + } + } + + // 로딩 상태 + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@UnstableApi +@Composable +private fun ShortsVideoItem( + video: ShortsVideo, + isCurrentItem: Boolean, + screenHeight: Dp, + onLikeClick: () -> Unit, + onShareClick: () -> Unit, + onMoreClick: () -> Unit +) { + var isPlaying by remember { mutableStateOf(false) } + val context = LocalContext.current + val exoPlayer = remember { + ExoPlayer.Builder(context).build() + } + var showLikeSheet by remember { mutableStateOf(false) } + val likeSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showInfoBadge by remember { mutableStateOf(false) } + + // 현재 아이템이 될 때만 재생 + LaunchedEffect(isCurrentItem) { + if (isCurrentItem) { + try { + exoPlayer.setMediaItem(MediaItem.fromUri(video.videoUrl)) + exoPlayer.prepare() + exoPlayer.playWhenReady = true + exoPlayer.repeatMode = Player.REPEAT_MODE_ONE + // 화면을 꽉 채우도록 스케일링 (크롭) + exoPlayer.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + isPlaying = true + // 5초 후 ESG 안내 배지 표시 + showInfoBadge = false + delay(8000) + // 여전히 현재 아이템인 경우에만 표시 + if (isCurrentItem) showInfoBadge = true + } catch (e: Exception) { + Log.e("ShortsScreen", "동영상 재생 오류: ${e.message}") + isPlaying = false + } + } else { + exoPlayer.pause() + isPlaying = false + showInfoBadge = false + } + } + + // 컴포넌트가 dispose될 때 플레이어 해제 + DisposableEffect(Unit) { + onDispose { + exoPlayer.release() + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(screenHeight) + .background(Color.Black) + ) { + // 비디오 플레이어 + AndroidView( + factory = { context -> + PlayerView(context).apply { + player = exoPlayer + useController = false + setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING) + // 터치 이벤트 비활성화하여 스크롤 가능하도록 설정 + isClickable = false + isFocusable = false + // 플레이어 뷰가 화면을 꽉 채우도록 크롭 + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + }, + modifier = Modifier.fillMaxSize() + ) + + + // 우측 액션 버튼들 + Column( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // 좋아요 버튼 + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + IconButton( + onClick = onLikeClick, + modifier = Modifier + .size(48.dp) + .background( + Color.Black.copy(alpha = 0.3f), + CircleShape + ) + ) { + Icon( + imageVector = if (video.isLiked) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + contentDescription = "좋아요", + tint = if (video.isLiked) Color.Red else Color.White, + modifier = Modifier.size(28.dp) + ) + } + Text( + text = formatCount(video.likeCount), + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } + + // 공유 버튼 + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + IconButton( + onClick = onShareClick, + modifier = Modifier + .size(48.dp) + .background( + Color.Black.copy(alpha = 0.3f), + CircleShape + ) + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "공유", + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + Text( + text = formatCount(video.shareCount), + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } + + // 더보기 버튼 + IconButton( + onClick = onMoreClick, + modifier = Modifier + .size(48.dp) + .background( + Color.Black.copy(alpha = 0.3f), + CircleShape + ) + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "더보기", + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + } + + // 하단 비디오 정보 + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(16.dp) + .fillMaxWidth(0.7f) + ) { + // ESG 기부 안내 (info 스타일 배지) + if (showInfoBadge) Surface( + modifier = Modifier + .padding(start = 8.dp, bottom = 8.dp) + .clickable { showLikeSheet = true }, + color = Color(0xCC111827), + shape = RoundedCornerShape(20.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = "ESG 기부 안내", + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "지금 나오는 영상과 연관된 ESG 테마에 기부해보세요!", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } + } + + // 사용자 정보 + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 8.dp) + ) { + Box( + modifier = Modifier + .size(32.dp) + .background(Color.Gray, CircleShape) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = video.username, + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.width(8.dp)) + OutlinedButton( + onClick = { /* 팔로우 기능 */ }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.White + ), + border = androidx.compose.foundation.BorderStroke(1.dp, Color.White), + modifier = Modifier.height(28.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp) + ) { + Text( + text = "팔로우", + fontSize = 12.sp + ) + } + } + + // 비디오 제목 + Text( + text = video.title, + color = Color.White, + fontSize = 14.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 4.dp) + ) + + // 해시태그 + Text( + text = video.hashtags.joinToString(" ") { "#$it" }, + color = Color.White.copy(alpha = 0.8f), + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + // 좋아요 모달 시트 + if (showLikeSheet) { + ModalBottomSheet( + onDismissRequest = { showLikeSheet = false }, + sheetState = likeSheetState, + containerColor = Color.White + ) { + // 카드 래핑 없이 내용만 직접 표시 + DonationContent( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + } + } +} + +private fun formatCount(count: Int): String { + return when { + count >= 1000000 -> "${count / 1000000}M" + count >= 1000 -> "${count / 1000}K" + else -> count.toString() + } +} + +@Composable +private fun DonationContent( + modifier: Modifier = Modifier +) { + // 금액 선택 상태 + var selectedAmount by remember { mutableStateOf(100) } + var showCustomInput by remember { mutableStateOf(false) } + var customAmountText by remember { mutableStateOf("") } + + val currencyFormatter = remember { NumberFormat.getNumberInstance(Locale.KOREA) } + + Column(modifier = modifier.fillMaxWidth().padding(4.dp)) { + // 헤더 + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(48.dp) + .background(Color(0xFFEAF3FF), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Public, + contentDescription = null, + tint = Color(0xFF1B6BFF) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "환경 보호에 동참하세요", + style = MaterialTheme.typography.titleMedium, + color = Color(0xFF0F172A) + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "이런 혁신이 더 많이 생기도록 도와주세요.", + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF64748B) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 금액 선택 버튼들 (4등분 균등 배치, 한 줄 고정) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + AmountChip( + text = "100원", + selected = selectedAmount == 100, + modifier = Modifier.weight(1f) + ) { selectedAmount = 100 } + AmountChip( + text = "300원", + selected = selectedAmount == 300, + modifier = Modifier.weight(1f) + ) { selectedAmount = 300 } + AmountChip( + text = "500원", + selected = selectedAmount == 500, + modifier = Modifier.weight(1f) + ) { selectedAmount = 500 } + AmountChip( + text = "직접 입력", + selected = selectedAmount == null, + modifier = Modifier.weight(1f) + ) { + selectedAmount = null + showCustomInput = true + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 통계 박스들 (좌우 꽉 채우기) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StatBox( + title = "12,847명", + subtitle = "이 영상으로 기부한 사람", + modifier = Modifier.weight(1f) + ) + StatBox( + title = "8,471,200원", + subtitle = "모인 기부금", + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 기부 버튼 + val donateText = buildString { + val amount = selectedAmount ?: customAmountText.filter { it.isDigit() }.toIntOrNull() + val display = amount?.let { currencyFormatter.format(it) } ?: "금액" + append(display).append("원 기부하기") + } + Button( + onClick = { /* TODO: 기부 액션 */ }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B5BFF)) + ) { + Text(text = donateText, color = Color.White) + } + } + + // 직접 입력 다이얼로그 + if (showCustomInput) { + AlertDialog( + onDismissRequest = { showCustomInput = false }, + confirmButton = { + TextButton(onClick = { + val parsed = customAmountText.filter { it.isDigit() }.toIntOrNull() + if (parsed != null && parsed > 0) { + selectedAmount = parsed + showCustomInput = false + } + }) { Text("확인") } + }, + dismissButton = { TextButton(onClick = { showCustomInput = false }) { Text("취소") } }, + title = { Text("기부 금액 입력") }, + text = { + OutlinedTextField( + value = customAmountText, + onValueChange = { customAmountText = it.filter { ch -> ch.isDigit() } }, + singleLine = true, + placeholder = { Text("금액 (원)") } + ) + } + ) + } +} + +@Composable +private fun AmountChip( + text: String, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + if (selected) { + Button( + onClick = onClick, + modifier = modifier, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF3B5BFF)), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + ) { + Text( + text = text, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } else { + OutlinedButton( + onClick = onClick, + modifier = modifier, + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + ) { + Text( + text = text, + color = Color(0xFF475569), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun StatBox(title: String, subtitle: String, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background(Color(0xFFF8FAFC), RoundedCornerShape(12.dp)) + .padding(vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = title, color = Color(0xFF0F172A), fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = subtitle, color = Color(0xFF94A3B8), fontSize = 12.sp) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewShortsScreen() { + val mockUiState = ShortsUiState( + videos = listOf( + ShortsVideo( + id = "1", + videoUrl = "https://www.youtube.com/shorts/Y9hBFOavqO4?feature=share", + title = "재미있는 금융 꿀팁!\n적금 vs 투자 어떤게 좋을까요?", + username = "티끌금융왕", + likeCount = 15234, + shareCount = 892, + viewCount = 45632, + hashtags = listOf("금융", "투자", "꿀팁"), + isLiked = false + ), + ShortsVideo( + id = "2", + videoUrl = "https://www.youtube.com/shorts/8o5IOilnJus?feature=share", + title = "하루 1000원 절약법\n이것만 따라해도 한달에 3만원!", + username = "절약마스터", + likeCount = 8921, + shareCount = 456, + viewCount = 23184, + hashtags = listOf("절약", "일상", "브이로그"), + isLiked = true + ) + ), + currentVideoIndex = 0, + isLoading = false + ) + + PreviewShortsContent(uiState = mockUiState) +} + +@Preview(showBackground = true) +@Composable +private fun PreviewShortsVideoItem() { + val mockVideo = ShortsVideo( + id = "preview", + videoUrl = "https://www.youtube.com/shorts/Y9hBFOavqO4?feature=share", + title = "2024년 최고의 투자 전략\n초보자도 쉽게 따라할 수 있어요!", + username = "투자고수", + likeCount = 25678, + shareCount = 1234, + viewCount = 89432, + hashtags = listOf("투자", "주식", "부자되기", "금융"), + isLiked = false + ) + + ShortsVideoItemPreview( + video = mockVideo, + isCurrentItem = true, + screenHeight = 800.dp + ) +} + +@Composable +private fun PreviewShortsContent(uiState: ShortsUiState) { + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + itemsIndexed(uiState.videos) { index, video -> + ShortsVideoItemPreview( + video = video, + isCurrentItem = index == uiState.currentVideoIndex, + screenHeight = screenHeight + ) + } + } + + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } +} + +@Composable +private fun ShortsVideoItemPreview( + video: ShortsVideo, + isCurrentItem: Boolean, + screenHeight: Dp +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(screenHeight) + .background(Color.Black) + ) { + // 비디오 플레이어 대신 썸네일 이미지로 대체 (Preview용) + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Gray), + contentAlignment = Alignment.Center + ) { + Text( + text = "VIDEO\nPLAYER", + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + } + + // 우측 액션 버튼들 + Column( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // 좋아요 버튼 + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + IconButton( + onClick = { }, + modifier = Modifier + .size(48.dp) + .background( + Color.Black.copy(alpha = 0.3f), + CircleShape + ) + ) { + Icon( + imageVector = if (video.isLiked) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + contentDescription = "좋아요", + tint = if (video.isLiked) Color.Red else Color.White, + modifier = Modifier.size(28.dp) + ) + } + Text( + text = formatCount(video.likeCount), + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } + + // 공유 버튼 + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + IconButton( + onClick = { }, + modifier = Modifier + .size(48.dp) + .background( + Color.Black.copy(alpha = 0.3f), + CircleShape + ) + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "공유", + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + Text( + text = formatCount(video.shareCount), + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } + + // 더보기 버튼 + IconButton( + onClick = { }, + modifier = Modifier + .size(48.dp) + .background( + Color.Black.copy(alpha = 0.3f), + CircleShape + ) + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "더보기", + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + } + + // 하단 비디오 정보 + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(16.dp) + .fillMaxWidth(0.7f) + ) { + // 사용자 정보 + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 8.dp) + ) { + Box( + modifier = Modifier + .size(32.dp) + .background(Color.Gray, CircleShape) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = video.username, + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.width(8.dp)) + OutlinedButton( + onClick = { }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.White + ), + border = androidx.compose.foundation.BorderStroke(1.dp, Color.White), + modifier = Modifier.height(28.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp) + ) { + Text( + text = "팔로우", + fontSize = 12.sp + ) + } + } + + // 비디오 제목 + Text( + text = video.title, + color = Color.White, + fontSize = 14.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 4.dp) + ) + + // 해시태그 + Text( + text = video.hashtags.joinToString(" ") { "#$it" }, + color = Color.White.copy(alpha = 0.8f), + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +// 추가 프리뷰: 바텀시트 내 기부 UI 상태별 +@Preview(showBackground = true) +@Composable +private fun PreviewDonationContent_Default() { + DonationContent( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) +} + +@Preview(showBackground = true) +@Composable +private fun PreviewDonationContent_CustomInput() { + var show by remember { mutableStateOf(true) } + if (show) { + // 다이얼로그까지 띄운 상태는 preview 제약이 있으므로 + // 금액 칩과 통계 레이아웃이 깨지지 않는지만 검증용으로만 표시 + DonationContent( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewStatRow() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StatBox(title = "12,847명", subtitle = "이 영상으로 기부한 사람", modifier = Modifier.weight(1f)) + StatBox(title = "8,471,200원", subtitle = "모인 기부금", modifier = Modifier.weight(1f)) + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/shorts/ShortsUiState.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/shorts/ShortsUiState.kt new file mode 100644 index 0000000..c97d83d --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/shorts/ShortsUiState.kt @@ -0,0 +1,9 @@ +package com.ssafy.tiggle.presentation.ui.shorts + +data class ShortsUiState( + val videos: List = emptyList(), + val currentVideoIndex: Int = 0, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val hasReachedEnd: Boolean = false +) diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/shorts/ShortsVideo.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/shorts/ShortsVideo.kt new file mode 100644 index 0000000..07da385 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/shorts/ShortsVideo.kt @@ -0,0 +1,17 @@ +package com.ssafy.tiggle.presentation.ui.shorts + +data class ShortsVideo( + val id: String, + val videoUrl: String, + val thumbnailUrl: String? = null, + val title: String, + val username: String, + val userProfileUrl: String? = null, + val likeCount: Int, + val shareCount: Int, + val viewCount: Int, + val hashtags: List, + val isLiked: Boolean = false, + val isFollowing: Boolean = false, + val duration: Long = 0L // 밀리초 +) diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/shorts/ShortsViewModel.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/shorts/ShortsViewModel.kt new file mode 100644 index 0000000..1aa45ce --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/shorts/ShortsViewModel.kt @@ -0,0 +1,137 @@ +package com.ssafy.tiggle.presentation.ui.shorts + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ShortsViewModel @Inject constructor( + // private val shortsRepository: ShortsRepository // 나중에 구현 +) : ViewModel() { + + private val _uiState = MutableStateFlow(ShortsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadInitialVideos() + } + + private fun loadInitialVideos() { + _uiState.update { it.copy(isLoading = true) } + + viewModelScope.launch { + try { + // TODO: 실제 비디오 데이터는 Repository에서 가져오기 + val mockVideos = generateMockVideos() + _uiState.update { + it.copy( + videos = mockVideos, + isLoading = false + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message + ) + } + } + } + } + + fun setCurrentVideoIndex(index: Int) { + _uiState.update { it.copy(currentVideoIndex = index) } + + // 마지막에 가까워지면 더 많은 비디오 로드 + if (index >= _uiState.value.videos.size - 2 && !_uiState.value.hasReachedEnd) { + loadMoreVideos() + } + } + + fun loadMoreVideos() { + if (_uiState.value.isLoading) return + + _uiState.update { it.copy(isLoading = true) } + + viewModelScope.launch { + try { + // TODO: 더 많은 비디오 로드 로직 + val moreVideos = generateMockVideos(startIndex = _uiState.value.videos.size) + _uiState.update { currentState -> + currentState.copy( + videos = currentState.videos + moreVideos, + isLoading = false + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message + ) + } + } + } + } + + fun toggleLike(videoId: String) { + _uiState.update { currentState -> + currentState.copy( + videos = currentState.videos.map { video -> + if (video.id == videoId) { + video.copy( + isLiked = !video.isLiked, + likeCount = if (video.isLiked) video.likeCount - 1 else video.likeCount + 1 + ) + } else { + video + } + } + ) + } + } + + fun clearErrorMessage() { + _uiState.update { it.copy(errorMessage = null) } + } + + // Mock 데이터 생성 (나중에 제거) + private fun generateMockVideos(startIndex: Int = 0): List { + val mockVideos = mutableListOf() + + // 테스트용 샘플 비디오 URL들 - 안정적인 Google 스토리지 사용 + // 실제 프로젝트에서는 로컬 파일이나 자체 서버 URL 사용 권장 + val sampleVideoUrls = listOf( + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4", + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4" + ) + + for (i in startIndex until startIndex + 10) { + mockVideos.add( + ShortsVideo( + id = "video_$i", + videoUrl = sampleVideoUrls[i % sampleVideoUrls.size], + title = "재미있는 숏폼 영상 #$i\n금융 꿀팁과 일상 브이로그", + username = "티끌유저${i + 1}", + likeCount = (100..10000).random(), + shareCount = (10..1000).random(), + viewCount = (1000..100000).random(), + hashtags = listOf("금융", "절약", "투자", "일상", "브이로그", "꿀팁").shuffled().take(3), + isLiked = false + ) + ) + } + + return mockVideos + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ce489a3..12cfa85 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,17 +1,19 @@ [versions] agp = "8.11.1" +firebaseBom = "33.2.0" kotlin = "2.0.21" coreKtx = "1.16.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" +kotlinxCoroutinesPlayServices = "1.7.3" lifecycleRuntimeKtx = "2.9.2" activityCompose = "1.10.1" composeBom = "2024.09.00" hilt = "2.48" hiltNavigationCompose = "1.2.0" lifecycleViewModelCompose = "2.8.7" -navigationCompose = "2.8.1" +media3Exoplayer = "1.2.1" retrofit = "2.9.0" retrofitGsonConverter = "2.9.0" okhttp = "4.12.0" @@ -25,6 +27,13 @@ roomKtx = "2.7.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "media3Exoplayer" } +androidx-media3-datasource = { module = "androidx.media3:media3-datasource", version.ref = "media3Exoplayer" } +androidx-media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3Exoplayer" } +androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } +androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Exoplayer" } +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } +firebase-messaging-ktx = { module = "com.google.firebase:firebase-messaging-ktx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -54,6 +63,7 @@ androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runt androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3Runtime" } androidx-lifecycle-viewmodel-navigation3-android = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3-android", version.ref = "lifecycleViewmodelNavigation3Android" } # Network +kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesPlayServices" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-gson-converter = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofitGsonConverter" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }