diff --git a/android/app/build.gradle b/android/app/build.gradle index 276fa6de..3518949a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -46,6 +46,10 @@ android { } buildTypes { + debug { + minifyEnabled false + shrinkResources false + } release { signingConfig = signingConfigs.release minifyEnabled true diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 921ee4d4..08dccc6a 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -6,4 +6,8 @@ -keep class io.flutter.** { *; } -keep class io.flutter.plugins.** { *; } -dontwarn io.flutter.embedding.** --dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication \ No newline at end of file +-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication + +# Kakao SDK +-keep class com.kakao.** { *; } +-dontwarn com.kakao.** \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b68cf66b..ea7343dd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,14 @@ + + + + + + + + - - - diff --git a/assets/icons/ic_article_grey10.svg b/assets/icons/ic_article_grey10.svg new file mode 100644 index 00000000..4dd3435b --- /dev/null +++ b/assets/icons/ic_article_grey10.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ic_comm_grey10.svg b/assets/icons/ic_comm_grey10.svg new file mode 100644 index 00000000..46d96263 --- /dev/null +++ b/assets/icons/ic_comm_grey10.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ic_copy_grey10.svg b/assets/icons/ic_copy_grey10.svg new file mode 100644 index 00000000..bbeb1c48 --- /dev/null +++ b/assets/icons/ic_copy_grey10.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ic_debateseason.svg b/assets/icons/ic_debateseason.svg new file mode 100644 index 00000000..4f8f5be6 --- /dev/null +++ b/assets/icons/ic_debateseason.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ic_info_grey50.svg b/assets/icons/ic_info_grey50.svg new file mode 100644 index 00000000..1f37670a --- /dev/null +++ b/assets/icons/ic_info_grey50.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ic_kakao_login.svg b/assets/icons/ic_kakao_login.svg deleted file mode 100644 index 8546bcc0..00000000 --- a/assets/icons/ic_kakao_login.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/icons/ic_login_apple.svg b/assets/icons/ic_login_apple.svg new file mode 100644 index 00000000..c6f3d761 --- /dev/null +++ b/assets/icons/ic_login_apple.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ic_login_kakao.svg b/assets/icons/ic_login_kakao.svg new file mode 100644 index 00000000..c4edd078 --- /dev/null +++ b/assets/icons/ic_login_kakao.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ic_logo.svg b/assets/icons/ic_logo.svg new file mode 100644 index 00000000..921be69d --- /dev/null +++ b/assets/icons/ic_logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ic_more_grey50.svg b/assets/icons/ic_more_grey50.svg new file mode 100644 index 00000000..706a8bf6 --- /dev/null +++ b/assets/icons/ic_more_grey50.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ic_youtube_grey10.svg b/assets/icons/ic_youtube_grey10.svg new file mode 100644 index 00000000..a9e272c0 --- /dev/null +++ b/assets/icons/ic_youtube_grey10.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ic_youtube_logo_grey50.svg b/assets/icons/ic_youtube_logo_grey50.svg new file mode 100644 index 00000000..5149c8af --- /dev/null +++ b/assets/icons/ic_youtube_logo_grey50.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/images/img_media_thumbnail.png b/assets/images/img_media_thumbnail.png new file mode 100644 index 00000000..3537b97b Binary files /dev/null and b/assets/images/img_media_thumbnail.png differ diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 2eed59cb..ae119555 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -29,6 +29,7 @@ UIMainStoryboardFile Main UISupportedInterfaceOrientations + UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft @@ -52,5 +53,7 @@ https http + ITSAppUsesNonExemptEncryption + diff --git a/lib/core/constants/de_colors.dart b/lib/core/constants/de_colors.dart index 08a28778..af5e5213 100644 --- a/lib/core/constants/de_colors.dart +++ b/lib/core/constants/de_colors.dart @@ -28,5 +28,13 @@ class DeColors { static const blueDark = Color(0xFF242345); static const blueMine = Color(0xFF4F4C71); + static const imageRed = Color(0xFFFE2222); + static const imageOrange = Color(0xFFFF813D); + static const imageYellow = Color(0xFFFFE93F); + static const imageGreen = Color(0xFF2CCD3F); + static const imageBlue = Color(0xFF371DFF); + static const imageNavy = Color(0xFF350FC1); + static const imagePurple = Color(0xFF9500AF); + static const kakao = Color(0xFFFEE500); } diff --git a/lib/core/constants/de_dimensions.dart b/lib/core/constants/de_dimensions.dart index 20afba2f..67317c6b 100644 --- a/lib/core/constants/de_dimensions.dart +++ b/lib/core/constants/de_dimensions.dart @@ -8,10 +8,16 @@ class DeDimensions { static const EdgeInsets all16 = EdgeInsets.all(16); static const EdgeInsets all20 = EdgeInsets.all(20); + static const EdgeInsets padding6x2 = + EdgeInsets.symmetric(horizontal: 6, vertical: 2); static const EdgeInsets padding8x10 = EdgeInsets.symmetric(horizontal: 8, vertical: 10); + static const EdgeInsets padding10x4 = + EdgeInsets.symmetric(horizontal: 10, vertical: 4); static const EdgeInsets padding10x5 = EdgeInsets.symmetric(horizontal: 10, vertical: 5); + static const EdgeInsets padding10x8 = + EdgeInsets.symmetric(horizontal: 10, vertical: 8); static const EdgeInsets padding12x4 = EdgeInsets.symmetric(horizontal: 12, vertical: 4); static const EdgeInsets padding12x16 = @@ -28,6 +34,7 @@ class DeDimensions { static const EdgeInsets horizontal20 = EdgeInsets.symmetric(horizontal: 20); static const EdgeInsets vertical2 = EdgeInsets.symmetric(vertical: 2); + static const EdgeInsets vertical8 = EdgeInsets.symmetric(vertical: 8); static const EdgeInsets vertical10 = EdgeInsets.symmetric(vertical: 10); static const EdgeInsets vertical12 = EdgeInsets.symmetric(vertical: 12); static const EdgeInsets vertical16 = EdgeInsets.symmetric(vertical: 16); diff --git a/lib/core/constants/de_gaps.dart b/lib/core/constants/de_gaps.dart index 250ccbf7..79f3f3c1 100644 --- a/lib/core/constants/de_gaps.dart +++ b/lib/core/constants/de_gaps.dart @@ -15,9 +15,11 @@ class DeGaps { static const v32 = SizedBox(height: 32); static const v36 = SizedBox(height: 36); static const v40 = SizedBox(height: 40); + static const v42 = SizedBox(height: 42); static const h2 = SizedBox(width: 2); static const h4 = SizedBox(width: 4); + static const h6 = SizedBox(width: 6); static const h8 = SizedBox(width: 8); static const h12 = SizedBox(width: 12); static const h14 = SizedBox(width: 14); diff --git a/lib/core/constants/de_icons.dart b/lib/core/constants/de_icons.dart index 26dd3cce..0505a04e 100644 --- a/lib/core/constants/de_icons.dart +++ b/lib/core/constants/de_icons.dart @@ -2,7 +2,9 @@ class DeIcons { //colorable static const icAddGrey30 = 'assets/icons/ic_add_grey30.svg'; static const icBackGrey50 = 'assets/icons/ic_back_grey50.svg'; + static const icCopyGrey10 = 'assets/icons/ic_copy_grey10.svg'; static const icMoreGrey10 = 'assets/icons/ic_more_grey10.svg'; + static const icMoreGrey50 = 'assets/icons/ic_more_grey50.svg'; static const icSendGrey10 = 'assets/icons/ic_send_grey10.svg'; static const icSendGrey70 = 'assets/icons/ic_send_grey70.svg'; static const icXGrey50 = 'assets/icons/ic_x_grey50.svg'; @@ -15,8 +17,13 @@ class DeIcons { static const icSignOutGrey50 = 'assets/icons/ic_sign_out_grey50.svg'; static const icBookmarkGrey50 = 'assets/icons/ic_bookmark_grey50.svg'; static const icAssistantGrey50 = 'assets/icons/ic_assistant_grey50.svg'; + static const icInfoGrey50 = 'assets/icons/ic_info_grey50.svg'; + static const icYoutubeLogoGrey50 = 'assets/icons/ic_youtube_logo_grey50.svg'; + static const icYoutubeGrey10 = 'assets/icons/ic_youtube_grey10.svg'; + static const icArticleGrey10 = 'assets/icons/ic_article_grey10.svg'; + static const icCommGrey10 = 'assets/icons/ic_comm_grey10.svg'; //none colorable - static const icKakaoLogin = 'assets/icons/ic_kakao_login.svg'; - static const icAppleLogin = 'assets/icons/ic_apple_login.svg'; + static const icKakaoLogin = 'assets/icons/ic_login_kakao.svg'; + static const icAppleLogin = 'assets/icons/ic_login_apple.svg'; } diff --git a/lib/core/routers/get_router.dart b/lib/core/routers/get_router.dart index b856916d..3870e24d 100644 --- a/lib/core/routers/get_router.dart +++ b/lib/core/routers/get_router.dart @@ -5,6 +5,11 @@ import 'package:debateseason_frontend_v1/features/chat/bindings/chat_binding.dar import 'package:debateseason_frontend_v1/features/chat/bindings/debate_binding.dart'; import 'package:debateseason_frontend_v1/features/chat/presentation/views/chat_room_screen.dart'; import 'package:debateseason_frontend_v1/features/chat/presentation/views/debate_room_screen.dart'; +import 'package:debateseason_frontend_v1/features/chat/presentation/views/inappropriate_chat_report_screen.dart'; +import 'package:debateseason_frontend_v1/features/home/bindings/media_binding.dart'; +import 'package:debateseason_frontend_v1/features/home/bindings/recommend_binding.dart'; +import 'package:debateseason_frontend_v1/features/home/presentation/views/home_screen.dart'; +import 'package:debateseason_frontend_v1/features/home/presentation/views/live_all_page.dart'; import 'package:debateseason_frontend_v1/features/issue/bindings/issue_binding.dart'; import 'package:debateseason_frontend_v1/features/issue/presentation/views/issue_room_screen.dart'; import 'package:debateseason_frontend_v1/features/issuemap/bindings/issuemap_binding.dart'; @@ -12,12 +17,14 @@ import 'package:debateseason_frontend_v1/features/issuemap/presentation/views/is import 'package:debateseason_frontend_v1/features/main/main_binding.dart'; import 'package:debateseason_frontend_v1/features/main/main_screen.dart'; import 'package:debateseason_frontend_v1/features/profile/bindings/profile_binding.dart'; +import 'package:debateseason_frontend_v1/features/profile/bindings/profile_image_binding.dart'; import 'package:debateseason_frontend_v1/features/profile/bindings/profile_input_binding.dart'; import 'package:debateseason_frontend_v1/features/profile/bindings/terms_binding.dart'; -import 'package:debateseason_frontend_v1/features/profile/presentation/views/profile_input_page.dart'; -import 'package:debateseason_frontend_v1/features/profile/presentation/views/terms_page.dart'; +import 'package:debateseason_frontend_v1/features/profile/presentation/views/profile_image_screen.dart'; +import 'package:debateseason_frontend_v1/features/profile/presentation/views/profile_input_screen.dart'; import 'package:debateseason_frontend_v1/features/profile/presentation/views/profile_screen.dart'; -import 'package:debateseason_frontend_v1/features/profile/presentation/views/profile_withdraw_page.dart'; +import 'package:debateseason_frontend_v1/features/profile/presentation/views/profile_withdraw_screen.dart'; +import 'package:debateseason_frontend_v1/features/profile/presentation/views/terms_screen.dart'; import 'package:debateseason_frontend_v1/features/splash/presentation/splash_screen.dart'; import 'package:get/get.dart'; @@ -44,23 +51,33 @@ class GetRouter { ), GetPage( name: GetRouterName.terms, - page: () => TermsPage(), + page: () => TermsScreen(), binding: TermsBinding(), ), GetPage( name: GetRouterName.profileInput, - page: () => ProfileInputPage(), + page: () => ProfileInputScreen(), binding: ProfileInputBinding(), ), + GetPage( + name: GetRouterName.profileImage, + page: () => ProfileImageScreen(), + binding: ProfileImageBinding(), + ), GetPage( name: GetRouterName.profileWithdraw, - page: () => ProfileWithdrawPage(), + page: () => ProfileWithdrawScreen(), ), GetPage( name: GetRouterName.chat, page: () => ChatRoomScreen(), binding: ChatBinding(), ), + GetPage( + name: GetRouterName.reportMessage, + page: () => InappropriateChatReportScreen(), + binding: null, + ), GetPage( name: GetRouterName.profile, page: () => ProfileScreen(), @@ -81,5 +98,20 @@ class GetRouter { page: () => DebateRoomScreen(), binding: DebateBinding(), ), + GetPage( + name: GetRouterName.recommend, + page: () => HomeScreen(), + binding: RecommendBinding(), + ), + GetPage( + name: GetRouterName.media, + page: () => HomeScreen(), + binding: MediaBinding(), + ), + GetPage( + name: GetRouterName.lives, + page: () => LiveAllPage(), + binding: MediaBinding(), + ) ]; } diff --git a/lib/core/routers/get_router_name.dart b/lib/core/routers/get_router_name.dart index f02ad940..5c7c5398 100644 --- a/lib/core/routers/get_router_name.dart +++ b/lib/core/routers/get_router_name.dart @@ -8,10 +8,15 @@ class GetRouterName { static const String issue = '/issue'; static const String category = '/category'; static const String debate = '/debate'; + static const String reportMessage = '/report_message'; + static const String recommend = '/home/recommend'; + static const String media = '/home/media'; + static const String lives = '/lives'; /// Profile static const String profile = '/profile'; static const String profileInput = '/profile_input'; + static const String profileImage = '/profile_image'; static const String profileWithdraw = '/profile_withdraw'; static const String terms = '/terms'; } diff --git a/lib/core/services/pip_controller.dart b/lib/core/services/pip_controller.dart new file mode 100644 index 00000000..e914c387 --- /dev/null +++ b/lib/core/services/pip_controller.dart @@ -0,0 +1,212 @@ +import 'package:debateseason_frontend_v1/widgets/de_pip.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:youtube_player_flutter/youtube_player_flutter.dart'; + +class PipController extends GetxController with WidgetsBindingObserver { + YoutubePlayerController? youtubePlayerController; + final showPip = false.obs; + final isControlVisible = false.obs; + String? currentVideoId; + + final pipOffset = Rx(const Offset(20, 40)); + Size pipSize = Size.zero; + Offset? originalOffsetBeforeKeyboard; + bool movedByUserWhileKeyboardUp = false; + + OverlayEntry? _overlayEntry; + + void _ensureOverlay() { + if (_overlayEntry != null) return; + final overlay = Overlay.of(Get.overlayContext!); + _overlayEntry = OverlayEntry(builder: (_) => const DePip()); + overlay.insert(_overlayEntry!); + } + + void _removeOverlay() { + _overlayEntry?.remove(); + _overlayEntry = null; + } + + void initYoutube(String videoId) { + if (currentVideoId == videoId && youtubePlayerController != null) return; + currentVideoId = videoId; + + youtubePlayerController?.dispose(); + youtubePlayerController = YoutubePlayerController( + initialVideoId: videoId, + flags: const YoutubePlayerFlags( + autoPlay: true, + mute: false, + disableDragSeek: true, + hideControls: false, + ), + ); + } + + void show(String videoId) { + _ensureOverlay(); + + if (youtubePlayerController == null) { + initYoutube(videoId); + } else if (currentVideoId != videoId) { + youtubePlayerController!.load(videoId); + currentVideoId = videoId; + } + showPip.value = true; + } + + void hide() { + showPip.value = false; + _removeOverlay(); + youtubePlayerController?.pause(); + } + + void updateOffset(Offset delta) { + pipOffset.value += delta; + + final keyboardVisible = + Get.mediaQuery.viewInsets.bottom > 0; + if (keyboardVisible) { + movedByUserWhileKeyboardUp = true; + } + } + + void snapToCorner(Size screenSize, Size size) { + pipSize = size; + final Offset current = pipOffset.value; + final double screenW = screenSize.width; + final double screenH = screenSize.height; + final double pipW = pipSize.width; + final double pipH = pipSize.height; + + final keyboardHeight = Get.mediaQuery.viewInsets.bottom; + final double bottomLimit = screenH - keyboardHeight; + + final List corners = [ + const Offset(20, 40), // top-left + Offset(screenW - pipW - 20, 40), // top-right + Offset(20, screenH - pipH - 20), // bottom-left + Offset(screenW - pipW - 20, screenH - pipH - 20), // bottom-right + Offset(20, (screenH - pipH) / 2), // left-center + Offset(screenW - pipW - 20, (screenH - pipH) / 2), // right-center + ]; + + if (keyboardHeight > 0) { + final double snapY = bottomLimit - pipH - 10; + corners.addAll([ + Offset(20, snapY), // 키보드 좌상단 + Offset(screenW - pipW - 20, snapY), // 키보드 우상단 + ]); + } + + final isBelowKeyboard = current.dy + pipH > bottomLimit; + final filtered = corners.where((offset) { + return offset.dy + pipH <= bottomLimit; + }).toList(); + + if (isBelowKeyboard && filtered.isEmpty) { + pipOffset.value = Offset( + pipOffset.value.dx, + bottomLimit - pipH - 10, + ); + return; + } + + Offset closest = filtered.first; + double minDistance = (current - closest).distance; + + for (final corner in filtered) { + double distance = (current - corner).distance; + if (distance < minDistance) { + minDistance = distance; + closest = corner; + } + } + + pipOffset.value = closest; + } + + void showControls() { + isControlVisible.value = true; + Future.delayed(const Duration(seconds: 3), () { + isControlVisible.value = false; + }); + } + + @override + void onClose() { + WidgetsBinding.instance.removeObserver(this); + _removeOverlay(); + youtubePlayerController?.dispose(); + super.onClose(); + } + + @override + void onInit() { + super.onInit(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeMetrics() { + if (!showPip.value) return; + if (pipSize.height == 0) return; + + final keyboardHeight = Get.mediaQuery.viewInsets.bottom; + final isKeyboardVisible = keyboardHeight > 0; + + if (pipSize.height == 0) return; + + final pipBottom = pipOffset.value.dy + pipSize.height; + final screenBottom = Get.height; + final keyboardTop = screenBottom - keyboardHeight; + final overlapsKeyboard = pipBottom > keyboardTop; + + if (isKeyboardVisible) { + if (overlapsKeyboard) { + if (originalOffsetBeforeKeyboard == null) { + originalOffsetBeforeKeyboard = pipOffset.value; + movedByUserWhileKeyboardUp = false; + } + + final newY = keyboardTop - pipSize.height - 20; + pipOffset.value = Offset(pipOffset.value.dx, newY); + } + } else { + if (originalOffsetBeforeKeyboard != null && !movedByUserWhileKeyboardUp) { + final snapY = Get.height - pipSize.height - 20; + final wasSnappedToKeyboardTop = + originalOffsetBeforeKeyboard!.dy <= keyboardTop - pipSize.height; + + if (wasSnappedToKeyboardTop) { + pipOffset.value = Offset(pipOffset.value.dx, snapY); + } else { + pipOffset.value = originalOffsetBeforeKeyboard!; + } + } + + if (movedByUserWhileKeyboardUp) { + final Offset current = pipOffset.value; + final screenW = Get.width; + final pipW = pipSize.width; + + final bool isTopLeft = + (current.dx - 20).abs() < 10 && (current.dy - 40).abs() < 10; + final bool isTopRight = + (current.dx - (screenW - pipW - 20)).abs() < 10 && + (current.dy - 40).abs() < 10; + + if (!isTopLeft && !isTopRight) { + pipOffset.value = Offset( + current.dx, + Get.height - pipSize.height - 20, + ); + } + } + + originalOffsetBeforeKeyboard = null; + movedByUserWhileKeyboardUp = false; + } + } +} diff --git a/lib/features/auth/auth_constants.dart b/lib/features/auth/auth_constants.dart index cb79d796..ac25bd13 100644 --- a/lib/features/auth/auth_constants.dart +++ b/lib/features/auth/auth_constants.dart @@ -1,6 +1,6 @@ class AuthConstants { static const kakaoLoginType = 'kakao'; static const appleLoginType = 'apple'; - static const kakaoLogin = '카카오 로그인'; - static const appleLogin = '애플 로그인'; + static const kakaoLogin = '카카오로 로그인'; + static const appleLogin = 'Apple로 로그인'; } diff --git a/lib/features/auth/presentation/view_models/auth_view_model.dart b/lib/features/auth/presentation/view_models/auth_view_model.dart index 2d60b0dc..3915e736 100644 --- a/lib/features/auth/presentation/view_models/auth_view_model.dart +++ b/lib/features/auth/presentation/view_models/auth_view_model.dart @@ -3,6 +3,7 @@ import 'package:debateseason_frontend_v1/features/auth/auth_constants.dart'; import 'package:debateseason_frontend_v1/features/auth/domain/entities/users_login_entity.dart'; import 'package:debateseason_frontend_v1/features/auth/domain/repositories/users_login_repository.dart'; import 'package:debateseason_frontend_v1/features/profile/domain/repositories/profile_repository.dart'; +import 'package:debateseason_frontend_v1/utils/amplitude_util.dart'; import 'package:debateseason_frontend_v1/utils/base/ui_state.dart'; import 'package:debateseason_frontend_v1/utils/logger.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -19,6 +20,7 @@ class AuthViewModel extends GetxController { void onInit() { super.onInit(); + AmplitudeUtil.trackEvent(eventName: 'Auth'); _usersLoginRepository = Get.find(); _profileRepository = Get.find(); _kakaoSdkInit(); diff --git a/lib/features/auth/presentation/views/auth_screen.dart b/lib/features/auth/presentation/views/auth_screen.dart index 890bf094..bb664440 100644 --- a/lib/features/auth/presentation/views/auth_screen.dart +++ b/lib/features/auth/presentation/views/auth_screen.dart @@ -57,7 +57,7 @@ class AuthScreen extends GetView { if (loginStatus.$2) { //profileStatus if (loginStatus.$1) { - Get.offNamed(GetRouterName.issuemap); + Get.offNamed(GetRouterName.main); } else { Get.offNamed(GetRouterName.profileInput); } @@ -91,7 +91,7 @@ class AuthScreen extends GetView { if (loginStatus.$2) { // profileStatus if (loginStatus.$1) { - Get.offNamed(GetRouterName.issuemap); + Get.offNamed(GetRouterName.main); } else { Get.offNamed(GetRouterName.profileInput); } @@ -139,7 +139,7 @@ class AuthScreen extends GetView { ? DeIcons.icKakaoLogin : DeIcons.icAppleLogin, ), - DeGaps.h8, + DeGaps.h4, Text( loginType == AuthConstants.kakaoLoginType ? AuthConstants.kakaoLogin diff --git a/lib/features/chat/bindings/chat_binding.dart b/lib/features/chat/bindings/chat_binding.dart index 6588abcf..f8b07034 100644 --- a/lib/features/chat/bindings/chat_binding.dart +++ b/lib/features/chat/bindings/chat_binding.dart @@ -16,6 +16,11 @@ class ChatBinding extends Bindings { Get.find(), ), ); + Get.lazyPut( + () => ChatRoomsMessagesRepositoryImpl( + Get.find(), + ), + ); Get.lazyPut(() => ChatRoomViewModel()); } diff --git a/lib/features/chat/data/data_sources/chat_rooms_messages_data_source.dart b/lib/features/chat/data/data_sources/chat_rooms_messages_data_source.dart index 89873e9a..36b6b928 100644 --- a/lib/features/chat/data/data_sources/chat_rooms_messages_data_source.dart +++ b/lib/features/chat/data/data_sources/chat_rooms_messages_data_source.dart @@ -1,3 +1,4 @@ +import 'package:debateseason_frontend_v1/features/chat/data/models/report_reason_req.dart'; import 'package:debateseason_frontend_v1/features/chat/domain/entities/chat_message_entity.dart'; import 'package:debateseason_frontend_v1/utils/base/base_res.dart'; import 'package:dio/dio.dart'; @@ -15,6 +16,12 @@ abstract class ChatRoomsMessagesDataSource { @Path('roomId') required int roomId, @Query('cursor') int? cursor, }); + + @POST('/api/v1/chat/messages/{messageId}/report') + Future reportMessage({ + @Path() required int messageId, + @Body() required ReportReasonReq body, + }); } // 내부적으로 사용되지 않는 일회용 클래스이므로 data_source 에 정의함. diff --git a/lib/features/chat/data/data_sources/chat_rooms_messages_data_source.g.dart b/lib/features/chat/data/data_sources/chat_rooms_messages_data_source.g.dart index 19e80ac0..4c43a490 100644 --- a/lib/features/chat/data/data_sources/chat_rooms_messages_data_source.g.dart +++ b/lib/features/chat/data/data_sources/chat_rooms_messages_data_source.g.dart @@ -75,6 +75,40 @@ class _ChatRoomsMessagesDataSource implements ChatRoomsMessagesDataSource { return _value; } + @override + Future> reportMessage({ + required int messageId, + required ReportReasonReq body, + }) async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + _data.addAll(body.toJson()); + final _options = _setStreamType>( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/v1/chat/messages/${messageId}/report', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late BaseRes _value; + try { + _value = BaseRes.fromJson( + _result.data!, + (json) => json as dynamic, + ); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + RequestOptions _setStreamType(RequestOptions requestOptions) { if (T != dynamic && !(requestOptions.responseType == ResponseType.bytes || diff --git a/lib/features/chat/data/models/report_reason_req.dart b/lib/features/chat/data/models/report_reason_req.dart new file mode 100644 index 00000000..e7c9f1f9 --- /dev/null +++ b/lib/features/chat/data/models/report_reason_req.dart @@ -0,0 +1,14 @@ +class ReportReasonReq { + final List reasons; + final String? etcDescription; + + ReportReasonReq({ + required this.reasons, + this.etcDescription, + }); + + Map toJson() => { + 'reasonType': reasons, + if (etcDescription != null) 'reasonDetail': etcDescription, + }; +} diff --git a/lib/features/chat/data/repository_impls/chat_rooms_messages_repository_impl.dart b/lib/features/chat/data/repository_impls/chat_rooms_messages_repository_impl.dart index e89f1fd0..d1c588f3 100644 --- a/lib/features/chat/data/repository_impls/chat_rooms_messages_repository_impl.dart +++ b/lib/features/chat/data/repository_impls/chat_rooms_messages_repository_impl.dart @@ -2,6 +2,7 @@ import 'package:debateseason_frontend_v1/core/model/cursor_pagination_model.dart import 'package:debateseason_frontend_v1/features/chat/data/data_sources/chat_rooms_messages_data_source.dart'; import 'package:debateseason_frontend_v1/features/chat/domain/entities/chat_message_entity.dart'; import 'package:debateseason_frontend_v1/features/chat/domain/repositories/chat_rooms_messages_repository.dart'; +import 'package:debateseason_frontend_v1/features/chat/presentation/models/report_reason_selection.dart'; import 'package:debateseason_frontend_v1/utils/base/base_res.dart'; import 'package:debateseason_frontend_v1/utils/base/ui_state.dart'; @@ -42,4 +43,20 @@ class ChatRoomsMessagesRepositoryImpl implements ChatRoomsMessagesRepository { response.message.isEmpty ? "서버 통신에 문제가 발생했습니다" : response.message); } } + + Future reportMessage({ + required int messageId, + required ReportReasonSelection reasons, + }) async { + final response = await dataSource.reportMessage( + messageId: messageId, body: reasons.toReq()); + // handle response + switch (response.status) { + case 200: + return UiState.success(response.data); + default: + return UiState.failure( + response.message.isEmpty ? "서버 통신에 문제가 발생했습니다" : response.message); + } + } } diff --git a/lib/features/chat/presentation/models/report_reason_selection.dart b/lib/features/chat/presentation/models/report_reason_selection.dart new file mode 100644 index 00000000..7111ad1a --- /dev/null +++ b/lib/features/chat/presentation/models/report_reason_selection.dart @@ -0,0 +1,47 @@ +import 'package:debateseason_frontend_v1/features/chat/data/models/report_reason_req.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'report_reason_selection.freezed.dart'; + +@freezed +class ReportReasonSelection with _$ReportReasonSelection { + const factory ReportReasonSelection({ + @Default(false) bool isAbusive, // 욕설 + @Default(false) bool isSexual, // 음란 + @Default(false) bool isFalseInfo, // 허위 + @Default(false) bool isSpam, // 도배 + @Default(false) bool isPromotion, // 홍보 + @Default(false) bool isPrivacyLeak, // 개인정보 노출 + @Default(false) bool isEtc, // 기타 + @Default("") String? etcDescription, // 기타 설명 (기타가 true일 경우 유효) + }) = _ReportReasonSelection; + const ReportReasonSelection._(); // for custom methods + + /// 변환 함수 (to DTO) + ReportReasonReq toReq() { + final reasons = []; + if (isAbusive) reasons.add("ABUSIVE"); + if (isSexual) reasons.add("SEXUAL"); + if (isFalseInfo) reasons.add("FALSE_INFO"); + if (isSpam) reasons.add("SPAM"); + if (isPromotion) reasons.add("PROMOTION"); + if (isPrivacyLeak) reasons.add("PRIVACY_LEAK"); + if (isEtc) reasons.add("ETC"); + + return ReportReasonReq( + reasons: reasons, + etcDescription: isEtc ? etcDescription : null, + ); + } +} + +extension ReportReasonSelectionExtension on ReportReasonSelection { + bool get hasAnySelected => + isAbusive || + isSexual || + isFalseInfo || + isSpam || + isPromotion || + isPrivacyLeak || + isEtc; +} diff --git a/lib/features/chat/presentation/models/report_reason_selection.freezed.dart b/lib/features/chat/presentation/models/report_reason_selection.freezed.dart new file mode 100644 index 00000000..54b2a960 --- /dev/null +++ b/lib/features/chat/presentation/models/report_reason_selection.freezed.dart @@ -0,0 +1,315 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'report_reason_selection.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$ReportReasonSelection { + bool get isAbusive => throw _privateConstructorUsedError; // 욕설 + bool get isSexual => throw _privateConstructorUsedError; // 음란 + bool get isFalseInfo => throw _privateConstructorUsedError; // 허위 + bool get isSpam => throw _privateConstructorUsedError; // 도배 + bool get isPromotion => throw _privateConstructorUsedError; // 홍보 + bool get isPrivacyLeak => throw _privateConstructorUsedError; // 개인정보 노출 + bool get isEtc => throw _privateConstructorUsedError; // 기타 + String? get etcDescription => throw _privateConstructorUsedError; + + /// Create a copy of ReportReasonSelection + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ReportReasonSelectionCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ReportReasonSelectionCopyWith<$Res> { + factory $ReportReasonSelectionCopyWith(ReportReasonSelection value, + $Res Function(ReportReasonSelection) then) = + _$ReportReasonSelectionCopyWithImpl<$Res, ReportReasonSelection>; + @useResult + $Res call( + {bool isAbusive, + bool isSexual, + bool isFalseInfo, + bool isSpam, + bool isPromotion, + bool isPrivacyLeak, + bool isEtc, + String? etcDescription}); +} + +/// @nodoc +class _$ReportReasonSelectionCopyWithImpl<$Res, + $Val extends ReportReasonSelection> + implements $ReportReasonSelectionCopyWith<$Res> { + _$ReportReasonSelectionCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ReportReasonSelection + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? isAbusive = null, + Object? isSexual = null, + Object? isFalseInfo = null, + Object? isSpam = null, + Object? isPromotion = null, + Object? isPrivacyLeak = null, + Object? isEtc = null, + Object? etcDescription = freezed, + }) { + return _then(_value.copyWith( + isAbusive: null == isAbusive + ? _value.isAbusive + : isAbusive // ignore: cast_nullable_to_non_nullable + as bool, + isSexual: null == isSexual + ? _value.isSexual + : isSexual // ignore: cast_nullable_to_non_nullable + as bool, + isFalseInfo: null == isFalseInfo + ? _value.isFalseInfo + : isFalseInfo // ignore: cast_nullable_to_non_nullable + as bool, + isSpam: null == isSpam + ? _value.isSpam + : isSpam // ignore: cast_nullable_to_non_nullable + as bool, + isPromotion: null == isPromotion + ? _value.isPromotion + : isPromotion // ignore: cast_nullable_to_non_nullable + as bool, + isPrivacyLeak: null == isPrivacyLeak + ? _value.isPrivacyLeak + : isPrivacyLeak // ignore: cast_nullable_to_non_nullable + as bool, + isEtc: null == isEtc + ? _value.isEtc + : isEtc // ignore: cast_nullable_to_non_nullable + as bool, + etcDescription: freezed == etcDescription + ? _value.etcDescription + : etcDescription // ignore: cast_nullable_to_non_nullable + as String?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$ReportReasonSelectionImplCopyWith<$Res> + implements $ReportReasonSelectionCopyWith<$Res> { + factory _$$ReportReasonSelectionImplCopyWith( + _$ReportReasonSelectionImpl value, + $Res Function(_$ReportReasonSelectionImpl) then) = + __$$ReportReasonSelectionImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {bool isAbusive, + bool isSexual, + bool isFalseInfo, + bool isSpam, + bool isPromotion, + bool isPrivacyLeak, + bool isEtc, + String? etcDescription}); +} + +/// @nodoc +class __$$ReportReasonSelectionImplCopyWithImpl<$Res> + extends _$ReportReasonSelectionCopyWithImpl<$Res, + _$ReportReasonSelectionImpl> + implements _$$ReportReasonSelectionImplCopyWith<$Res> { + __$$ReportReasonSelectionImplCopyWithImpl(_$ReportReasonSelectionImpl _value, + $Res Function(_$ReportReasonSelectionImpl) _then) + : super(_value, _then); + + /// Create a copy of ReportReasonSelection + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? isAbusive = null, + Object? isSexual = null, + Object? isFalseInfo = null, + Object? isSpam = null, + Object? isPromotion = null, + Object? isPrivacyLeak = null, + Object? isEtc = null, + Object? etcDescription = freezed, + }) { + return _then(_$ReportReasonSelectionImpl( + isAbusive: null == isAbusive + ? _value.isAbusive + : isAbusive // ignore: cast_nullable_to_non_nullable + as bool, + isSexual: null == isSexual + ? _value.isSexual + : isSexual // ignore: cast_nullable_to_non_nullable + as bool, + isFalseInfo: null == isFalseInfo + ? _value.isFalseInfo + : isFalseInfo // ignore: cast_nullable_to_non_nullable + as bool, + isSpam: null == isSpam + ? _value.isSpam + : isSpam // ignore: cast_nullable_to_non_nullable + as bool, + isPromotion: null == isPromotion + ? _value.isPromotion + : isPromotion // ignore: cast_nullable_to_non_nullable + as bool, + isPrivacyLeak: null == isPrivacyLeak + ? _value.isPrivacyLeak + : isPrivacyLeak // ignore: cast_nullable_to_non_nullable + as bool, + isEtc: null == isEtc + ? _value.isEtc + : isEtc // ignore: cast_nullable_to_non_nullable + as bool, + etcDescription: freezed == etcDescription + ? _value.etcDescription + : etcDescription // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc + +class _$ReportReasonSelectionImpl extends _ReportReasonSelection { + const _$ReportReasonSelectionImpl( + {this.isAbusive = false, + this.isSexual = false, + this.isFalseInfo = false, + this.isSpam = false, + this.isPromotion = false, + this.isPrivacyLeak = false, + this.isEtc = false, + this.etcDescription = ""}) + : super._(); + + @override + @JsonKey() + final bool isAbusive; +// 욕설 + @override + @JsonKey() + final bool isSexual; +// 음란 + @override + @JsonKey() + final bool isFalseInfo; +// 허위 + @override + @JsonKey() + final bool isSpam; +// 도배 + @override + @JsonKey() + final bool isPromotion; +// 홍보 + @override + @JsonKey() + final bool isPrivacyLeak; +// 개인정보 노출 + @override + @JsonKey() + final bool isEtc; +// 기타 + @override + @JsonKey() + final String? etcDescription; + + @override + String toString() { + return 'ReportReasonSelection(isAbusive: $isAbusive, isSexual: $isSexual, isFalseInfo: $isFalseInfo, isSpam: $isSpam, isPromotion: $isPromotion, isPrivacyLeak: $isPrivacyLeak, isEtc: $isEtc, etcDescription: $etcDescription)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ReportReasonSelectionImpl && + (identical(other.isAbusive, isAbusive) || + other.isAbusive == isAbusive) && + (identical(other.isSexual, isSexual) || + other.isSexual == isSexual) && + (identical(other.isFalseInfo, isFalseInfo) || + other.isFalseInfo == isFalseInfo) && + (identical(other.isSpam, isSpam) || other.isSpam == isSpam) && + (identical(other.isPromotion, isPromotion) || + other.isPromotion == isPromotion) && + (identical(other.isPrivacyLeak, isPrivacyLeak) || + other.isPrivacyLeak == isPrivacyLeak) && + (identical(other.isEtc, isEtc) || other.isEtc == isEtc) && + (identical(other.etcDescription, etcDescription) || + other.etcDescription == etcDescription)); + } + + @override + int get hashCode => Object.hash(runtimeType, isAbusive, isSexual, isFalseInfo, + isSpam, isPromotion, isPrivacyLeak, isEtc, etcDescription); + + /// Create a copy of ReportReasonSelection + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ReportReasonSelectionImplCopyWith<_$ReportReasonSelectionImpl> + get copyWith => __$$ReportReasonSelectionImplCopyWithImpl< + _$ReportReasonSelectionImpl>(this, _$identity); +} + +abstract class _ReportReasonSelection extends ReportReasonSelection { + const factory _ReportReasonSelection( + {final bool isAbusive, + final bool isSexual, + final bool isFalseInfo, + final bool isSpam, + final bool isPromotion, + final bool isPrivacyLeak, + final bool isEtc, + final String? etcDescription}) = _$ReportReasonSelectionImpl; + const _ReportReasonSelection._() : super._(); + + @override + bool get isAbusive; // 욕설 + @override + bool get isSexual; // 음란 + @override + bool get isFalseInfo; // 허위 + @override + bool get isSpam; // 도배 + @override + bool get isPromotion; // 홍보 + @override + bool get isPrivacyLeak; // 개인정보 노출 + @override + bool get isEtc; // 기타 + @override + String? get etcDescription; + + /// Create a copy of ReportReasonSelection + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ReportReasonSelectionImplCopyWith<_$ReportReasonSelectionImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/features/chat/presentation/view_models/chat_room_view_model.dart b/lib/features/chat/presentation/view_models/chat_room_view_model.dart index 6a3c8ec7..9ebb61ad 100644 --- a/lib/features/chat/presentation/view_models/chat_room_view_model.dart +++ b/lib/features/chat/presentation/view_models/chat_room_view_model.dart @@ -1,7 +1,9 @@ import 'package:debateseason_frontend_v1/core/model/cursor_pagination_model.dart'; +import 'package:debateseason_frontend_v1/core/routers/get_router_name.dart'; import 'package:debateseason_frontend_v1/core/services/shared_preferences_service.dart'; import 'package:debateseason_frontend_v1/core/services/web_socket/stomp_service.dart'; import 'package:debateseason_frontend_v1/features/chat/data/models/room_res.dart'; +import 'package:debateseason_frontend_v1/features/chat/data/repository_impls/chat_rooms_messages_repository_impl.dart'; import 'package:debateseason_frontend_v1/features/chat/domain/entities/chat_message_entity.dart'; import 'package:debateseason_frontend_v1/features/chat/domain/enums/chat_message_type.dart'; import 'package:debateseason_frontend_v1/features/chat/domain/repositories/chat_rooms_messages_repository.dart'; @@ -80,7 +82,25 @@ class ChatRoomViewModel extends GetxController { ); } - void reportInappropriateChat(int index) {} + Future reportInappropriateChat(int messageId) async { + final data = await Get.toNamed( + GetRouterName.reportMessage, + ); + + // 뒤로가기 했을 경우에는 생략. + if (data == null) return null; + + final repo = Get.find(); + final status = + await repo.reportMessage(messageId: messageId, reasons: data); + // message 신고 후, 서버로부터의 응답여부에 따라 토스트 여부 결정 + + return status.when( + loading: () => "서버에 문제가 있습니다.", + success: (data) => data, + failure: (data) => data, + ); + } void sendMessage(String content) { try { diff --git a/lib/features/chat/presentation/view_models/debate_room_view_model.dart b/lib/features/chat/presentation/view_models/debate_room_view_model.dart index f3c8a9a3..68bf632f 100644 --- a/lib/features/chat/presentation/view_models/debate_room_view_model.dart +++ b/lib/features/chat/presentation/view_models/debate_room_view_model.dart @@ -4,6 +4,7 @@ import 'package:debateseason_frontend_v1/features/chat/data/data_sources/vote_da import 'package:debateseason_frontend_v1/features/chat/data/models/room_res.dart'; import 'package:debateseason_frontend_v1/features/chat/presentation/views/debate_topic_screen.dart'; import 'package:debateseason_frontend_v1/features/issue/presentation/view_models/issue_room_view_model.dart'; +import 'package:debateseason_frontend_v1/utils/amplitude_util.dart'; import 'package:debateseason_frontend_v1/utils/logger.dart'; import 'package:get/get.dart'; @@ -39,6 +40,7 @@ class DebateRoomViewModel extends GetxController { @override void onInit() { super.onInit(); + AmplitudeUtil.trackEvent(eventName: 'DebateRoom'); _roomDataSource = Get.find(); _voteDataSource = Get.find(); diff --git a/lib/features/chat/presentation/view_models/inappropriate_chat_report_view_model.dart b/lib/features/chat/presentation/view_models/inappropriate_chat_report_view_model.dart deleted file mode 100644 index e69de29b..00000000 diff --git a/lib/features/chat/presentation/views/chat_room_screen.dart b/lib/features/chat/presentation/views/chat_room_screen.dart index c54ecb2f..045daace 100644 --- a/lib/features/chat/presentation/views/chat_room_screen.dart +++ b/lib/features/chat/presentation/views/chat_room_screen.dart @@ -2,15 +2,20 @@ import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; import 'package:debateseason_frontend_v1/core/constants/de_dimensions.dart'; import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_icons.dart'; import 'package:debateseason_frontend_v1/core/model/cursor_pagination_model.dart'; import 'package:debateseason_frontend_v1/features/chat/domain/entities/chat_message_entity.dart'; import 'package:debateseason_frontend_v1/features/chat/presentation/view_models/chat_room_view_model.dart'; import 'package:debateseason_frontend_v1/features/chat/presentation/widgets/chat_input_field.dart'; import 'package:debateseason_frontend_v1/features/chat/presentation/widgets/chat_message.dart'; +import 'package:debateseason_frontend_v1/utils/amplitude_util.dart'; import 'package:debateseason_frontend_v1/utils/date_format_util.dart'; +import 'package:debateseason_frontend_v1/utils/de_snack_bar.dart'; import 'package:debateseason_frontend_v1/utils/logger.dart'; +import 'package:debateseason_frontend_v1/widgets/de_dialog_with_single_button.dart'; import 'package:debateseason_frontend_v1/widgets/import_de.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; class ChatRoomScreen extends StatefulWidget { @@ -26,6 +31,9 @@ class _ChatRoomScreenState extends State { @override void initState() { super.initState(); + + AmplitudeUtil.trackEvent(eventName: 'ChatRoom'); + // 메시지 로딩 후 가장 아래로 스크롤 WidgetsBinding.instance.addPostFrameCallback((_) { if (scrollController.hasClients) { @@ -78,6 +86,35 @@ class _ChatRoomScreenState extends State { ), ), isCenter: false, + actions: [ + Row( + children: [ + DeText( + "신고 방침", + style: DeFonts.caption12M.copyWith(color: DeColors.grey50), + ), + DeGaps.h2, + GestureDetector( + child: SvgPicture.asset( + DeIcons.icInfoGrey50, + width: 16.0, + height: 16.0, + ), + onTap: () { + DeDialogWithSingleButton.show( + dialogTitle: "신고 방침", + dialogTexts: [ + "• 신고 3회 누적 시, 해당 대화 작성자에 대한 신고가 진행됩니다.", + "• 신고는 제출 후 수정이 불가하며, 신고는 접수 후 24시간 이내에 처리됩니다.", + "• 허위 신고 시 신고자의 서비스 이용이
제한될 수 있으니 주의해 주세요." + ], + doneText: "확인", + onTapDone: () {}); + }, + ), + ], + ) + ], bottom: PreferredSize( preferredSize: Size.fromHeight(1.0), child: Container( @@ -155,7 +192,11 @@ class _ChatRoomScreenState extends State { return ChatMessage( chatMessageModel, parentHeight: parentHeight, - onInappropriateChatReport: viewModel.reportInappropriateChat, + onInappropriateChatReport: (messageId) async { + final reportMessage = + await viewModel.reportInappropriateChat(messageId); + if (reportMessage != null) deSnackBar(reportMessage); + }, ); }, separatorBuilder: (context, index) { diff --git a/lib/features/chat/presentation/views/debate_topic_screen.dart b/lib/features/chat/presentation/views/debate_topic_screen.dart index 581c5084..1c3861aa 100644 --- a/lib/features/chat/presentation/views/debate_topic_screen.dart +++ b/lib/features/chat/presentation/views/debate_topic_screen.dart @@ -1,6 +1,7 @@ import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; +import 'package:debateseason_frontend_v1/utils/amplitude_util.dart'; import 'package:debateseason_frontend_v1/widgets/import_de.dart'; import 'package:flutter/material.dart'; @@ -12,6 +13,13 @@ class DebateTopicScreen extends StatefulWidget { } class _DebateTopicScreenState extends State { + @override + void initState() { + super.initState(); + + AmplitudeUtil.trackEvent(eventName: 'DebateTopic'); + } + @override Widget build(BuildContext context) { return DeScaffold( diff --git a/lib/features/chat/presentation/views/inappropriate_chat_report_screen.dart b/lib/features/chat/presentation/views/inappropriate_chat_report_screen.dart index 41bfc127..e40874bf 100644 --- a/lib/features/chat/presentation/views/inappropriate_chat_report_screen.dart +++ b/lib/features/chat/presentation/views/inappropriate_chat_report_screen.dart @@ -1,7 +1,9 @@ import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; +import 'package:debateseason_frontend_v1/features/chat/presentation/models/report_reason_selection.dart'; import 'package:debateseason_frontend_v1/features/chat/presentation/widgets/custom_checkbox.dart'; +import 'package:debateseason_frontend_v1/utils/amplitude_util.dart'; import 'package:debateseason_frontend_v1/widgets/import_de.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -17,6 +19,15 @@ class InappropriateChatReportScreen extends StatefulWidget { class _InappropriateChatReportScreenState extends State { List isCheckedList = List.generate(7, (index) => false); // 체크 상태 리스트 + bool isCustomReasonVisible = false; + late TextEditingController customReasonController = TextEditingController(); + + @override + void initState() { + super.initState(); + + AmplitudeUtil.trackEvent(eventName: 'InappropriateChatReport'); + } @override Widget build(BuildContext context) { @@ -31,7 +42,9 @@ class _InappropriateChatReportScreenState child: Column( children: [ Text( - "허위 신고일 경우, 신고자의 서비스 활동이 제한될 수 있으니 유의해주세요.", + "신고 누적 시 해당 대화가 삭제되거나 작성자의 서비스 이용이 " + "제한됩니다. 신고는 제출 후 수정이 불가하며, 허위 신고 시 " + "신고자의 서비스 이용이 제한될 수 있으니 주의해 주세요.", style: DeFonts.body14M.copyWith(color: DeColors.grey30), ), DeGaps.v28, @@ -47,12 +60,24 @@ class _InappropriateChatReportScreenState DeGaps.v20, renderCheckbox("개인 정보 노출, 유포, 거래", 5), DeGaps.v20, - renderCheckbox("기타", 6), + renderCheckboxWithReason("기타", 6), Spacer(), DeButtonLarge( "확인", onPressed: () async { - Get.back>(result: isCheckedList); + // 신고 항목 전달. + Get.back( + result: ReportReasonSelection( + isAbusive: isCheckedList[0], + isSexual: isCheckedList[1], + isFalseInfo: isCheckedList[2], + isSpam: isCheckedList[3], + isPromotion: isCheckedList[4], + isPrivacyLeak: isCheckedList[5], + isEtc: isCheckedList[6], + etcDescription: isCheckedList[6] + ? customReasonController.text + : null)); }, enable: isCheckedList.contains(true) ? true : false, ), @@ -82,4 +107,43 @@ class _InappropriateChatReportScreenState ], ); } + + Widget renderCheckboxWithReason(String text, int index) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + text, + style: DeFonts.body16M, + ), + CustomCheckbox( + isChecked: isCheckedList[index], + onChanged: (newValue) { + setState(() { + isCustomReasonVisible = newValue; + isCheckedList[index] = newValue; + }); + }, + ), + ], + ), + DeGaps.v12, + if (isCustomReasonVisible) + SizedBox( + height: 120, + child: DeTextField( + textAlignVertical: TextAlignVertical.top, + // 글자 상단 정렬 + controller: customReasonController, + isCleanIcon: false, + // 삭제 버튼 없앰 + expands: true, + maxLines: null, + ), + ), + ], + ); + } } diff --git a/lib/features/chat/presentation/widgets/chat_message.dart b/lib/features/chat/presentation/widgets/chat_message.dart index f6840dc1..40e1ae16 100644 --- a/lib/features/chat/presentation/widgets/chat_message.dart +++ b/lib/features/chat/presentation/widgets/chat_message.dart @@ -6,6 +6,7 @@ import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; import 'package:debateseason_frontend_v1/core/constants/de_icons.dart'; import 'package:debateseason_frontend_v1/features/chat/domain/entities/chat_message_entity.dart'; +import 'package:debateseason_frontend_v1/features/chat/presentation/widgets/reaction_picker.dart'; import 'package:debateseason_frontend_v1/widgets/de_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; @@ -27,63 +28,71 @@ class ChatMessage extends StatefulWidget { } class _ChatMessageState extends State { - // void _showReactionModal( - // BuildContext context, - // GlobalKey messageKey, - // Alignment aligntment, - // String? link, - // ) { - // final RenderBox renderBox = - // messageKey.currentContext!.findRenderObject() as RenderBox; - // final Offset position = renderBox.localToGlobal(Offset.zero); - // final linkHeight = (link == null) ? 0 : 220; // 220 is Preview Size - // final Size chatBubbleSize = Size( - // renderBox.size.width, renderBox.size.height - linkHeight); // 메시지 크기 + void _showReactionModal( + BuildContext context, + GlobalKey messageKey, + Alignment aligntment, + String? link, + ) { + final RenderBox renderBox = messageKey.currentContext!.findRenderObject() + as RenderBox; // messageKey로 지정된 위젯 + final Offset position = renderBox + .localToGlobal(Offset.zero); // messageKey 로 지정된 위치가 현재 전체 화면상에서 어디인지 확인 + final linkHeight = (link == null) ? 0 : 220; // 220 is Preview Size + final Size chatBubbleSize = Size( + renderBox.size.width, renderBox.size.height - linkHeight); // 메시지 크기 - // double pickerTop; - // // bubble 하단에 이미지 픽커가 Default - // // bubble 하단에서 시현해서 잘려질 경우, 위쪽에 표시 - // if (widget.parentHeight < - // position.dy - kToolbarHeight + chatBubbleSize.height + 84) { - // pickerTop = position.dy - - // kToolbarHeight - - // 84 + - // 20 - - // 6; // 84 is reaction picker size 20 is chat header 6 is gap - // } else { - // pickerTop = position.dy - kToolbarHeight + chatBubbleSize.height; - // } + double pickerTop; + // bubble 하단에 이미지 픽커가 Default + // bubble 하단에서 시현해서 잘려질 경우, 위쪽에 표시 + double reactionPickerHeight = 40; // 픽커의 행이 2개일 때, 84(Gap 포함). 아니면 40 + if (widget.parentHeight < + position.dy - + kToolbarHeight + + chatBubbleSize.height + + reactionPickerHeight) { + pickerTop = position.dy - + kToolbarHeight - + reactionPickerHeight + + 20 - + 6; // 84 is reaction picker size 20 is chat header 6 is gap + } else { + pickerTop = position.dy - kToolbarHeight + chatBubbleSize.height; + } - // final right = - // aligntment == Alignment.centerRight ? 20.0 : null; // 20 padding - // final left = aligntment == Alignment.centerLeft ? 20.0 : null; // 20 padding + final right = + aligntment == Alignment.centerRight ? 20.0 : null; // 20 padding + final left = aligntment == Alignment.centerLeft ? 20.0 : null; // 20 padding - // showDialog( - // context: context, - // barrierColor: Colors.transparent, - // builder: (context) { - // return Stack( - // children: [ - // Positioned( - // top: pickerTop, - // right: right, - // left: left, - // child: Material( - // color: Colors.transparent, - // child: ReactionPicker( - // onReactionSelect: (emoji) { - // Navigator.pop(context); - // }, - // onInappropriateChatReport: () => widget - // .onInappropriateChatReport(widget.chatMessageEntity.id), - // ), - // ), - // ), - // ], - // ); - // }, - // ); - // } + showDialog( + context: context, + barrierColor: Colors.transparent, + builder: (context) { + return Stack( + children: [ + Positioned( + top: pickerTop, + right: right, + left: left, + child: Material( + color: Colors.transparent, + child: ReactionPicker(onReactionSelect: (emoji) { + Navigator.pop(context); + }, onInappropriateChatReport: () { + Navigator.pop(context); + Future.microtask(() { + // 팝업창 닫히는 것 보장. + widget + .onInappropriateChatReport(widget.chatMessageEntity.id); + }); + }), + ), + ), + ], + ); + }, + ); + } @override Widget build(BuildContext context) { @@ -115,8 +124,8 @@ class _ChatMessageState extends State { alignment: alignment, child: GestureDetector( key: messageKey, - // onLongPress: () => - // _showReactionModal(context, messageKey, alignment, link), + onLongPress: () => + _showReactionModal(context, messageKey, alignment, link), child: Column( crossAxisAlignment: crossAxisAlignment, children: [ diff --git a/lib/features/chat/presentation/widgets/reaction_picker.dart b/lib/features/chat/presentation/widgets/reaction_picker.dart index d310a484..b0cbbe5c 100644 --- a/lib/features/chat/presentation/widgets/reaction_picker.dart +++ b/lib/features/chat/presentation/widgets/reaction_picker.dart @@ -1,6 +1,5 @@ import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; -import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; import 'package:flutter/material.dart'; class ReactionPicker extends StatelessWidget { @@ -20,47 +19,50 @@ class ReactionPicker extends StatelessWidget { child: Column( children: [ // 리액션 - Container( - padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), - decoration: BoxDecoration( - color: DeColors.grey70, - borderRadius: BorderRadius.circular(40), - ), - child: IntrinsicHeight( - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Image.asset( - 'assets/images/logic_minus.png', - width: 24, - height: 24, - ), - DeGaps.h12, - Image.asset( - 'assets/images/logic_plus.png', - width: 24, - height: 24, - ), - VerticalDivider( - color: DeColors.grey50, - width: 25, - thickness: 1, - indent: 6, - endIndent: 6, - ), - Image.asset( - 'assets/images/attitude_minus.png', - width: 24, - height: 24, - ), - DeGaps.h12, - Image.asset( - 'assets/images/attitude_plus.png', - width: 24, - height: 24, - ), - ]), - ), - ), - DeGaps.v4, + // Container( + // padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), + // decoration: BoxDecoration( + // color: DeColors.grey70, + // borderRadius: BorderRadius.circular(40), + // ), + // child: IntrinsicHeight( + // child: Row( + // mainAxisSize: MainAxisSize.min, + // children: [ + // Image.asset( + // 'assets/images/logic_minus.png', + // width: 24, + // height: 24, + // ), + // DeGaps.h12, + // Image.asset( + // 'assets/images/logic_plus.png', + // width: 24, + // height: 24, + // ), + // VerticalDivider( + // color: DeColors.grey50, + // width: 25, + // thickness: 1, + // indent: 6, + // endIndent: 6, + // ), + // Image.asset( + // 'assets/images/attitude_minus.png', + // width: 24, + // height: 24, + // ), + // DeGaps.h12, + // Image.asset( + // 'assets/images/attitude_plus.png', + // width: 24, + // height: 24, + // ), + // ], + // ), + // ), + // ), + // DeGaps.v4, // 신고 Container( padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), diff --git a/lib/features/home/bindings/home_binding.dart b/lib/features/home/bindings/home_binding.dart new file mode 100644 index 00000000..6e0195bd --- /dev/null +++ b/lib/features/home/bindings/home_binding.dart @@ -0,0 +1,9 @@ +// import 'package:debateseason_frontend_v1/features/home/presentation/view_models/home_view_model.dart'; +// import 'package:get/get.dart'; +// +// class HomeBinding extends Bindings { +// @override +// void dependencies() { +// Get.lazyPut(() => HomeViewModel()); +// } +// } diff --git a/lib/features/home/bindings/media_binding.dart b/lib/features/home/bindings/media_binding.dart new file mode 100644 index 00000000..9ccd2838 --- /dev/null +++ b/lib/features/home/bindings/media_binding.dart @@ -0,0 +1,22 @@ +import 'package:debateseason_frontend_v1/core/network/dio_client.dart'; +import 'package:debateseason_frontend_v1/features/home/data/datasources/live_all_data_source.dart'; +import 'package:debateseason_frontend_v1/features/home/data/datasources/media_data_source.dart'; +import 'package:debateseason_frontend_v1/features/home/data/repository_impls/live_all_repository_impl.dart'; +import 'package:debateseason_frontend_v1/features/home/data/repository_impls/media_repository_impl.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/repositories/live_all_repository.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/repositories/media_repository.dart'; +import 'package:debateseason_frontend_v1/features/home/presentation/view_models/live_view_model.dart'; +import 'package:debateseason_frontend_v1/features/home/presentation/view_models/media_view_model.dart'; +import 'package:get/get.dart'; + +class MediaBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => MediaViewModel()); + Get.lazyPut(() => MediaDataSource(DioClient().dio)); + Get.lazyPut(() => MediaRepositoryImpl(Get.find())); + Get.lazyPut(()=> LiveViewModel()); + Get.lazyPut(() => LiveAllDataSource(DioClient().dio)); + Get.lazyPut(() => LiveAllRepositoryImpl(Get.find())); + } +} diff --git a/lib/features/home/bindings/recommend_binding.dart b/lib/features/home/bindings/recommend_binding.dart new file mode 100644 index 00000000..b4a623a7 --- /dev/null +++ b/lib/features/home/bindings/recommend_binding.dart @@ -0,0 +1,15 @@ +import 'package:debateseason_frontend_v1/core/network/dio_client.dart'; +import 'package:debateseason_frontend_v1/features/home/data/datasources/recommend_data_source.dart'; +import 'package:debateseason_frontend_v1/features/home/data/repository_impls/recommend_repository_impl.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/repositories/recommend_repository.dart'; +import 'package:debateseason_frontend_v1/features/home/presentation/view_models/recommend_view_model.dart'; +import 'package:get/get.dart'; + +class RecommendBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => RecommendViewModel()); + Get.lazyPut(() => RecommendDataSource(DioClient().dio)); + Get.lazyPut(() => RecommendRepositoryImpl(Get.find())); + } +} diff --git a/lib/features/home/data/datasources/live_all_data_source.dart b/lib/features/home/data/datasources/live_all_data_source.dart new file mode 100644 index 00000000..3c3b8d34 --- /dev/null +++ b/lib/features/home/data/datasources/live_all_data_source.dart @@ -0,0 +1,14 @@ +import 'package:debateseason_frontend_v1/features/home/data/models/live_all_res.dart'; +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; +import 'package:debateseason_frontend_v1/utils/base/base_res.dart'; + +part 'live_all_data_source.g.dart'; + +@RestApi() +abstract class LiveAllDataSource { + factory LiveAllDataSource(Dio dio) = _LiveAllDataSource; + + @GET('/api/v1/lives') + Future> getLiveAll(); +} diff --git a/lib/features/home/data/datasources/live_all_data_source.g.dart b/lib/features/home/data/datasources/live_all_data_source.g.dart new file mode 100644 index 00000000..d6db59e4 --- /dev/null +++ b/lib/features/home/data/datasources/live_all_data_source.g.dart @@ -0,0 +1,76 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'live_all_data_source.dart'; + +// ************************************************************************** +// RetrofitGenerator +// ************************************************************************** + +// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers,unused_element,unnecessary_string_interpolations + +class _LiveAllDataSource implements LiveAllDataSource { + _LiveAllDataSource(this._dio, {this.baseUrl, this.errorLogger}); + + final Dio _dio; + + String? baseUrl; + + final ParseErrorLogger? errorLogger; + + @override + Future> getLiveAll() async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/v1/lives', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late BaseRes _value; + try { + _value = BaseRes.fromJson( + _result.data!, + (json) => LiveAllRes.fromJson(json as Map), + ); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + RequestOptions _setStreamType(RequestOptions requestOptions) { + if (T != dynamic && + !(requestOptions.responseType == ResponseType.bytes || + requestOptions.responseType == ResponseType.stream)) { + if (T == String) { + requestOptions.responseType = ResponseType.plain; + } else { + requestOptions.responseType = ResponseType.json; + } + } + return requestOptions; + } + + String _combineBaseUrls(String dioBaseUrl, String? baseUrl) { + if (baseUrl == null || baseUrl.trim().isEmpty) { + return dioBaseUrl; + } + + final url = Uri.parse(baseUrl); + + if (url.isAbsolute) { + return url.toString(); + } + + return Uri.parse(dioBaseUrl).resolveUri(url).toString(); + } +} diff --git a/lib/features/home/data/datasources/media_data_source.dart b/lib/features/home/data/datasources/media_data_source.dart new file mode 100644 index 00000000..a3fbeec0 --- /dev/null +++ b/lib/features/home/data/datasources/media_data_source.dart @@ -0,0 +1,17 @@ +import 'package:debateseason_frontend_v1/features/home/data/models/media_res.dart'; +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; +import 'package:debateseason_frontend_v1/utils/base/base_res.dart'; + +part 'media_data_source.g.dart'; + +@RestApi() +abstract class MediaDataSource { + factory MediaDataSource(Dio dio) = _MediaDataSource; + + @GET('/api/v1/home/media') + Future> getMedia({ + @Query('type') String? type, + @Query('time') String? time, + }); +} diff --git a/lib/features/home/data/datasources/media_data_source.g.dart b/lib/features/home/data/datasources/media_data_source.g.dart new file mode 100644 index 00000000..acc20f36 --- /dev/null +++ b/lib/features/home/data/datasources/media_data_source.g.dart @@ -0,0 +1,77 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'media_data_source.dart'; + +// ************************************************************************** +// RetrofitGenerator +// ************************************************************************** + +// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers,unused_element,unnecessary_string_interpolations + +class _MediaDataSource implements MediaDataSource { + _MediaDataSource(this._dio, {this.baseUrl, this.errorLogger}); + + final Dio _dio; + + String? baseUrl; + + final ParseErrorLogger? errorLogger; + + @override + Future> getMedia({String? type, String? time}) async { + final _extra = {}; + final queryParameters = {r'type': type, r'time': time}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/v1/home/media', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late BaseRes _value; + try { + _value = BaseRes.fromJson( + _result.data!, + (json) => MediaRes.fromJson(json as Map), + ); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + RequestOptions _setStreamType(RequestOptions requestOptions) { + if (T != dynamic && + !(requestOptions.responseType == ResponseType.bytes || + requestOptions.responseType == ResponseType.stream)) { + if (T == String) { + requestOptions.responseType = ResponseType.plain; + } else { + requestOptions.responseType = ResponseType.json; + } + } + return requestOptions; + } + + String _combineBaseUrls(String dioBaseUrl, String? baseUrl) { + if (baseUrl == null || baseUrl.trim().isEmpty) { + return dioBaseUrl; + } + + final url = Uri.parse(baseUrl); + + if (url.isAbsolute) { + return url.toString(); + } + + return Uri.parse(dioBaseUrl).resolveUri(url).toString(); + } +} diff --git a/lib/features/home/data/datasources/recommend_data_source.dart b/lib/features/home/data/datasources/recommend_data_source.dart new file mode 100644 index 00000000..87fb22f2 --- /dev/null +++ b/lib/features/home/data/datasources/recommend_data_source.dart @@ -0,0 +1,14 @@ +import 'package:debateseason_frontend_v1/features/home/data/models/recommend_res.dart'; +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; +import 'package:debateseason_frontend_v1/utils/base/base_res.dart'; + +part 'recommend_data_source.g.dart'; + +@RestApi() +abstract class RecommendDataSource { + factory RecommendDataSource(Dio dio) = _RecommendDataSource; + + @GET('/api/v1/home/recommend') + Future> getRecommend(); +} diff --git a/lib/features/home/data/datasources/recommend_data_source.g.dart b/lib/features/home/data/datasources/recommend_data_source.g.dart new file mode 100644 index 00000000..dc09bb5e --- /dev/null +++ b/lib/features/home/data/datasources/recommend_data_source.g.dart @@ -0,0 +1,76 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recommend_data_source.dart'; + +// ************************************************************************** +// RetrofitGenerator +// ************************************************************************** + +// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers,unused_element,unnecessary_string_interpolations + +class _RecommendDataSource implements RecommendDataSource { + _RecommendDataSource(this._dio, {this.baseUrl, this.errorLogger}); + + final Dio _dio; + + String? baseUrl; + + final ParseErrorLogger? errorLogger; + + @override + Future> getRecommend() async { + final _extra = {}; + final queryParameters = {}; + final _headers = {}; + const Map? _data = null; + final _options = _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose( + _dio.options, + '/api/v1/home/recommend', + queryParameters: queryParameters, + data: _data, + ) + .copyWith(baseUrl: _combineBaseUrls(_dio.options.baseUrl, baseUrl)), + ); + final _result = await _dio.fetch>(_options); + late BaseRes _value; + try { + _value = BaseRes.fromJson( + _result.data!, + (json) => RecommendRes.fromJson(json as Map), + ); + } on Object catch (e, s) { + errorLogger?.logError(e, s, _options); + rethrow; + } + return _value; + } + + RequestOptions _setStreamType(RequestOptions requestOptions) { + if (T != dynamic && + !(requestOptions.responseType == ResponseType.bytes || + requestOptions.responseType == ResponseType.stream)) { + if (T == String) { + requestOptions.responseType = ResponseType.plain; + } else { + requestOptions.responseType = ResponseType.json; + } + } + return requestOptions; + } + + String _combineBaseUrls(String dioBaseUrl, String? baseUrl) { + if (baseUrl == null || baseUrl.trim().isEmpty) { + return dioBaseUrl; + } + + final url = Uri.parse(baseUrl); + + if (url.isAbsolute) { + return url.toString(); + } + + return Uri.parse(dioBaseUrl).resolveUri(url).toString(); + } +} diff --git a/lib/features/home/data/models/breaking_news_res.dart b/lib/features/home/data/models/breaking_news_res.dart new file mode 100644 index 00000000..edce0b3b --- /dev/null +++ b/lib/features/home/data/models/breaking_news_res.dart @@ -0,0 +1,22 @@ +import 'package:debateseason_frontend_v1/features/home/domain/entities/breaking_news_entity.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'breaking_news_res.g.dart'; + +@JsonSerializable() +class BreakingNewsRes { + final String title; + final String url; + + BreakingNewsRes({ + required this.title, + required this.url, + }); + + factory BreakingNewsRes.fromJson(Map json) => + _$BreakingNewsResFromJson(json); + + Map toJson() => _$BreakingNewsResToJson(this); + + BreakingNewsEntity toEntity() => BreakingNewsEntity(title: title, url: url); +} diff --git a/lib/features/home/data/models/breaking_news_res.g.dart b/lib/features/home/data/models/breaking_news_res.g.dart new file mode 100644 index 00000000..9f3e6fe1 --- /dev/null +++ b/lib/features/home/data/models/breaking_news_res.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'breaking_news_res.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +BreakingNewsRes _$BreakingNewsResFromJson(Map json) => + BreakingNewsRes( + title: json['title'] as String, + url: json['url'] as String, + ); + +Map _$BreakingNewsResToJson(BreakingNewsRes instance) => + { + 'title': instance.title, + 'url': instance.url, + }; diff --git a/lib/features/home/data/models/live_all_res.dart b/lib/features/home/data/models/live_all_res.dart new file mode 100644 index 00000000..61a137eb --- /dev/null +++ b/lib/features/home/data/models/live_all_res.dart @@ -0,0 +1,22 @@ +import 'package:debateseason_frontend_v1/features/home/data/models/media/youtube_live_res.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'live_all_res.g.dart'; + +@JsonSerializable() +class LiveAllRes { + final List youtubeLives; + + LiveAllRes({ + required this.youtubeLives, + }); + + factory LiveAllRes.fromJson(Map json) => + LiveAllRes( + youtubeLives: (json['youtubeLives'] as List) + .map((e) => YoutubeLiveRes.fromJson(e)) + .toList(), + ); + + Map toJson() => _$LiveAllResToJson(this); +} diff --git a/lib/features/home/data/models/live_all_res.g.dart b/lib/features/home/data/models/live_all_res.g.dart new file mode 100644 index 00000000..f9dc429b --- /dev/null +++ b/lib/features/home/data/models/live_all_res.g.dart @@ -0,0 +1,18 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'live_all_res.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LiveAllRes _$LiveAllResFromJson(Map json) => LiveAllRes( + youtubeLives: (json['youtubeLives'] as List) + .map((e) => YoutubeLiveRes.fromJson(e as Map)) + .toList(), + ); + +Map _$LiveAllResToJson(LiveAllRes instance) => + { + 'youtubeLives': instance.youtubeLives, + }; diff --git a/lib/features/home/data/models/media/media_item_res.dart b/lib/features/home/data/models/media/media_item_res.dart new file mode 100644 index 00000000..656780f0 --- /dev/null +++ b/lib/features/home/data/models/media/media_item_res.dart @@ -0,0 +1,37 @@ +import 'package:debateseason_frontend_v1/features/home/domain/entities/media_item_entity.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'media_item_res.g.dart'; + +@JsonSerializable() +class MediaItemRes { + final int id; + final String title; + final String supplier; + final DateTime outdated; + final String url; + final String? src; + + MediaItemRes({ + required this.id, + required this.title, + required this.supplier, + required this.outdated, + required this.url, + this.src, + }); + + factory MediaItemRes.fromJson(Map json) => + _$MediaItemResFromJson(json); + + Map toJson() => _$MediaItemResToJson(this); + + MediaItemEntity toEntity() => MediaItemEntity( + id: id, + title: title, + supplier: supplier, + outdated: outdated, + url: url, + src: src, + ); +} diff --git a/lib/features/home/data/models/media/media_item_res.g.dart b/lib/features/home/data/models/media/media_item_res.g.dart new file mode 100644 index 00000000..2d3f4552 --- /dev/null +++ b/lib/features/home/data/models/media/media_item_res.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'media_item_res.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MediaItemRes _$MediaItemResFromJson(Map json) => MediaItemRes( + id: (json['id'] as num).toInt(), + title: json['title'] as String, + supplier: json['supplier'] as String, + outdated: DateTime.parse(json['outdated'] as String), + url: json['url'] as String, + src: json['src'] as String?, + ); + +Map _$MediaItemResToJson(MediaItemRes instance) => + { + 'id': instance.id, + 'title': instance.title, + 'supplier': instance.supplier, + 'outdated': instance.outdated.toIso8601String(), + 'url': instance.url, + 'src': instance.src, + }; diff --git a/lib/features/home/data/models/media/youtube_live_container_res.dart b/lib/features/home/data/models/media/youtube_live_container_res.dart new file mode 100644 index 00000000..52b83868 --- /dev/null +++ b/lib/features/home/data/models/media/youtube_live_container_res.dart @@ -0,0 +1,50 @@ +import 'package:debateseason_frontend_v1/features/home/data/models/media/youtube_live_res.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/youtube_live_entity.dart'; +import 'package:debateseason_frontend_v1/utils/logger.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'youtube_live_container_res.g.dart'; + +@JsonSerializable() +class YoutubeLiveContainerRes { + final Map youtubeLive; + + YoutubeLiveContainerRes({required this.youtubeLive}); + + factory YoutubeLiveContainerRes.fromJson(Map json) { + // final map = json.map((k, e) => MapEntry( + // k, + // YoutubeLiveRes.fromJson(e as Map), + // )); + + // final map = {}; + // + // json.forEach((k, e) { + // if (e != null && e is Map) { + // log.d('🔁 Key: $k, Value: $e'); // + // map[k] = YoutubeLiveRes.fromJson(e); + // } else { + // log.w("⚠️ Skipping invalid or null youtubeLive item for key '$k': $e"); + // } + // }); + + // log.d('📦 YoutubeLiveContainerRes.fromJson() raw json: $json'); + // final raw = json['youtubeLives'] as Map; + // final map = raw.map((k, e) { + // log.d('🔁 Key: $k, Value: $e'); + // return MapEntry(k, YoutubeLiveRes.fromJson(e as Map)); + // }); + + final map = json.map((k, v) { + log.d('🎥 [YoutubeLiveContainerRes.fromJson] key=$k, value=$v'); + return MapEntry(k, YoutubeLiveRes.fromJson(v as Map)); + }); + + return YoutubeLiveContainerRes(youtubeLive: map); + } + + Map toJson() => _$YoutubeLiveContainerResToJson(this); + + List toEntityList() => + youtubeLive.values.map((e) => e.toEntity()).toList(); +} diff --git a/lib/features/home/data/models/media/youtube_live_container_res.g.dart b/lib/features/home/data/models/media/youtube_live_container_res.g.dart new file mode 100644 index 00000000..e7cde491 --- /dev/null +++ b/lib/features/home/data/models/media/youtube_live_container_res.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'youtube_live_container_res.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +YoutubeLiveContainerRes _$YoutubeLiveContainerResFromJson( + Map json) => + YoutubeLiveContainerRes( + youtubeLive: (json['youtubeLive'] as Map).map( + (k, e) => + MapEntry(k, YoutubeLiveRes.fromJson(e as Map)), + ), + ); + +Map _$YoutubeLiveContainerResToJson( + YoutubeLiveContainerRes instance) => + { + 'youtubeLive': instance.youtubeLive, + }; diff --git a/lib/features/home/data/models/media/youtube_live_res.dart b/lib/features/home/data/models/media/youtube_live_res.dart new file mode 100644 index 00000000..3740632e --- /dev/null +++ b/lib/features/home/data/models/media/youtube_live_res.dart @@ -0,0 +1,42 @@ +import 'package:debateseason_frontend_v1/features/home/domain/entities/youtube_live_entity.dart'; +import 'package:debateseason_frontend_v1/utils/logger.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'youtube_live_res.g.dart'; + +@JsonSerializable() +class YoutubeLiveRes { + final int id; + final String title; + final String supplier; + final String videoId; + final String category; + final DateTime? createAt; + final String? src; + + YoutubeLiveRes({ + required this.id, + required this.title, + required this.supplier, + required this.videoId, + required this.category, + this.createAt, + this.src, + }); + + factory YoutubeLiveRes.fromJson(Map json) { + log.d('🐛 YoutubeLiveRes.fromJson() json: $json'); + return _$YoutubeLiveResFromJson(json);} + + Map toJson() => _$YoutubeLiveResToJson(this); + + YoutubeLiveEntity toEntity() => YoutubeLiveEntity( + id: id, + title: title, + supplier: supplier, + videoId: videoId, + category: category, + createAt: createAt, + src: src, + ); +} diff --git a/lib/features/home/data/models/media/youtube_live_res.g.dart b/lib/features/home/data/models/media/youtube_live_res.g.dart new file mode 100644 index 00000000..c118a9a3 --- /dev/null +++ b/lib/features/home/data/models/media/youtube_live_res.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'youtube_live_res.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +YoutubeLiveRes _$YoutubeLiveResFromJson(Map json) => + YoutubeLiveRes( + id: (json['id'] as num).toInt(), + title: json['title'] as String, + supplier: json['supplier'] as String, + videoId: json['videoId'] as String, + category: json['category'] as String, + createAt: json['createAt'] == null + ? null + : DateTime.parse(json['createAt'] as String), + src: json['src'] as String?, + ); + +Map _$YoutubeLiveResToJson(YoutubeLiveRes instance) => + { + 'id': instance.id, + 'title': instance.title, + 'supplier': instance.supplier, + 'videoId': instance.videoId, + 'category': instance.category, + 'createAt': instance.createAt?.toIso8601String(), + 'src': instance.src, + }; diff --git a/lib/features/home/data/models/media_res.dart b/lib/features/home/data/models/media_res.dart new file mode 100644 index 00000000..942de6e2 --- /dev/null +++ b/lib/features/home/data/models/media_res.dart @@ -0,0 +1,39 @@ +import 'package:debateseason_frontend_v1/features/home/data/models/media/media_item_res.dart'; +import 'package:debateseason_frontend_v1/features/home/data/models/media/youtube_live_container_res.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/media_entity.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'media_res.g.dart'; + +@JsonSerializable() +class MediaRes { + @JsonKey( + name: 'youtubeLiveContainer', + fromJson: YoutubeLiveContainerRes.fromJson, + toJson: _youtubeLiveContainerToJson, + ) + final YoutubeLiveContainerRes? youtubeLiveContainer; + + @JsonKey(name: 'items') + final List? items; + + MediaRes({ + required this.youtubeLiveContainer, + required this.items, + }); + + factory MediaRes.fromJson(Map json) => + _$MediaResFromJson(json); + + Map toJson() => _$MediaResToJson(this); + + MediaEntity toEntity() => MediaEntity( + youtubeLive: youtubeLiveContainer?.toEntityList() ?? [], + items: items?.map((e) => e.toEntity()).toList() ?? [], + ); +} + +Map? _youtubeLiveContainerToJson( + YoutubeLiveContainerRes? instance, + ) => + instance?.toJson(); \ No newline at end of file diff --git a/lib/features/home/data/models/media_res.g.dart b/lib/features/home/data/models/media_res.g.dart new file mode 100644 index 00000000..b846da1b --- /dev/null +++ b/lib/features/home/data/models/media_res.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'media_res.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MediaRes _$MediaResFromJson(Map json) => MediaRes( + youtubeLiveContainer: YoutubeLiveContainerRes.fromJson( + json['youtubeLiveContainer'] as Map), + items: (json['items'] as List?) + ?.map((e) => MediaItemRes.fromJson(e as Map)) + .toList(), + ); + +Map _$MediaResToJson(MediaRes instance) => { + 'youtubeLiveContainer': + _youtubeLiveContainerToJson(instance.youtubeLiveContainer), + 'items': instance.items, + }; diff --git a/lib/features/home/data/models/recommend/best_chat_room_res.dart b/lib/features/home/data/models/recommend/best_chat_room_res.dart new file mode 100644 index 00000000..f82e2ce6 --- /dev/null +++ b/lib/features/home/data/models/recommend/best_chat_room_res.dart @@ -0,0 +1,33 @@ +import 'package:debateseason_frontend_v1/features/home/domain/entities/best_chat_room_entity.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'best_chat_room_res.g.dart'; + +@JsonSerializable() +class BestChatRoomRes { + final int issueId; + final String issueTitle; + final int debateId; + final String debateTitle; + final String time; + + BestChatRoomRes({ + required this.issueId, + required this.issueTitle, + required this.debateId, + required this.debateTitle, + required this.time, + }); + + factory BestChatRoomRes.fromJson(Map json) => + _$BestChatRoomResFromJson(json); + + Map toJson() => _$BestChatRoomResToJson(this); + + BestChatRoomEntity toEntity() => BestChatRoomEntity( + issueId: issueId, + issueTitle: issueTitle, + debateId: debateId, + debateTitle: debateTitle, + time: time); +} diff --git a/lib/features/home/data/models/recommend/best_chat_room_res.g.dart b/lib/features/home/data/models/recommend/best_chat_room_res.g.dart new file mode 100644 index 00000000..05af507d --- /dev/null +++ b/lib/features/home/data/models/recommend/best_chat_room_res.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'best_chat_room_res.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +BestChatRoomRes _$BestChatRoomResFromJson(Map json) => + BestChatRoomRes( + issueId: (json['issueId'] as num).toInt(), + issueTitle: json['issueTitle'] as String, + debateId: (json['debateId'] as num).toInt(), + debateTitle: json['debateTitle'] as String, + time: json['time'] as String, + ); + +Map _$BestChatRoomResToJson(BestChatRoomRes instance) => + { + 'issueId': instance.issueId, + 'issueTitle': instance.issueTitle, + 'debateId': instance.debateId, + 'debateTitle': instance.debateTitle, + 'time': instance.time, + }; diff --git a/lib/features/home/data/models/recommend/best_issue_room_res.dart b/lib/features/home/data/models/recommend/best_issue_room_res.dart new file mode 100644 index 00000000..e35cb05a --- /dev/null +++ b/lib/features/home/data/models/recommend/best_issue_room_res.dart @@ -0,0 +1,30 @@ +import 'package:debateseason_frontend_v1/features/home/domain/entities/best_issue_room_entity.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'best_issue_room_res.g.dart'; + +@JsonSerializable() +class BestIssueRoomRes { + final int issueId; + final String title; + final int countChatRoom; + final int bookMarks; + + BestIssueRoomRes({ + required this.issueId, + required this.title, + required this.countChatRoom, + required this.bookMarks, + }); + + factory BestIssueRoomRes.fromJson(Map json) => + _$BestIssueRoomResFromJson(json); + + Map toJson() => _$BestIssueRoomResToJson(this); + + BestIssueRoomEntity toEntity() => BestIssueRoomEntity( + issueId: issueId, + title: title, + countChatRoom: countChatRoom, + bookMarks: bookMarks); +} diff --git a/lib/features/home/data/models/recommend/best_issue_room_res.g.dart b/lib/features/home/data/models/recommend/best_issue_room_res.g.dart new file mode 100644 index 00000000..1aa34fe1 --- /dev/null +++ b/lib/features/home/data/models/recommend/best_issue_room_res.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'best_issue_room_res.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +BestIssueRoomRes _$BestIssueRoomResFromJson(Map json) => + BestIssueRoomRes( + issueId: (json['issueId'] as num).toInt(), + title: json['title'] as String, + countChatRoom: (json['countChatRoom'] as num).toInt(), + bookMarks: (json['bookMarks'] as num).toInt(), + ); + +Map _$BestIssueRoomResToJson(BestIssueRoomRes instance) => + { + 'issueId': instance.issueId, + 'title': instance.title, + 'countChatRoom': instance.countChatRoom, + 'bookMarks': instance.bookMarks, + }; diff --git a/lib/features/home/data/models/recommend/chat_room_response_res.dart b/lib/features/home/data/models/recommend/chat_room_response_res.dart new file mode 100644 index 00000000..ccce5585 --- /dev/null +++ b/lib/features/home/data/models/recommend/chat_room_response_res.dart @@ -0,0 +1,44 @@ +import 'package:debateseason_frontend_v1/common/enums/opinion_type.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/chat_room_response_entity.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'chat_room_response_res.g.dart'; + +@JsonSerializable() +class ChatRoomResponseRes { + final int chatRoomId; + final String title; + final String content; + final int agree; + final int disagree; + final DateTime createdAt; + @JsonKey(fromJson: OpinionType.fromJson, toJson: OpinionType.toJsonUpper) //서버에서는 String이라고 하는데, 일단 enum으로 처리해줌 + final OpinionType opinion; + final String time; + + ChatRoomResponseRes({ + required this.chatRoomId, + required this.title, + required this.content, + required this.agree, + required this.disagree, + required this.createdAt, + required this.opinion, + required this.time, + }); + + factory ChatRoomResponseRes.fromJson(Map json) => + _$ChatRoomResponseResFromJson(json); + + Map toJson() => _$ChatRoomResponseResToJson(this); + + ChatRoomResponseEntity toEntity() => ChatRoomResponseEntity( + chatRoomId: chatRoomId, + title: title, + content: content, + agree: agree, + disagree: disagree, + createdAt: createdAt, + opinion: opinion, + time: time); +} diff --git a/lib/features/home/data/models/recommend/chat_room_response_res.g.dart b/lib/features/home/data/models/recommend/chat_room_response_res.g.dart new file mode 100644 index 00000000..13cde77e --- /dev/null +++ b/lib/features/home/data/models/recommend/chat_room_response_res.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_room_response_res.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ChatRoomResponseRes _$ChatRoomResponseResFromJson(Map json) => + ChatRoomResponseRes( + chatRoomId: (json['chatRoomId'] as num).toInt(), + title: json['title'] as String, + content: json['content'] as String, + agree: (json['agree'] as num).toInt(), + disagree: (json['disagree'] as num).toInt(), + createdAt: DateTime.parse(json['createdAt'] as String), + opinion: OpinionType.fromJson(json['opinion'] as String), + time: json['time'] as String, + ); + +Map _$ChatRoomResponseResToJson( + ChatRoomResponseRes instance) => + { + 'chatRoomId': instance.chatRoomId, + 'title': instance.title, + 'content': instance.content, + 'agree': instance.agree, + 'disagree': instance.disagree, + 'createdAt': instance.createdAt.toIso8601String(), + 'opinion': OpinionType.toJsonUpper(instance.opinion), + 'time': instance.time, + }; diff --git a/lib/features/home/data/models/recommend_res.dart b/lib/features/home/data/models/recommend_res.dart new file mode 100644 index 00000000..3d24886b --- /dev/null +++ b/lib/features/home/data/models/recommend_res.dart @@ -0,0 +1,35 @@ +import 'package:debateseason_frontend_v1/features/home/data/models/recommend/best_chat_room_res.dart'; +import 'package:debateseason_frontend_v1/features/home/data/models/breaking_news_res.dart'; +import 'package:debateseason_frontend_v1/features/home/data/models/recommend/best_issue_room_res.dart'; +import 'package:debateseason_frontend_v1/features/home/data/models/recommend/chat_room_response_res.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/recommend_entity.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'recommend_res.g.dart'; + +@JsonSerializable() +class RecommendRes { + final List breakingNews; + final List top5BestChatRooms; + final List top5BestIssueRooms; + final List? chatRoomResponse; + + RecommendRes({ + required this.breakingNews, + required this.top5BestChatRooms, + required this.top5BestIssueRooms, + this.chatRoomResponse, + }); + + factory RecommendRes.fromJson(Map json) => + _$RecommendResFromJson(json); + + Map toJson() => _$RecommendResToJson(this); + + RecommendEntity toEntity() => RecommendEntity( + breakingNews: breakingNews.map((e) => e.toEntity()).toList(), + top5BestChatRooms: top5BestChatRooms.map((e) => e.toEntity()).toList(), + top5BestIssueRooms: top5BestIssueRooms.map((e) => e.toEntity()).toList(), + chatRoomResponse: chatRoomResponse?.map((e) => e.toEntity()).toList(), + ); +} diff --git a/lib/features/home/data/models/recommend_res.g.dart b/lib/features/home/data/models/recommend_res.g.dart new file mode 100644 index 00000000..b0ebfac7 --- /dev/null +++ b/lib/features/home/data/models/recommend_res.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recommend_res.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +RecommendRes _$RecommendResFromJson(Map json) => RecommendRes( + breakingNews: (json['breakingNews'] as List) + .map((e) => BreakingNewsRes.fromJson(e as Map)) + .toList(), + top5BestChatRooms: (json['top5BestChatRooms'] as List) + .map((e) => BestChatRoomRes.fromJson(e as Map)) + .toList(), + top5BestIssueRooms: (json['top5BestIssueRooms'] as List) + .map((e) => BestIssueRoomRes.fromJson(e as Map)) + .toList(), + chatRoomResponse: (json['chatRoomResponse'] as List?) + ?.map((e) => ChatRoomResponseRes.fromJson(e as Map)) + .toList(), + ); + +Map _$RecommendResToJson(RecommendRes instance) => + { + 'breakingNews': instance.breakingNews, + 'top5BestChatRooms': instance.top5BestChatRooms, + 'top5BestIssueRooms': instance.top5BestIssueRooms, + 'chatRoomResponse': instance.chatRoomResponse, + }; diff --git a/lib/features/home/data/repository_impls/live_all_repository_impl.dart b/lib/features/home/data/repository_impls/live_all_repository_impl.dart new file mode 100644 index 00000000..9f4cacc6 --- /dev/null +++ b/lib/features/home/data/repository_impls/live_all_repository_impl.dart @@ -0,0 +1,26 @@ +import 'package:debateseason_frontend_v1/features/home/data/datasources/live_all_data_source.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/youtube_live_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/repositories/live_all_repository.dart'; +import 'package:debateseason_frontend_v1/utils/base/ui_state.dart'; + +class LiveAllRepositoryImpl implements LiveAllRepository { + final LiveAllDataSource dataSource; + + LiveAllRepositoryImpl(this.dataSource); + + @override + Future>> getLiveAll() async { + final response = await dataSource.getLiveAll(); + switch (response.status) { + case 200: + final lives = response.data.youtubeLives; + final entities = lives.map((e) => e.toEntity()).toList(); + return UiState.success(entities); + default: + if (response.message.isEmpty) { + UiState.failure('데이터를 불러오는데 에러가 발생했습니다.'); + } + } + return UiState.failure(response.message); + } +} diff --git a/lib/features/home/data/repository_impls/media_repository_impl.dart b/lib/features/home/data/repository_impls/media_repository_impl.dart new file mode 100644 index 00000000..ca734402 --- /dev/null +++ b/lib/features/home/data/repository_impls/media_repository_impl.dart @@ -0,0 +1,24 @@ +import 'package:debateseason_frontend_v1/features/home/data/datasources/media_data_source.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/media_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/repositories/media_repository.dart'; +import 'package:debateseason_frontend_v1/utils/base/ui_state.dart'; + +class MediaRepositoryImpl implements MediaRepository { + final MediaDataSource dataSource; + + MediaRepositoryImpl(this.dataSource); + + @override + Future> getMedia({String? type, String? time}) async { + final response = await dataSource.getMedia(type: type, time: time); + switch (response.status) { + case 200: + return UiState.success(response.data.toEntity()); + default: + if (response.message.isEmpty) { + UiState.failure('데이터를 불러오는데 에러가 발생했습니다.'); + } + } + return UiState.failure(response.message); + } +} diff --git a/lib/features/home/data/repository_impls/recommend_repository_impl.dart b/lib/features/home/data/repository_impls/recommend_repository_impl.dart new file mode 100644 index 00000000..e2bca5d9 --- /dev/null +++ b/lib/features/home/data/repository_impls/recommend_repository_impl.dart @@ -0,0 +1,24 @@ +import 'package:debateseason_frontend_v1/features/home/data/datasources/recommend_data_source.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/recommend_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/repositories/recommend_repository.dart'; +import 'package:debateseason_frontend_v1/utils/base/ui_state.dart'; + +class RecommendRepositoryImpl implements RecommendRepository { + final RecommendDataSource dataSource; + + RecommendRepositoryImpl(this.dataSource); + + @override + Future> getRecommend() async { + final response = await dataSource.getRecommend(); + switch (response.status) { + case 200: + return UiState.success(response.data.toEntity()); + default: + if (response.message.isEmpty) { + UiState.failure('데이터를 불러오는데 에러가 발생했습니다.'); + } + } + return UiState.failure(response.message); + } +} diff --git a/lib/features/home/domain/entities/best_chat_room_entity.dart b/lib/features/home/domain/entities/best_chat_room_entity.dart new file mode 100644 index 00000000..05e57c84 --- /dev/null +++ b/lib/features/home/domain/entities/best_chat_room_entity.dart @@ -0,0 +1,15 @@ +class BestChatRoomEntity { + final int issueId; + final String issueTitle; + final int debateId; + final String debateTitle; + final String time; + + BestChatRoomEntity({ + required this.issueId, + required this.issueTitle, + required this.debateId, + required this.debateTitle, + required this.time, + }); +} diff --git a/lib/features/home/domain/entities/best_issue_room_entity.dart b/lib/features/home/domain/entities/best_issue_room_entity.dart new file mode 100644 index 00000000..3d742695 --- /dev/null +++ b/lib/features/home/domain/entities/best_issue_room_entity.dart @@ -0,0 +1,14 @@ +class BestIssueRoomEntity { + final int issueId; + final String title; + //final DateTime createdAt; // todo 명세서엔 없는데, 응답에서 null로 오고있음 + final int countChatRoom; + final int bookMarks; + + BestIssueRoomEntity({ + required this.issueId, + required this.title, + required this.countChatRoom, + required this.bookMarks, + }); +} diff --git a/lib/features/home/domain/entities/breaking_news_entity.dart b/lib/features/home/domain/entities/breaking_news_entity.dart new file mode 100644 index 00000000..4c23e9b4 --- /dev/null +++ b/lib/features/home/domain/entities/breaking_news_entity.dart @@ -0,0 +1,9 @@ +class BreakingNewsEntity { + final String title; + final String url; + + BreakingNewsEntity({ + required this.title, + required this.url, + }); +} diff --git a/lib/features/home/domain/entities/chat_room_response_entity.dart b/lib/features/home/domain/entities/chat_room_response_entity.dart new file mode 100644 index 00000000..9421932b --- /dev/null +++ b/lib/features/home/domain/entities/chat_room_response_entity.dart @@ -0,0 +1,23 @@ +import 'package:debateseason_frontend_v1/common/enums/opinion_type.dart'; + +class ChatRoomResponseEntity { + final int chatRoomId; + final String title; + final String content; + final int agree; + final int disagree; + final DateTime createdAt; + final OpinionType opinion; + final String time; + + ChatRoomResponseEntity({ + required this.chatRoomId, + required this.title, + required this.content, + required this.agree, + required this.disagree, + required this.createdAt, + required this.opinion, + required this.time, + }); +} diff --git a/lib/features/home/domain/entities/media_entity.dart b/lib/features/home/domain/entities/media_entity.dart new file mode 100644 index 00000000..6dfbc044 --- /dev/null +++ b/lib/features/home/domain/entities/media_entity.dart @@ -0,0 +1,13 @@ +import 'package:debateseason_frontend_v1/features/home/domain/entities/media_item_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/youtube_live_entity.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'media_entity.freezed.dart'; + +@freezed +class MediaEntity with _$MediaEntity { + factory MediaEntity({ + required List youtubeLive, + required List items, + }) = _MediaEntity; +} diff --git a/lib/features/home/domain/entities/media_entity.freezed.dart b/lib/features/home/domain/entities/media_entity.freezed.dart new file mode 100644 index 00000000..ca0bd537 --- /dev/null +++ b/lib/features/home/domain/entities/media_entity.freezed.dart @@ -0,0 +1,180 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'media_entity.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$MediaEntity { + List get youtubeLive => throw _privateConstructorUsedError; + List get items => throw _privateConstructorUsedError; + + /// Create a copy of MediaEntity + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $MediaEntityCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $MediaEntityCopyWith<$Res> { + factory $MediaEntityCopyWith( + MediaEntity value, $Res Function(MediaEntity) then) = + _$MediaEntityCopyWithImpl<$Res, MediaEntity>; + @useResult + $Res call({List youtubeLive, List items}); +} + +/// @nodoc +class _$MediaEntityCopyWithImpl<$Res, $Val extends MediaEntity> + implements $MediaEntityCopyWith<$Res> { + _$MediaEntityCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of MediaEntity + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? youtubeLive = null, + Object? items = null, + }) { + return _then(_value.copyWith( + youtubeLive: null == youtubeLive + ? _value.youtubeLive + : youtubeLive // ignore: cast_nullable_to_non_nullable + as List, + items: null == items + ? _value.items + : items // ignore: cast_nullable_to_non_nullable + as List, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$MediaEntityImplCopyWith<$Res> + implements $MediaEntityCopyWith<$Res> { + factory _$$MediaEntityImplCopyWith( + _$MediaEntityImpl value, $Res Function(_$MediaEntityImpl) then) = + __$$MediaEntityImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({List youtubeLive, List items}); +} + +/// @nodoc +class __$$MediaEntityImplCopyWithImpl<$Res> + extends _$MediaEntityCopyWithImpl<$Res, _$MediaEntityImpl> + implements _$$MediaEntityImplCopyWith<$Res> { + __$$MediaEntityImplCopyWithImpl( + _$MediaEntityImpl _value, $Res Function(_$MediaEntityImpl) _then) + : super(_value, _then); + + /// Create a copy of MediaEntity + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? youtubeLive = null, + Object? items = null, + }) { + return _then(_$MediaEntityImpl( + youtubeLive: null == youtubeLive + ? _value._youtubeLive + : youtubeLive // ignore: cast_nullable_to_non_nullable + as List, + items: null == items + ? _value._items + : items // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc + +class _$MediaEntityImpl implements _MediaEntity { + _$MediaEntityImpl( + {required final List youtubeLive, + required final List items}) + : _youtubeLive = youtubeLive, + _items = items; + + final List _youtubeLive; + @override + List get youtubeLive { + if (_youtubeLive is EqualUnmodifiableListView) return _youtubeLive; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_youtubeLive); + } + + final List _items; + @override + List get items { + if (_items is EqualUnmodifiableListView) return _items; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_items); + } + + @override + String toString() { + return 'MediaEntity(youtubeLive: $youtubeLive, items: $items)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$MediaEntityImpl && + const DeepCollectionEquality() + .equals(other._youtubeLive, _youtubeLive) && + const DeepCollectionEquality().equals(other._items, _items)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(_youtubeLive), + const DeepCollectionEquality().hash(_items)); + + /// Create a copy of MediaEntity + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$MediaEntityImplCopyWith<_$MediaEntityImpl> get copyWith => + __$$MediaEntityImplCopyWithImpl<_$MediaEntityImpl>(this, _$identity); +} + +abstract class _MediaEntity implements MediaEntity { + factory _MediaEntity( + {required final List youtubeLive, + required final List items}) = _$MediaEntityImpl; + + @override + List get youtubeLive; + @override + List get items; + + /// Create a copy of MediaEntity + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$MediaEntityImplCopyWith<_$MediaEntityImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/home/domain/entities/media_item_entity.dart b/lib/features/home/domain/entities/media_item_entity.dart new file mode 100644 index 00000000..f98717f7 --- /dev/null +++ b/lib/features/home/domain/entities/media_item_entity.dart @@ -0,0 +1,17 @@ +class MediaItemEntity { + final int id; + final String title; + final String supplier; + final DateTime outdated; + final String url; + final String? src; + + MediaItemEntity({ + required this.id, + required this.title, + required this.supplier, + required this.outdated, + required this.url, + this.src, + }); +} diff --git a/lib/features/home/domain/entities/recommend_entity.dart b/lib/features/home/domain/entities/recommend_entity.dart new file mode 100644 index 00000000..bb692aa4 --- /dev/null +++ b/lib/features/home/domain/entities/recommend_entity.dart @@ -0,0 +1,17 @@ +import 'package:debateseason_frontend_v1/features/home/domain/entities/best_chat_room_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/best_issue_room_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/breaking_news_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/chat_room_response_entity.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'recommend_entity.freezed.dart'; + +@freezed +class RecommendEntity with _$RecommendEntity { + factory RecommendEntity({ + required List breakingNews, + required List top5BestChatRooms, + required List top5BestIssueRooms, + required List? chatRoomResponse, + }) = _RecommendEntity; +} diff --git a/lib/features/home/domain/entities/recommend_entity.freezed.dart b/lib/features/home/domain/entities/recommend_entity.freezed.dart new file mode 100644 index 00000000..1eb79c86 --- /dev/null +++ b/lib/features/home/domain/entities/recommend_entity.freezed.dart @@ -0,0 +1,254 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'recommend_entity.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$RecommendEntity { + List get breakingNews => + throw _privateConstructorUsedError; + List get top5BestChatRooms => + throw _privateConstructorUsedError; + List get top5BestIssueRooms => + throw _privateConstructorUsedError; + List? get chatRoomResponse => + throw _privateConstructorUsedError; + + /// Create a copy of RecommendEntity + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $RecommendEntityCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $RecommendEntityCopyWith<$Res> { + factory $RecommendEntityCopyWith( + RecommendEntity value, $Res Function(RecommendEntity) then) = + _$RecommendEntityCopyWithImpl<$Res, RecommendEntity>; + @useResult + $Res call( + {List breakingNews, + List top5BestChatRooms, + List top5BestIssueRooms, + List? chatRoomResponse}); +} + +/// @nodoc +class _$RecommendEntityCopyWithImpl<$Res, $Val extends RecommendEntity> + implements $RecommendEntityCopyWith<$Res> { + _$RecommendEntityCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of RecommendEntity + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? breakingNews = null, + Object? top5BestChatRooms = null, + Object? top5BestIssueRooms = null, + Object? chatRoomResponse = freezed, + }) { + return _then(_value.copyWith( + breakingNews: null == breakingNews + ? _value.breakingNews + : breakingNews // ignore: cast_nullable_to_non_nullable + as List, + top5BestChatRooms: null == top5BestChatRooms + ? _value.top5BestChatRooms + : top5BestChatRooms // ignore: cast_nullable_to_non_nullable + as List, + top5BestIssueRooms: null == top5BestIssueRooms + ? _value.top5BestIssueRooms + : top5BestIssueRooms // ignore: cast_nullable_to_non_nullable + as List, + chatRoomResponse: freezed == chatRoomResponse + ? _value.chatRoomResponse + : chatRoomResponse // ignore: cast_nullable_to_non_nullable + as List?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$RecommendEntityImplCopyWith<$Res> + implements $RecommendEntityCopyWith<$Res> { + factory _$$RecommendEntityImplCopyWith(_$RecommendEntityImpl value, + $Res Function(_$RecommendEntityImpl) then) = + __$$RecommendEntityImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {List breakingNews, + List top5BestChatRooms, + List top5BestIssueRooms, + List? chatRoomResponse}); +} + +/// @nodoc +class __$$RecommendEntityImplCopyWithImpl<$Res> + extends _$RecommendEntityCopyWithImpl<$Res, _$RecommendEntityImpl> + implements _$$RecommendEntityImplCopyWith<$Res> { + __$$RecommendEntityImplCopyWithImpl( + _$RecommendEntityImpl _value, $Res Function(_$RecommendEntityImpl) _then) + : super(_value, _then); + + /// Create a copy of RecommendEntity + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? breakingNews = null, + Object? top5BestChatRooms = null, + Object? top5BestIssueRooms = null, + Object? chatRoomResponse = freezed, + }) { + return _then(_$RecommendEntityImpl( + breakingNews: null == breakingNews + ? _value._breakingNews + : breakingNews // ignore: cast_nullable_to_non_nullable + as List, + top5BestChatRooms: null == top5BestChatRooms + ? _value._top5BestChatRooms + : top5BestChatRooms // ignore: cast_nullable_to_non_nullable + as List, + top5BestIssueRooms: null == top5BestIssueRooms + ? _value._top5BestIssueRooms + : top5BestIssueRooms // ignore: cast_nullable_to_non_nullable + as List, + chatRoomResponse: freezed == chatRoomResponse + ? _value._chatRoomResponse + : chatRoomResponse // ignore: cast_nullable_to_non_nullable + as List?, + )); + } +} + +/// @nodoc + +class _$RecommendEntityImpl implements _RecommendEntity { + _$RecommendEntityImpl( + {required final List breakingNews, + required final List top5BestChatRooms, + required final List top5BestIssueRooms, + required final List? chatRoomResponse}) + : _breakingNews = breakingNews, + _top5BestChatRooms = top5BestChatRooms, + _top5BestIssueRooms = top5BestIssueRooms, + _chatRoomResponse = chatRoomResponse; + + final List _breakingNews; + @override + List get breakingNews { + if (_breakingNews is EqualUnmodifiableListView) return _breakingNews; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_breakingNews); + } + + final List _top5BestChatRooms; + @override + List get top5BestChatRooms { + if (_top5BestChatRooms is EqualUnmodifiableListView) + return _top5BestChatRooms; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_top5BestChatRooms); + } + + final List _top5BestIssueRooms; + @override + List get top5BestIssueRooms { + if (_top5BestIssueRooms is EqualUnmodifiableListView) + return _top5BestIssueRooms; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_top5BestIssueRooms); + } + + final List? _chatRoomResponse; + @override + List? get chatRoomResponse { + final value = _chatRoomResponse; + if (value == null) return null; + if (_chatRoomResponse is EqualUnmodifiableListView) + return _chatRoomResponse; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); + } + + @override + String toString() { + return 'RecommendEntity(breakingNews: $breakingNews, top5BestChatRooms: $top5BestChatRooms, top5BestIssueRooms: $top5BestIssueRooms, chatRoomResponse: $chatRoomResponse)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$RecommendEntityImpl && + const DeepCollectionEquality() + .equals(other._breakingNews, _breakingNews) && + const DeepCollectionEquality() + .equals(other._top5BestChatRooms, _top5BestChatRooms) && + const DeepCollectionEquality() + .equals(other._top5BestIssueRooms, _top5BestIssueRooms) && + const DeepCollectionEquality() + .equals(other._chatRoomResponse, _chatRoomResponse)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(_breakingNews), + const DeepCollectionEquality().hash(_top5BestChatRooms), + const DeepCollectionEquality().hash(_top5BestIssueRooms), + const DeepCollectionEquality().hash(_chatRoomResponse)); + + /// Create a copy of RecommendEntity + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$RecommendEntityImplCopyWith<_$RecommendEntityImpl> get copyWith => + __$$RecommendEntityImplCopyWithImpl<_$RecommendEntityImpl>( + this, _$identity); +} + +abstract class _RecommendEntity implements RecommendEntity { + factory _RecommendEntity( + {required final List breakingNews, + required final List top5BestChatRooms, + required final List top5BestIssueRooms, + required final List? chatRoomResponse}) = + _$RecommendEntityImpl; + + @override + List get breakingNews; + @override + List get top5BestChatRooms; + @override + List get top5BestIssueRooms; + @override + List? get chatRoomResponse; + + /// Create a copy of RecommendEntity + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$RecommendEntityImplCopyWith<_$RecommendEntityImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/home/domain/entities/youtube_live_entity.dart b/lib/features/home/domain/entities/youtube_live_entity.dart new file mode 100644 index 00000000..154cd422 --- /dev/null +++ b/lib/features/home/domain/entities/youtube_live_entity.dart @@ -0,0 +1,19 @@ +class YoutubeLiveEntity { + final int id; + final String title; + final String supplier; + final String videoId; + final String category; + final DateTime? createAt; + final String? src; + + YoutubeLiveEntity({ + required this.id, + required this.title, + required this.supplier, + required this.videoId, + required this.category, + this.createAt, + this.src, + }); +} \ No newline at end of file diff --git a/lib/features/home/domain/repositories/live_all_repository.dart b/lib/features/home/domain/repositories/live_all_repository.dart new file mode 100644 index 00000000..1cf18527 --- /dev/null +++ b/lib/features/home/domain/repositories/live_all_repository.dart @@ -0,0 +1,6 @@ +import 'package:debateseason_frontend_v1/features/home/domain/entities/youtube_live_entity.dart'; +import 'package:debateseason_frontend_v1/utils/base/ui_state.dart'; + +abstract class LiveAllRepository { + Future>> getLiveAll(); +} \ No newline at end of file diff --git a/lib/features/home/domain/repositories/media_repository.dart b/lib/features/home/domain/repositories/media_repository.dart new file mode 100644 index 00000000..5995f209 --- /dev/null +++ b/lib/features/home/domain/repositories/media_repository.dart @@ -0,0 +1,6 @@ +import 'package:debateseason_frontend_v1/features/home/domain/entities/media_entity.dart'; +import 'package:debateseason_frontend_v1/utils/base/ui_state.dart'; + +abstract class MediaRepository { + Future> getMedia({String? type, String? time}); +} diff --git a/lib/features/home/domain/repositories/recommend_repository.dart b/lib/features/home/domain/repositories/recommend_repository.dart new file mode 100644 index 00000000..a60cc6ee --- /dev/null +++ b/lib/features/home/domain/repositories/recommend_repository.dart @@ -0,0 +1,6 @@ +import 'package:debateseason_frontend_v1/features/home/domain/entities/recommend_entity.dart'; +import 'package:debateseason_frontend_v1/utils/base/ui_state.dart'; + +abstract class RecommendRepository { + Future> getRecommend(); +} \ No newline at end of file diff --git a/lib/features/home/home_binding.dart b/lib/features/home/home_binding.dart deleted file mode 100644 index bcf6d68d..00000000 --- a/lib/features/home/home_binding.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:get/get.dart'; - -class HomeBinding extends Bindings { - @override - void dependencies() { - // Get.lazyPut(() => HomeViewModel()); - } -} diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart deleted file mode 100644 index 554a4874..00000000 --- a/lib/features/home/home_screen.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:debateseason_frontend_v1/widgets/de_scaffold.dart'; -import 'package:debateseason_frontend_v1/widgets/de_text.dart'; -import 'package:flutter/material.dart'; - -class HomeScreen extends StatelessWidget { - const HomeScreen({super.key}); - - @override - Widget build(BuildContext context) { - return DeScaffold( - body: DeText('홈 화면'), - ); - } -} diff --git a/lib/features/home/presentation/view_models/home_view_model.dart b/lib/features/home/presentation/view_models/home_view_model.dart new file mode 100644 index 00000000..ea8e54b1 --- /dev/null +++ b/lib/features/home/presentation/view_models/home_view_model.dart @@ -0,0 +1,3 @@ +import 'package:get/get.dart'; + +class HomeViewModel extends GetxController {} diff --git a/lib/features/home/presentation/view_models/live_view_model.dart b/lib/features/home/presentation/view_models/live_view_model.dart new file mode 100644 index 00000000..2a9823b3 --- /dev/null +++ b/lib/features/home/presentation/view_models/live_view_model.dart @@ -0,0 +1,32 @@ +import 'package:debateseason_frontend_v1/features/home/domain/entities/youtube_live_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/repositories/live_all_repository.dart'; +import 'package:debateseason_frontend_v1/utils/base/ui_state.dart'; +import 'package:debateseason_frontend_v1/utils/logger.dart'; +import 'package:get/get.dart'; + +class LiveViewModel extends GetxController { + late final LiveAllRepository _liveAllRepository; + final _liveList = Rx>>( + const UiState.loading()); + + UiState> get liveList => _liveList.value; + + final showPip = false.obs; + + @override + void onInit() { + super.onInit(); + _liveAllRepository = Get.find(); + getLiveAll(); + } + Future getLiveAll() async { + try { + final response = await _liveAllRepository.getLiveAll(); + log.d(response); + _liveList.value = response; + } catch (e, s) { + log.e(e); + log.e(s); + } + } +} \ No newline at end of file diff --git a/lib/features/home/presentation/view_models/media_view_model.dart b/lib/features/home/presentation/view_models/media_view_model.dart new file mode 100644 index 00000000..5a71030e --- /dev/null +++ b/lib/features/home/presentation/view_models/media_view_model.dart @@ -0,0 +1,124 @@ +import 'package:debateseason_frontend_v1/features/home/domain/entities/media_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/media_item_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/youtube_live_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/repositories/media_repository.dart'; +import 'package:debateseason_frontend_v1/utils/base/ui_state.dart'; +import 'package:debateseason_frontend_v1/utils/logger.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:youtube_player_flutter/youtube_player_flutter.dart'; + +class MediaViewModel extends GetxController { + late final MediaRepository _mediaRepository; + final _mediaData = Rx>(const UiState.loading()); + + UiState get mediaData => _mediaData.value; + + static const int _pageSize = 5; + String? lastFetchedTime; + bool hasMore = true; + final RxBool isAppending = false.obs; + + final ScrollController scrollController = ScrollController(); + + final List _allItems = []; + List _initialYoutubeLive = []; + + final showPip = false.obs; + late YoutubePlayerController youtubePlayerController; + + void initYoutube(String videoId) { + youtubePlayerController = YoutubePlayerController( + initialVideoId: videoId, + flags: const YoutubePlayerFlags(autoPlay: true), + ); + } + + void togglePip(String videoId) { + if (!showPip.value) { + initYoutube(videoId); + } + showPip.toggle(); + } + + @override + void onInit() { + super.onInit(); + _mediaRepository = Get.find(); + + scrollController.addListener(() { + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 200) { + fetchMediaData(); + } + }); + + fetchMediaData(); + } + + @override + void onClose() { + youtubePlayerController.dispose(); + scrollController + ..removeListener(() {}) + ..dispose(); + super.onClose(); + } + + Future fetchMediaData({String? type}) async { + if (!hasMore || isAppending.value) return; + isAppending.value = true; + + if (lastFetchedTime == null) { + _allItems.clear(); + _initialYoutubeLive.clear(); + _mediaData.value = const UiState.loading(); + } + + try { + final result = await _mediaRepository.getMedia( + type: type, + time: lastFetchedTime, + ); + + result.when( + loading: () { + // 이미 loading 상태는 처리했으므로 무시 + }, + success: (media) { + if (_initialYoutubeLive.isEmpty) { + _initialYoutubeLive = media.youtubeLive; + } + + // items 누적 + if (media.items.isNotEmpty) { + _allItems.addAll(media.items); + + lastFetchedTime = media.items.last.outdated.toIso8601String(); + + if (media.items.length < _pageSize) { + hasMore = false; + } + } else { + hasMore = false; + } + + final combined = MediaEntity( + youtubeLive: _initialYoutubeLive, + items: _allItems, + ); + _mediaData.value = UiState.success(combined); + }, + failure: (msg) { + _mediaData.value = UiState.failure(msg); + }, + ); + } catch (e, s) { + log.e('fetchMediaData error: $e'); + log.e(s); + _mediaData.value = const UiState.failure('네트워크 오류가 발생했습니다.'); + } finally { + isAppending.value = false; + } + } +} diff --git a/lib/features/home/presentation/view_models/ratio_view_model.dart b/lib/features/home/presentation/view_models/ratio_view_model.dart new file mode 100644 index 00000000..f43286c1 --- /dev/null +++ b/lib/features/home/presentation/view_models/ratio_view_model.dart @@ -0,0 +1,14 @@ +getPercentages(int agree, int disagree) { + int total = agree + disagree; + double agreeRatio = agree / total; + double disagreeRatio = disagree / total; + + if (total == 0) { + agreeRatio = 0; + disagreeRatio = 0; + } + String agreeRatioText = (agreeRatio * 100).toStringAsFixed(0); + String disagreeRatioText = (disagreeRatio * 100).toStringAsFixed(0); + + return [agreeRatioText, disagreeRatioText]; +} \ No newline at end of file diff --git a/lib/features/home/presentation/view_models/recommend_view_model.dart b/lib/features/home/presentation/view_models/recommend_view_model.dart new file mode 100644 index 00000000..3864c2fe --- /dev/null +++ b/lib/features/home/presentation/view_models/recommend_view_model.dart @@ -0,0 +1,36 @@ +import 'package:debateseason_frontend_v1/features/home/domain/entities/recommend_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/repositories/recommend_repository.dart'; +import 'package:debateseason_frontend_v1/utils/base/ui_state.dart'; +import 'package:debateseason_frontend_v1/utils/logger.dart'; +import 'package:get/get.dart'; + +class RecommendViewModel extends GetxController { + late final RecommendRepository _recommendRepository; + final _recommendData = Rx>(const UiState.loading()); + + UiState get recommendData => _recommendData.value; + + //final RxList breakingNews = [].obs; + //final RxList top5BestChatRooms = [].obs; + //final RxList top5BestIssueRooms = [].obs; + //final Rxn> chatRoomResponse = Rxn>(); + //final RxList chatRoomResponse = [].obs; + + @override + void onInit() { + super.onInit(); + _recommendRepository = Get.find(); + fetchRecommendData(); + } + + Future fetchRecommendData() async { + try { + final response = await _recommendRepository.getRecommend(); + log.d('🔍 Raw response: $response'); + _recommendData.value = response; + } catch (e, s) { + log.d(e); + log.d(s); + } + } +} diff --git a/lib/features/home/presentation/views/home_media_page.dart b/lib/features/home/presentation/views/home_media_page.dart new file mode 100644 index 00000000..e0b66638 --- /dev/null +++ b/lib/features/home/presentation/views/home_media_page.dart @@ -0,0 +1,441 @@ +import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_dimensions.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_icons.dart'; +import 'package:debateseason_frontend_v1/core/routers/get_router_name.dart'; +import 'package:debateseason_frontend_v1/core/services/pip_controller.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/media_item_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/presentation/view_models/media_view_model.dart'; +import 'package:debateseason_frontend_v1/features/profile/presentation/views/web_view_page.dart'; +import 'package:debateseason_frontend_v1/utils/de_toast.dart'; +import 'package:debateseason_frontend_v1/widgets/de_bottom_sheet_notitle.dart'; +import 'package:debateseason_frontend_v1/widgets/de_gesture_detector.dart'; +import 'package:debateseason_frontend_v1/widgets/de_scaffold.dart'; +import 'package:debateseason_frontend_v1/widgets/de_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:html_unescape/html_unescape.dart'; + +class HomeMediaPage extends GetView { + const HomeMediaPage({super.key}); + + PipController get pipController => Get.find(); + + @override + Widget build(BuildContext context) { + return DeScaffold( + body: _body(context), + ); + } + + Widget _body(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: Obx(() { + final state = controller.mediaData; + return state.when( + loading: () => const Center(child: CircularProgressIndicator()), + failure: (msg) => Center( + child: DeText(msg, + style: DeFonts.body16Sb.copyWith(color: DeColors.red))), + success: (media) { + final items = media.items; + // 0: v4, 1: mainMedia, 2: v40, 3: header, 4~: items, 마지막: 로더 + final totalCount = + 4 + items.length + (controller.hasMore ? 1 : 0); + + return ListView.builder( + controller: controller.scrollController, + padding: EdgeInsets.zero, + itemCount: totalCount, + itemBuilder: (ctx, idx) { + switch (idx) { + case 0: + return DeGaps.v4; + case 1: + return _mainMedia(context); + case 2: + return DeGaps.v40; + case 3: + return _mediaHeader(); + default: + final itemIndex = idx - 4; + if (itemIndex < items.length) { + return Padding( + padding: DeDimensions.padding20x12, + child: _mediaItem(items[itemIndex]), + ); + } + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ); + } + }, + ); + }, + ); + }), + ), + ], + ); + } + + Widget _mainMedia(BuildContext context) { + return Obx(() { + final youtubeData = controller.mediaData; + return youtubeData.when( + loading: () => const Center(child: CircularProgressIndicator()), + success: (youtubeData) { + final youtube = youtubeData.youtubeLive; + if (youtube.isEmpty) { + return Container(); + } + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + DeGaps.h20, + DeText( + '실시간 Live', + style: DeFonts.header18Sb.copyWith(color: DeColors.grey10), + ), + ], + ), + DeGaps.v12, + DeGestureDetector( + onTap: () { + // Get.to(() => WebViewPage( + // url: 'https://youtube.com/${youtube.videoId}', + // title: youtube.title, + // )); + //controller.togglePip(youtube[0].videoId); + pipController.show(youtube[0].videoId); + }, + child: Container( + width: double.infinity, + height: 180, + color: DeColors.brand, + child: Image.network( + 'https://img.youtube.com/vi/${youtube[0].videoId}/hqdefault.jpg', + //youtube[0].src.toString(), 이거 화질 안 좋다 + fit: BoxFit.cover, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 0), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: DeGestureDetector( + onTap: () {}, + child: DeText( + HtmlUnescape().convert(youtube[0].title), + style: DeFonts.body16M + .copyWith(color: DeColors.grey10), + overflow: TextOverflow.ellipsis, + ), + ), + ), + DeGaps.h12, + DeGestureDetector( + onTap: () { + showModalBottomSheet( + context: context, //todo context 넣은 부모위젯들 삭제 + builder: (context) { + return DeBottomSheetNoTitle( + widget: Column( + children: [ + DeGestureDetector( + onTap: () { + Clipboard.setData(ClipboardData( + text: + 'https://youtube.com/${youtube[0].videoId}')); + DeToast.showToast( + msg: 'URL이 복사되었습니다.', + ); + }, + child: (Row( + children: [ + SvgPicture.asset( + DeIcons.icCopyGrey10, + ), + DeGaps.h16, + DeText('URL 복사하기', + style: DeFonts.body16M.copyWith( + color: DeColors.grey10)), + ], + )), + ), + DeGaps.v16, + ], + ), + ); + }, + ); + }, + child: SvgPicture.asset( + DeIcons.icMoreGrey50, + ), + ), + ], + ), + DeGaps.v4, + Row( + children: [ + DeText(youtube[0].supplier, + style: DeFonts.caption12M + .copyWith(color: DeColors.grey50)), + DeGaps.h6, + SvgPicture.asset( + DeIcons.icDotGrey50, + ), + DeGaps.h6, + DeText(youtube[0].createAt.toString().substring(0, 16), + style: DeFonts.caption12M + .copyWith(color: DeColors.grey50)), + ], + ), + ], + ), + ), + DeGaps.v20, + Padding( + padding: DeDimensions.horizontal20, + child: DeGestureDetector( + onTap: () { + Get.toNamed(GetRouterName.lives); + }, + child: Container( + width: double.infinity, + padding: DeDimensions.vertical8, + decoration: ShapeDecoration( + color: DeColors.grey110, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + DeText( + '라이브 모두 보기', + style: + DeFonts.body14M.copyWith(color: DeColors.grey50), + ), + ], + ), + ), + ), + ), + ], + ); + }, + failure: (error) => Center( + child: DeText( + error, + style: DeFonts.body16Sb.copyWith(color: DeColors.red), + ), + ), + ); + }); + } + + Widget _mediaHeader() { + /*Widget mediaCategory() { + Widget categoryBtn(String title) { + final bool isSelected = title == '모두'; + + return DeGestureDetector( + onTap: () {}, + child: Container( + padding: DeDimensions.padding10x4, + decoration: BoxDecoration( + color: isSelected ? DeColors.grey10 : DeColors.grey110, + borderRadius: BorderRadius.circular(6), + ), + child: DeText( + title, + style: DeFonts.body14Sb.copyWith( + color: isSelected ? DeColors.grey120 : DeColors.grey50), + ), + ), + ); + } + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + categoryBtn('모두'), + DeGaps.h12, + categoryBtn('뉴스 · 기사'), + DeGaps.h12, + categoryBtn('Youtube'), + DeGaps.h12, + categoryBtn('커뮤니티'), + DeGaps.h12, + ], + ), + ); + }*/ + + return Padding( + padding: DeDimensions.padding20x12, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DeText( + '실시간 미디어', + style: DeFonts.header18Sb.copyWith(color: DeColors.grey10), + ), + //DeGaps.v16, + //mediaCategory(), + //DeGaps.v20, + ], + ), + ); + } + + Widget _mediaItem(MediaItemEntity media) { + return DeGestureDetector( + onTap: () { + Get.to(() => WebViewPage( + url: media.url, + title: media.title, + )); + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 78, + height: 78, + decoration: ShapeDecoration( + color: DeColors.brand, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + )), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + Positioned.fill( + child: media.src != null + ? Image.network( + media.src!, + fit: BoxFit.cover, + ) + : Image.asset( + 'assets/images/img_media_thumbnail.png', + fit: BoxFit.cover, + ), + ), + // Positioned( + // right: 6, + // top: 6, + // child: Container( + // width: 28, + // height: 28, + // padding: DeDimensions.all8, + // // todo padding 수정필요 + // decoration: BoxDecoration( + // color: DeColors.trans50, + // shape: BoxShape.circle, + // ), + // child: SvgPicture.asset( + // { + // 'YOUTUBE': DeIcons.icYoutubeGrey10, + // 'ARTICLE': DeIcons.icArticleGrey10, + // 'COMM': DeIcons.icCommGrey10, + // }[media.supplier] ?? + // '', + // )), + // ), + ], + ), + ), + DeGaps.h12, + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: DeText( + HtmlUnescape().convert(media.title), + style: DeFonts.body16M.copyWith(color: DeColors.grey10), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + DeGaps.h12, + DeGestureDetector( + onTap: () { + Get.bottomSheet(DeBottomSheetNoTitle( + widget: Column( + children: [ + DeGestureDetector( + onTap: () { + Clipboard.setData( + ClipboardData(text: media.url)); + DeToast.showToast( + msg: 'URL이 복사되었습니다.', + ); + }, + child: (Row( + children: [ + SvgPicture.asset( + DeIcons.icCopyGrey10, + ), + DeGaps.h16, + DeText('URL 복사하기', + style: DeFonts.body16M + .copyWith(color: DeColors.grey10)), + ], + )), + ), + DeGaps.v16, + ], + ), + )); + }, + child: SvgPicture.asset( + DeIcons.icMoreGrey50, + ), + ), + ], + ), + DeGaps.v4, + Row( + children: [ + DeText(media.supplier, + style: DeFonts.caption12M + .copyWith(color: DeColors.grey50)), + DeGaps.h6, + SvgPicture.asset( + DeIcons.icDotGrey50, + ), + DeGaps.h6, + DeText(media.outdated.toString().substring(0, 10), + style: DeFonts.caption12M + .copyWith(color: DeColors.grey50)), + ], + ), + ], + ), + ) + ], + ), + ); + } +} diff --git a/lib/features/home/presentation/views/home_recommend_page.dart b/lib/features/home/presentation/views/home_recommend_page.dart new file mode 100644 index 00000000..5366598a --- /dev/null +++ b/lib/features/home/presentation/views/home_recommend_page.dart @@ -0,0 +1,287 @@ +import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_dimensions.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; +import 'package:debateseason_frontend_v1/core/routers/get_router_name.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/best_chat_room_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/best_issue_room_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/chat_room_response_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/presentation/view_models/recommend_view_model.dart'; +import 'package:debateseason_frontend_v1/features/home/presentation/widgets/debate_card.dart'; +import 'package:debateseason_frontend_v1/features/home/presentation/widgets/issue_card.dart'; +import 'package:debateseason_frontend_v1/features/home/presentation/widgets/issue_card_new.dart'; +import 'package:debateseason_frontend_v1/widgets/de_gesture_detector.dart'; +import 'package:debateseason_frontend_v1/widgets/de_scaffold.dart'; +import 'package:debateseason_frontend_v1/widgets/de_text.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class HomeRecommendPage extends GetView { + const HomeRecommendPage({super.key}); + + @override + Widget build(BuildContext context) { + return DeScaffold( + body: _body(), + ); + } + + Widget _body() { + return ListView( + children: [ + //_headlineNews(), + DeGaps.v20, + _bestDebate(), + DeGaps.v40, + _bestIssue(), + DeGaps.v40, + _myDebate(), + DeGaps.v40, + //_recommendDebate(), + ], + ); + } + + /*Widget _headlineNews() { + return Padding( + padding: DeDimensions.horizontal20, + child: Column( + children: [ + Container( + width: double.infinity, + padding: DeDimensions.padding10x8, + decoration: BoxDecoration( + color: DeColors.grey110, + borderRadius: BorderRadius.circular(10), + ), + child: DeText( + '속보나올 자리', + style: DeFonts.body14M, + ), + ), + DeGaps.v4, + ], + ), + ); + }*/ + + //-------------------------------------------------- + + Widget _bestDebate() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: DeDimensions.horizontal20, + child: DeText( + '뜨겁게 논쟁 중인 찬반토론', + style: DeFonts.header18Sb.copyWith(color: DeColors.grey10), + ), + ), + DeGaps.v12, + _bestDebateList(), + ], + ); + } + + Widget _bestDebateItem(BestChatRoomEntity debate) { + return DeGestureDetector( + onTap: () { + Get.toNamed( + GetRouterName.debate, + arguments: { + 'chatroom_id': debate.debateId, + 'issue_title': debate.issueTitle, + }, + ); + }, + child: DebateCard(bestChatRoom: debate), + ); + } + + Widget _bestDebateList() { + return Obx(() { + final debateData = controller.recommendData; + + return debateData.when( + loading: () => const Center(child: CircularProgressIndicator()), + success: (debateData) { + final debaterooms = debateData.top5BestChatRooms; + return SizedBox( + height: 140, + child: ListView.builder( + itemCount: debaterooms.length, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) { + return Row( + children: [ + if (index == 0) DeGaps.h20, + _bestDebateItem(debaterooms[index]), + DeGaps.h8, + ], + ); + }, + ), + ); + }, + failure: (error) => Center( + child: DeText( + error, + style: DeFonts.body16Sb.copyWith(color: DeColors.red), + ), + ), + ); + }); + } + + //-------------------------------------------------- + + Widget _bestIssue() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: DeDimensions.horizontal20, + child: DeText( + '이런 이슈는 어때요?', + style: DeFonts.header18Sb.copyWith(color: DeColors.grey10), + ), + ), + DeGaps.v12, + _bestIssueList(), + ], + ); + } + + Widget _bestIssueList() { + return Obx(() { + final issueData = controller.recommendData; + return issueData.when( + loading: () => const Center(child: CircularProgressIndicator()), + success: (issueData) { + final issuerooms = issueData.top5BestIssueRooms; + return SizedBox( + height: 95, // todo 높이 조정 + child: ListView.builder( + itemBuilder: (context, index) { + return Row( + children: [ + if (index == 0) DeGaps.h20, + bestIssueItem(issuerooms[index]), + DeGaps.h8, + ], + ); + }, + itemCount: issuerooms.length, + scrollDirection: Axis.horizontal, + ), + ); + }, + failure: (error) => Center( + child: DeText( + error, + style: DeFonts.body16Sb.copyWith(color: DeColors.red), + ))); + }); + } + + Widget bestIssueItem(BestIssueRoomEntity issue) { + return DeGestureDetector( + onTap: () { + Get.toNamed(GetRouterName.issue, arguments: { + 'issue_id': issue.issueId, + }); + }, + child: IssueCardNew(bestIssueRoom: issue), + ); + } + + //-------------------------------------------------- + + Widget _myDebate() { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: DeDimensions.horizontal20, + child: DeText('참여 중인 토론', style: DeFonts.header18Sb), + ), + DeGaps.v12, + _debateList(), + ], + ); + } + + Widget _debateList() { + return Obx(() { + final mydebateData = controller.recommendData; + return mydebateData.when( + loading: () => const Center(child: CircularProgressIndicator()), + success: (mydebateData) { + final mydebates = mydebateData.chatRoomResponse; + final int len = mydebates?.length ?? 0; + if (len == 0) { + return DeText( + '참여 중인 토론이 없습니다. 지금 바로 토론에 참여해보세요.', + style: DeFonts.body16Sb.copyWith(color: DeColors.grey10), + ); + } + return Padding( + padding: DeDimensions.horizontal20, + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + return _debateItem(mydebates![index]); + }, + separatorBuilder: (context, index) => DeGaps.v12, + itemCount: len, + ), + ); + }, + failure: (error) => Center( + child: DeText( + error, + style: DeFonts.body16Sb.copyWith(color: DeColors.red), + ), + ), + ); + }); + } + + Widget _debateItem(ChatRoomResponseEntity chat) { + return DeGestureDetector( + onTap: () { + Get.toNamed( + GetRouterName.debate, + arguments: { + 'chatroom_id': chat.chatRoomId, + 'issue_title': chat.title, //todo issuetitle와야함 + }, + ); + }, + child: IssueCard(chatroom: chat), + ); + } + + //-------------------------------------------------- + + /*Widget _recommendDebate() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: DeDimensions.horizontal20, + child: DeText('이런 토론을 추천해요', style: DeFonts.header18Sb), + ), + DeGaps.v12, + _recommendDebateList(), + ], + ); + } + + Widget _recommendDebateList() { + return DeText('asdf'); + }*/ +} diff --git a/lib/features/home/presentation/views/home_screen.dart b/lib/features/home/presentation/views/home_screen.dart new file mode 100644 index 00000000..a385645c --- /dev/null +++ b/lib/features/home/presentation/views/home_screen.dart @@ -0,0 +1,52 @@ +import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; +import 'package:debateseason_frontend_v1/features/home/presentation/views/home_screen_content.dart'; +import 'package:debateseason_frontend_v1/utils/amplitude_util.dart'; +import 'package:debateseason_frontend_v1/widgets/de_app_bar.dart'; +import 'package:debateseason_frontend_v1/widgets/de_scaffold.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + @override + void initState() { + super.initState(); + AmplitudeUtil.trackEvent(eventName: 'Home'); + } + + @override + Widget build(BuildContext context) { + return DeScaffold( + appBar: _appbar(), + body: HomeScreenContent(), + ); + } + + DeAppBar _appbar() { + return DeAppBar( + title: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SvgPicture.asset( + 'assets/icons/ic_logo.svg', + width: 24, + height: 24, + ), + DeGaps.h4, + SvgPicture.asset( + 'assets/icons/ic_debateseason.svg', + width: 24, + height: 24, + ), + ], + ), + isBack: false, + ); + } +} diff --git a/lib/features/home/presentation/views/home_screen_content.dart b/lib/features/home/presentation/views/home_screen_content.dart new file mode 100644 index 00000000..289402d5 --- /dev/null +++ b/lib/features/home/presentation/views/home_screen_content.dart @@ -0,0 +1,126 @@ +import 'package:debateseason_frontend_v1/core/constants/de_dimensions.dart'; +import 'package:debateseason_frontend_v1/features/home/presentation/widgets/home_tab_bar.dart'; +import 'package:debateseason_frontend_v1/features/home/presentation/widgets/home_tab_view.dart'; +import 'package:debateseason_frontend_v1/utils/amplitude_util.dart'; +import 'package:debateseason_frontend_v1/utils/logger.dart'; +import 'package:flutter/material.dart'; + +import 'home_media_page.dart'; +import 'home_recommend_page.dart'; + +class HomeScreenContent extends StatefulWidget { + const HomeScreenContent({super.key}); + + @override + State createState() => _HomeScreenContentState(); +} + +class _HomeScreenContentState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + final List tabs = ['미디어', '이슈 · 토론']; + + @override + void initState() { + super.initState(); + AmplitudeUtil.trackEvent(eventName: 'Home').then((_) { + log.d('home'); + }); + _tabController = TabController(length: tabs.length, vsync: this); + _tabController.addListener(() { + if (_tabController.indexIsChanging) return; + + final eventNames = ['홈탭미디어', '홈탭이슈·토론']; + AmplitudeUtil.trackEvent(eventName: eventNames[_tabController.index]) + .then((_) { + log.d(eventNames[_tabController.index]); + }); + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + // SliverAppBar( + // pinned: true, + // floating: false, + // titleSpacing: 0, + // title: _headlineNews(), + // backgroundColor: DeColors.grey120, + // surfaceTintColor: Colors.transparent, + // ), + SliverPersistentHeader( + pinned: false, + delegate: _TabBarDelegate(Padding( + padding: DeDimensions.horizontal20, + child: HomeTabBar( + tabController: _tabController, + tabs: tabs, + ), + )), + ), + ], + body: HomeTabView( + tabController: _tabController, + tabViews: [ + HomeMediaPage(), + HomeRecommendPage(), + ], + ), + ); + } + +/* Widget _headlineNews() { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 4), + child: Column( + children: [ + Container( + width: double.infinity, + padding: DeDimensions.padding10x8, + decoration: BoxDecoration( + color: DeColors.grey110, + borderRadius: BorderRadius.circular(10), + ), + child: DeText( + '속보나올 자리', + style: DeFonts.body14M, + ), + ), + DeGaps.v4, + ], + ), + ); + }*/ +} + +class _TabBarDelegate extends SliverPersistentHeaderDelegate { + final Widget child; + + _TabBarDelegate(this.child); + + @override + double get minExtent => kToolbarHeight; + + @override + double get maxExtent => kToolbarHeight; + + @override + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) { + return Container( + child: child, + ); + } + + @override + bool shouldRebuild(covariant _TabBarDelegate oldDelegate) => false; +} diff --git a/lib/features/home/presentation/views/live_all_page.dart b/lib/features/home/presentation/views/live_all_page.dart new file mode 100644 index 00000000..7deebb1e --- /dev/null +++ b/lib/features/home/presentation/views/live_all_page.dart @@ -0,0 +1,156 @@ +import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_icons.dart'; +import 'package:debateseason_frontend_v1/core/services/pip_controller.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/youtube_live_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/presentation/view_models/live_view_model.dart'; +import 'package:debateseason_frontend_v1/utils/logger.dart'; +import 'package:debateseason_frontend_v1/widgets/de_app_bar.dart'; +import 'package:debateseason_frontend_v1/widgets/de_gesture_detector.dart'; +import 'package:debateseason_frontend_v1/widgets/de_progress_indicator.dart'; +import 'package:debateseason_frontend_v1/widgets/de_scaffold.dart'; +import 'package:debateseason_frontend_v1/widgets/de_text.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:html_unescape/html_unescape.dart'; + +class LiveAllPage extends GetView { + const LiveAllPage({super.key}); + + PipController get pipController => Get.find(); + + @override + Widget build(BuildContext context) { + return DeScaffold( + appBar: _appBar(), + body: _body(), + ); + } + + DeAppBar _appBar() { + return DeAppBar( + title: DeText('라이브 모두 보기', style: DeFonts.header18Sb), + // actions: [ + // DeGestureDetector( + // onTap: () {}, + // child: Padding( + // padding: DeDimensions.all8, + // child: SvgPicture.asset(DeIcons.icBookmarkGrey50), + // ), + // ), + // DeGestureDetector( + // onTap: () {}, + // child: Padding( + // padding: DeDimensions.all8, + // child: SvgPicture.asset(DeIcons.icAssistantGrey50), + // ), + // ), + // ], + ); + } + + Widget _body() { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), + child: ListView( + children: [ + _liveList(), + ], + ), + ); + } + + Widget _liveList() { + return Obx( + () { + final liveList = controller.liveList; + return liveList.when( + loading: () => const Center(child: DeProgressIndicator()), + success: (liveList) { + log.d(liveList.length); + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: liveList.length, + itemBuilder: (context, index) { + return _liveItem(liveList[index]); + }, + ); + }, + failure: (error) => Center( + child: DeText( + error, + style: DeFonts.body16Sb.copyWith(color: DeColors.red), + )), + ); + }, + ); + } + + Widget _liveItem(YoutubeLiveEntity live) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DeGestureDetector( + onTap: () { + pipController.show(live.videoId); + }, + child: Container( + width: double.infinity, + height: 180, + decoration: BoxDecoration( + color: DeColors.brand, + image: DecorationImage( + image: NetworkImage( + 'https://img.youtube.com/vi/${live.videoId}/hqdefault.jpg', + ), + fit: BoxFit.cover, + ), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + DeGaps.v8, + SizedBox( + child: DeGestureDetector( + onTap: () { + pipController.show(live.videoId); + }, + child: DeText( + HtmlUnescape().convert(live.title), + style: DeFonts.body16M.copyWith(color: DeColors.grey10), + overflow: TextOverflow.ellipsis, + ), + ), + ), + Row( + children: [ + SvgPicture.asset( + DeIcons.icYoutubeLogoGrey50, + ), + DeGaps.h4, + DeText('YouTube', + style: DeFonts.caption12M.copyWith(color: DeColors.grey50)), + DeGaps.h6, + SvgPicture.asset( + DeIcons.icDotGrey50, + ), + DeGaps.h6, + DeText(live.supplier, + style: DeFonts.caption12M.copyWith(color: DeColors.grey50)), + DeGaps.h6, + SvgPicture.asset( + DeIcons.icDotGrey50, + ), + DeGaps.h6, + DeText(live.createAt.toString().substring(0, 16), + style: DeFonts.caption12M.copyWith(color: DeColors.grey50)), + ], + ), + DeGaps.v28, + ], + ); + } +} diff --git a/lib/features/home/presentation/widgets/debate_card.dart b/lib/features/home/presentation/widgets/debate_card.dart new file mode 100644 index 00000000..0f94583b --- /dev/null +++ b/lib/features/home/presentation/widgets/debate_card.dart @@ -0,0 +1,63 @@ +import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_dimensions.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/best_chat_room_entity.dart'; +import 'package:debateseason_frontend_v1/widgets/de_text.dart'; +import 'package:flutter/cupertino.dart'; + +class DebateCard extends StatelessWidget { + final BestChatRoomEntity bestChatRoom; + + const DebateCard({super.key, required this.bestChatRoom}); + + @override + Widget build(BuildContext context) { + return Container( + width: 320, + padding: DeDimensions.all16, + decoration: BoxDecoration( + color: DeColors.grey110, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: DeColors.grey90, + width: 1, + )), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: DeDimensions.padding6x2, + decoration: BoxDecoration( + color: DeColors.tag, borderRadius: BorderRadius.circular(6)), + child: DeText( + bestChatRoom.issueTitle, + style: DeFonts.caption12M.copyWith(color: DeColors.grey10), + ), + ), + DeGaps.v8, + SizedBox( + height: 48, // fontsize * lineHeight * 2 + child: DeText( + bestChatRoom.debateTitle, + style: DeFonts.body16M.copyWith(color: DeColors.grey10), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + //Row( + // children: [ + // DeText('관심등록 ', + // style: DeFonts.caption12M.copyWith(color: DeColors.grey50)), + // DeText('198만', + // style: DeFonts.caption12M.copyWith(color: DeColors.grey30)) + // ], + //), + //DeGaps.v8, + DeText(bestChatRoom.time, + style: DeFonts.caption12M.copyWith(color: DeColors.brand)), + ], + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/home_tab_bar.dart b/lib/features/home/presentation/widgets/home_tab_bar.dart new file mode 100644 index 00000000..ddf76623 --- /dev/null +++ b/lib/features/home/presentation/widgets/home_tab_bar.dart @@ -0,0 +1,88 @@ +import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; +import 'package:flutter/material.dart'; + +class HomeTabBar extends StatelessWidget { + final TabController tabController; + final List tabs; + + const HomeTabBar({ + required this.tabController, + required this.tabs, + super.key, + }); + + @override + Widget build(BuildContext context) { + // return TabBar( + // controller: tabController, + // tabs: tabs, + // isScrollable: true, + // indicator: BoxDecoration( + // borderRadius: BorderRadius.circular(0), + // border: Border( + // bottom: BorderSide(color: DeColors.grey10, width: 2), + // ), + // ), + // dividerColor: DeColors.grey100, + // dividerHeight: 1, + // labelColor: DeColors.grey10, + // labelStyle: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + // labelPadding: const EdgeInsets.only(right: 16), + // unselectedLabelColor: DeColors.grey50, + // unselectedLabelStyle: + // TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + // enableFeedback: false, + // splashFactory: NoSplash.splashFactory, + // tabAlignment: TabAlignment.start, + // ); + return AnimatedBuilder( + animation: tabController, + builder: (context, _) { + return TabBar( + controller: tabController, + isScrollable: false, + labelPadding: EdgeInsets.zero, + indicatorSize: TabBarIndicatorSize.tab, + indicator: const BoxDecoration(), + dividerColor: DeColors.grey110, + dividerHeight: 1, + labelColor: DeColors.grey110, + labelStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + unselectedLabelColor: DeColors.grey50, + unselectedLabelStyle: + TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + enableFeedback: false, + splashFactory: NoSplash.splashFactory, + tabAlignment: TabAlignment.fill, + tabs: tabs.asMap().entries.map((entry) { + final i = entry.key; + final txt = entry.value; + final selected = tabController.index == i; + + return Container( + height: 40, + decoration: BoxDecoration( + color: selected + ? DeColors.grey10 + : DeColors.grey110, + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.center, + child: Text( + txt, + style: TextStyle( + fontSize: 14, + fontWeight: + selected ? FontWeight.w600 : FontWeight.w500, + color: selected + ? DeColors.grey110 + : DeColors.grey50, + ), + ), + ); + }).toList(), + ); + } + ); + } +} diff --git a/lib/features/home/presentation/widgets/home_tab_view.dart b/lib/features/home/presentation/widgets/home_tab_view.dart new file mode 100644 index 00000000..1dd48e4d --- /dev/null +++ b/lib/features/home/presentation/widgets/home_tab_view.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class HomeTabView extends StatelessWidget { + final TabController tabController; + final List tabViews; + + const HomeTabView({ + super.key, + required this.tabController, + required this.tabViews, + }); + + @override + Widget build(BuildContext context) { + return TabBarView( + controller: tabController, + children: tabViews, + ); + } +} diff --git a/lib/features/home/presentation/widgets/issue_card.dart b/lib/features/home/presentation/widgets/issue_card.dart new file mode 100644 index 00000000..0860921d --- /dev/null +++ b/lib/features/home/presentation/widgets/issue_card.dart @@ -0,0 +1,123 @@ +import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/chat_room_response_entity.dart'; +import 'package:debateseason_frontend_v1/features/home/presentation/view_models/ratio_view_model.dart'; +import 'package:debateseason_frontend_v1/features/issue/issue_constants.dart'; +import 'package:debateseason_frontend_v1/widgets/de_text.dart'; +import 'package:flutter/material.dart'; + +class IssueCard extends StatelessWidget { + final ChatRoomResponseEntity chatroom; + + const IssueCard({ + super.key, + required this.chatroom, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 20), + decoration: BoxDecoration( + color: DeColors.grey110, + border: Border.all(color: DeColors.grey90), + borderRadius: BorderRadius.circular(16.0), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _issueDate(), + _issueTitle(), + DeGaps.v12, + //_issueRecent(), + //Gaps.v12, + _issueStatus(), + ], + ), + ); + } + + Widget _issueDate() { + return DeText( + chatroom.createdAt.toString().substring(0, 10), + style: DeFonts.caption12M.copyWith(color: DeColors.grey50), + ); + } + + Widget _issueTitle() { + return DeText( + chatroom.title, + style: DeFonts.body16Sb.copyWith( + color: Color(0xFFF3F0F4), + ), + textAlign: TextAlign.center, + ); + } + + /*Widget _issueRecent() { + return DeText( + chatroom.time, //'3분 전 대화', todo: 대화 시간 표시 + style: DeFonts.caption12M.copyWith(color: DeColors.brand), + ); + }*/ + + Widget _issueStatus() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _widgetBtn('찬성'), + DeGaps.h8, + DeText( + IssueConstants.vs, + style: DeFonts.caption12M.copyWith(color: DeColors.grey70), + ), + DeGaps.h8, + _widgetBtn('반대'), + ], + ); + } + + Widget _widgetBtn(String data) { + final widgetColor = data == '찬성' ? DeColors.red : DeColors.blue; + final agree = chatroom.agree; + final disagree = chatroom.disagree; + + final percentages = getPercentages(agree, disagree); + final agreeRatioText = percentages[0]; + final disagreeRatioText = percentages[1]; + + return Container( + width: 80, + height: 80, + padding: EdgeInsets.symmetric( + horizontal: 9, + vertical: 7, + ), + decoration: ShapeDecoration( + color: widgetColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + DeText( + data, + style: DeFonts.caption12M2, + ), + DeText( + data == '찬성' ? '$agreeRatioText%' : '$disagreeRatioText%', + style: DeFonts.header20B, + ), + DeText( + data == '찬성' ? '$agree표' : '$disagree표', + style: DeFonts.caption12M2, + ), + ], + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/issue_card_new.dart b/lib/features/home/presentation/widgets/issue_card_new.dart new file mode 100644 index 00000000..3bab4e2a --- /dev/null +++ b/lib/features/home/presentation/widgets/issue_card_new.dart @@ -0,0 +1,77 @@ +import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_dimensions.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_icons.dart'; +import 'package:debateseason_frontend_v1/features/home/domain/entities/best_issue_room_entity.dart'; +import 'package:debateseason_frontend_v1/widgets/de_text.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_svg/svg.dart'; + +class IssueCardNew extends StatelessWidget { + final BestIssueRoomEntity bestIssueRoom; + + const IssueCardNew({super.key, required this.bestIssueRoom}); + + @override + Widget build(BuildContext context) { + return Container( + width: 200, + padding: DeDimensions.all16, + decoration: BoxDecoration( + color: DeColors.grey110, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: DeColors.grey90, + width: 1, + )), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DeText( + bestIssueRoom.title, + style: DeFonts.body16Sb.copyWith(color: DeColors.grey10), + overflow: TextOverflow.ellipsis, + ), + DeGaps.v8, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + //Row( + // children: [ + // DeText('관심등록 ', + // style: DeFonts.caption12M + // .copyWith(color: DeColors.grey50)), + // DeText('27만', + // style: DeFonts.caption12M + // .copyWith(color: DeColors.grey30)) + // ], + //), + Row( + children: [ + DeText('토론주제 ', + style: DeFonts.caption12M + .copyWith(color: DeColors.grey50)), + DeText(bestIssueRoom.countChatRoom.toString(), + style: DeFonts.caption12M + .copyWith(color: DeColors.grey30)) + ], + ), + ], + ), + SvgPicture.asset( + DeIcons.icArrowRightGrey50, + width: 20, + height: 20, + ) + ], + ), + ], + ), + ); + + } +} diff --git a/lib/features/issue/presentation/view_models/issue_room_view_model.dart b/lib/features/issue/presentation/view_models/issue_room_view_model.dart index 9708d31e..b132875f 100644 --- a/lib/features/issue/presentation/view_models/issue_room_view_model.dart +++ b/lib/features/issue/presentation/view_models/issue_room_view_model.dart @@ -1,5 +1,6 @@ import 'package:debateseason_frontend_v1/features/issue/domain/entities/issue_entity.dart'; import 'package:debateseason_frontend_v1/features/issue/domain/repositories/issue_repository.dart'; +import 'package:debateseason_frontend_v1/utils/amplitude_util.dart'; import 'package:debateseason_frontend_v1/utils/base/ui_state.dart'; import 'package:debateseason_frontend_v1/utils/logger.dart'; import 'package:get/get.dart'; @@ -17,6 +18,7 @@ class IssueRoomViewModel extends GetxController { @override void onInit() { super.onInit(); + AmplitudeUtil.trackEvent(eventName: 'IssueRoom'); _issueRepository = Get.find(); final arguments = Get.arguments as Map; diff --git a/lib/features/issuemap/presentation/view_models/issuemap_view_model.dart b/lib/features/issuemap/presentation/view_models/issuemap_view_model.dart index e06307e9..68836c66 100644 --- a/lib/features/issuemap/presentation/view_models/issuemap_view_model.dart +++ b/lib/features/issuemap/presentation/view_models/issuemap_view_model.dart @@ -1,5 +1,6 @@ import 'package:debateseason_frontend_v1/features/issuemap/domain/entities/category_entity.dart'; import 'package:debateseason_frontend_v1/features/issuemap/domain/repositories/users_home_repository.dart'; +import 'package:debateseason_frontend_v1/utils/amplitude_util.dart'; import 'package:debateseason_frontend_v1/utils/base/ui_state.dart'; import 'package:get/get.dart'; @@ -14,6 +15,7 @@ class IssuemapViewModel extends GetxController { void onInit() async { super.onInit(); + AmplitudeUtil.trackEvent(eventName: 'Issuemap'); _usersHomeRepository = Get.find(); await getCategories(); } diff --git a/lib/features/issuemap/presentation/views/issuemap_screen.dart b/lib/features/issuemap/presentation/views/issuemap_screen.dart index ec079a5d..17397e83 100644 --- a/lib/features/issuemap/presentation/views/issuemap_screen.dart +++ b/lib/features/issuemap/presentation/views/issuemap_screen.dart @@ -39,18 +39,6 @@ class IssuemapScreen extends GetView { width: 84, height: 24, ), - actions: [ - DeGestureDetector( - onTap: () { - Get.toNamed(GetRouterName.profile); - }, - child: Padding( - padding: DeDimensions.all8, - child: SvgPicture.asset(DeIcons.icProfileGrey10), - ), - ), - DeGaps.h20, - ], ); } diff --git a/lib/features/main/main_binding.dart b/lib/features/main/main_binding.dart index d2c2d124..37208d21 100644 --- a/lib/features/main/main_binding.dart +++ b/lib/features/main/main_binding.dart @@ -1,4 +1,5 @@ -import 'package:debateseason_frontend_v1/features/home/home_binding.dart'; +import 'package:debateseason_frontend_v1/features/home/bindings/media_binding.dart'; +import 'package:debateseason_frontend_v1/features/home/bindings/recommend_binding.dart'; import 'package:debateseason_frontend_v1/features/issuemap/bindings/issuemap_binding.dart'; import 'package:debateseason_frontend_v1/features/profile/bindings/profile_binding.dart'; import 'package:get/get.dart'; @@ -9,7 +10,8 @@ class MainBinding extends Bindings { @override void dependencies() { Get.lazyPut(() => MainViewModel()); - HomeBinding().dependencies(); + MediaBinding().dependencies(); + RecommendBinding().dependencies(); IssuemapBinding().dependencies(); ProfileBinding().dependencies(); } diff --git a/lib/features/main/main_screen.dart b/lib/features/main/main_screen.dart index 9971a90a..168787fb 100644 --- a/lib/features/main/main_screen.dart +++ b/lib/features/main/main_screen.dart @@ -1,7 +1,7 @@ import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; -import 'package:debateseason_frontend_v1/features/home/home_screen.dart'; +import 'package:debateseason_frontend_v1/features/home/presentation/views/home_screen.dart'; import 'package:debateseason_frontend_v1/features/issuemap/presentation/views/issuemap_screen.dart'; import 'package:debateseason_frontend_v1/features/profile/presentation/views/profile_screen.dart'; import 'package:debateseason_frontend_v1/widgets/de_scaffold.dart'; diff --git a/lib/features/main/main_view_model.dart b/lib/features/main/main_view_model.dart index 2e6a249f..28b1882a 100644 --- a/lib/features/main/main_view_model.dart +++ b/lib/features/main/main_view_model.dart @@ -1,3 +1,4 @@ +import 'package:debateseason_frontend_v1/utils/amplitude_util.dart'; import 'package:get/get.dart'; class MainViewModel extends GetxController { @@ -7,5 +8,8 @@ class MainViewModel extends GetxController { void changeTabIndex(int index) { _selectedIndex.value = index; + + final eventNames = ['GNB홈', 'GNB이슈맵', 'GNB프로필']; + AmplitudeUtil.trackEvent(eventName: eventNames[index]); } } diff --git a/lib/features/main/pip_overlay.dart b/lib/features/main/pip_overlay.dart new file mode 100644 index 00000000..ad47a06b --- /dev/null +++ b/lib/features/main/pip_overlay.dart @@ -0,0 +1,104 @@ +// import 'package:flutter/material.dart'; +// +// class PIPOverlay extends StatefulWidget { +// const PIPOverlay({super.key}); +// +// @override +// State createState() => _PIPOverlayState(); +// } +// +// class _PIPOverlayState extends State { +// Offset _position = const Offset(20, 100); // 초기 위치 +// +// @override +// Widget build(BuildContext context) { +// return Positioned( +// left: _position.dx, +// top: _position.dy, +// child: GestureDetector( +// onPanUpdate: (details) { +// setState(() { +// _position += details.delta; +// }); +// }, +// child: Container( +// width: 160, +// height: 90, +// decoration: BoxDecoration( +// color: Colors.black87, +// borderRadius: BorderRadius.circular(12), +// border: Border.all(color: Colors.white), +// ), +// child: const Center( +// child: Text( +// '📺 PIP View', +// style: TextStyle(color: Colors.white), +// ), +// ), +// ), +// ), +// ); +// } +// } + +import 'package:flutter/material.dart'; +import 'package:youtube_player_flutter/youtube_player_flutter.dart'; + +class PIPOverlay extends StatefulWidget { + const PIPOverlay({super.key}); + + @override + State createState() => _PIPOverlayState(); +} + +class _PIPOverlayState extends State { + late YoutubePlayerController _controller; + Offset _position = const Offset(20, 100); + + @override + void initState() { + super.initState(); + final videoUrl = 'https://www.youtube.com/embed/vz6kIu7k7lI'; // 예시 + final videoId = YoutubePlayer.convertUrlToId(videoUrl)!; + + _controller = YoutubePlayerController( + initialVideoId: videoId, + flags: const YoutubePlayerFlags( + autoPlay: false, + mute: false, + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Positioned( + left: _position.dx, + top: _position.dy, + child: GestureDetector( + onPanUpdate: (details) { + setState(() { + _position += details.delta; + }); + }, + child: SizedBox( + width: 210, + height: 140, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: YoutubePlayer( + controller: _controller, + showVideoProgressIndicator: true, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/profile/bindings/profile_image_binding.dart b/lib/features/profile/bindings/profile_image_binding.dart new file mode 100644 index 00000000..ee41f53e --- /dev/null +++ b/lib/features/profile/bindings/profile_image_binding.dart @@ -0,0 +1,9 @@ +import 'package:debateseason_frontend_v1/features/profile/presentation/view_models/profile_image_view_model.dart'; +import 'package:get/get.dart'; + +class ProfileImageBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => ProfileImageViewModel()); + } +} diff --git a/lib/features/profile/domain/type/district_type.dart b/lib/features/profile/domain/type/district_type.dart new file mode 100644 index 00000000..c6e9c2e8 --- /dev/null +++ b/lib/features/profile/domain/type/district_type.dart @@ -0,0 +1,333 @@ +import 'province_type.dart'; + +class DistrictType { + final String code; + final String name; + final ProvinceType provinceType; + + const DistrictType._(this.code, this.name, this.provinceType); + + static const List seoul = [ + DistrictType._("11010", "종로구", ProvinceType.seoul), + DistrictType._("11020", "중구", ProvinceType.seoul), + DistrictType._("11030", "용산구", ProvinceType.seoul), + DistrictType._("11040", "성동구", ProvinceType.seoul), + DistrictType._("11050", "광진구", ProvinceType.seoul), + DistrictType._("11060", "동대문구", ProvinceType.seoul), + DistrictType._("11070", "중랑구", ProvinceType.seoul), + DistrictType._("11080", "성북구", ProvinceType.seoul), + DistrictType._("11090", "강북구", ProvinceType.seoul), + DistrictType._("11100", "도봉구", ProvinceType.seoul), + DistrictType._("11110", "노원구", ProvinceType.seoul), + DistrictType._("11120", "은평구", ProvinceType.seoul), + DistrictType._("11130", "서대문구", ProvinceType.seoul), + DistrictType._("11140", "마포구", ProvinceType.seoul), + DistrictType._("11150", "양천구", ProvinceType.seoul), + DistrictType._("11160", "강서구", ProvinceType.seoul), + DistrictType._("11170", "구로구", ProvinceType.seoul), + DistrictType._("11180", "금천구", ProvinceType.seoul), + DistrictType._("11190", "영등포구", ProvinceType.seoul), + DistrictType._("11200", "동작구", ProvinceType.seoul), + DistrictType._("11210", "관악구", ProvinceType.seoul), + DistrictType._("11220", "서초구", ProvinceType.seoul), + DistrictType._("11230", "강남구", ProvinceType.seoul), + DistrictType._("11240", "송파구", ProvinceType.seoul), + DistrictType._("11250", "강동구", ProvinceType.seoul), + ]; + + static const List busan = [ + DistrictType._("21010", "중구", ProvinceType.busan), + DistrictType._("21020", "서구", ProvinceType.busan), + DistrictType._("21030", "동구", ProvinceType.busan), + DistrictType._("21040", "영도구", ProvinceType.busan), + DistrictType._("21050", "부산진구", ProvinceType.busan), + DistrictType._("21060", "동래구", ProvinceType.busan), + DistrictType._("21070", "남구", ProvinceType.busan), + DistrictType._("21080", "북구", ProvinceType.busan), + DistrictType._("21090", "해운대구", ProvinceType.busan), + DistrictType._("21100", "사하구", ProvinceType.busan), + DistrictType._("21110", "금정구", ProvinceType.busan), + DistrictType._("21120", "강서구", ProvinceType.busan), + DistrictType._("21130", "연제구", ProvinceType.busan), + DistrictType._("21140", "수영구", ProvinceType.busan), + DistrictType._("21150", "사상구", ProvinceType.busan), + DistrictType._("21310", "기장군", ProvinceType.busan), + ]; + + static const List daegu = [ + DistrictType._("22010", "중구", ProvinceType.daegu), + DistrictType._("22020", "동구", ProvinceType.daegu), + DistrictType._("22030", "서구", ProvinceType.daegu), + DistrictType._("22040", "남구", ProvinceType.daegu), + DistrictType._("22050", "북구", ProvinceType.daegu), + DistrictType._("22060", "수성구", ProvinceType.daegu), + DistrictType._("22070", "달서구", ProvinceType.daegu), + DistrictType._("22310", "달성군", ProvinceType.daegu), + DistrictType._("22330", "군위군", ProvinceType.daegu), + ]; + + static const List incheon = [ + DistrictType._("23010", "중구", ProvinceType.incheon), + DistrictType._("23020", "동구", ProvinceType.incheon), + DistrictType._("23040", "미추홀구", ProvinceType.incheon), + DistrictType._("23050", "연수구", ProvinceType.incheon), + DistrictType._("23060", "남동구", ProvinceType.incheon), + DistrictType._("23070", "부평구", ProvinceType.incheon), + DistrictType._("23080", "계양구", ProvinceType.incheon), + DistrictType._("23090", "서구", ProvinceType.incheon), + DistrictType._("23310", "강화군", ProvinceType.incheon), + DistrictType._("23320", "옹진군", ProvinceType.incheon), + ]; + + static const List gwangju = [ + DistrictType._("24010", "동구", ProvinceType.gwangju), + DistrictType._("24020", "서구", ProvinceType.gwangju), + DistrictType._("24030", "남구", ProvinceType.gwangju), + DistrictType._("24040", "북구", ProvinceType.gwangju), + DistrictType._("24050", "광산구", ProvinceType.gwangju), + ]; + + static const List daejeon = [ + DistrictType._("25010", "동구", ProvinceType.daejeon), + DistrictType._("25020", "중구", ProvinceType.daejeon), + DistrictType._("25030", "서구", ProvinceType.daejeon), + DistrictType._("25040", "유성구", ProvinceType.daejeon), + DistrictType._("25050", "대덕구", ProvinceType.daejeon), + ]; + + static const List ulsan = [ + DistrictType._("26010", "중구", ProvinceType.ulsan), + DistrictType._("26020", "남구", ProvinceType.ulsan), + DistrictType._("26030", "동구", ProvinceType.ulsan), + DistrictType._("26040", "북구", ProvinceType.ulsan), + DistrictType._("26310", "울주군", ProvinceType.ulsan), + ]; + + static const List sejong = [ + DistrictType._("29011", "조치원읍", ProvinceType.sejong), + DistrictType._("29021", "연기면", ProvinceType.sejong), + DistrictType._("29022", "연동면", ProvinceType.sejong), + DistrictType._("29023", "부강면", ProvinceType.sejong), + DistrictType._("29024", "금남면", ProvinceType.sejong), + DistrictType._("29025", "장군면", ProvinceType.sejong), + DistrictType._("29026", "연서면", ProvinceType.sejong), + DistrictType._("29027", "전의면", ProvinceType.sejong), + DistrictType._("29028", "전동면", ProvinceType.sejong), + DistrictType._("29029", "소정면", ProvinceType.sejong), + DistrictType._("29031", "한솔동", ProvinceType.sejong), + DistrictType._("29032", "새롬동", ProvinceType.sejong), + DistrictType._("29033", "나성동", ProvinceType.sejong), + DistrictType._("29034", "다정동", ProvinceType.sejong), + DistrictType._("29035", "도담동", ProvinceType.sejong), + DistrictType._("29036", "어진동", ProvinceType.sejong), + DistrictType._("29037", "해밀동", ProvinceType.sejong), + DistrictType._("29038", "아름동", ProvinceType.sejong), + DistrictType._("29039", "종촌동", ProvinceType.sejong), + DistrictType._("29040", "고운동", ProvinceType.sejong), + DistrictType._("29041", "보람동", ProvinceType.sejong), + DistrictType._("29042", "대평동", ProvinceType.sejong), + DistrictType._("29043", "소담동", ProvinceType.sejong), + DistrictType._("29044", "반곡동", ProvinceType.sejong), + ]; + + static const List gyeonggi = [ + DistrictType._("31010", "수원시 장안구", ProvinceType.gyeonggi), + DistrictType._("31020", "수원시 권선구", ProvinceType.gyeonggi), + DistrictType._("31030", "수원시 팔달구", ProvinceType.gyeonggi), + DistrictType._("31040", "수원시 영통구", ProvinceType.gyeonggi), + DistrictType._("31050", "성남시 수정구", ProvinceType.gyeonggi), + DistrictType._("31060", "성남시 중원구", ProvinceType.gyeonggi), + DistrictType._("31070", "성남시 분당구", ProvinceType.gyeonggi), + DistrictType._("31080", "의정부시", ProvinceType.gyeonggi), + DistrictType._("31090", "안양시 만안구", ProvinceType.gyeonggi), + DistrictType._("31100", "안양시 동안구", ProvinceType.gyeonggi), + DistrictType._("31110", "부천시", ProvinceType.gyeonggi), + DistrictType._("31120", "광명시", ProvinceType.gyeonggi), + DistrictType._("31130", "평택시", ProvinceType.gyeonggi), + DistrictType._("31140", "동두천시", ProvinceType.gyeonggi), + DistrictType._("31150", "안산시 상록구", ProvinceType.gyeonggi), + DistrictType._("31160", "안산시 단원구", ProvinceType.gyeonggi), + DistrictType._("31170", "고양시 덕양구", ProvinceType.gyeonggi), + DistrictType._("31180", "고양시 일산동구", ProvinceType.gyeonggi), + DistrictType._("31190", "고양시 일산서구", ProvinceType.gyeonggi), + DistrictType._("31200", "과천시", ProvinceType.gyeonggi), + DistrictType._("31210", "구리시", ProvinceType.gyeonggi), + DistrictType._("31220", "남양주시", ProvinceType.gyeonggi), + DistrictType._("31230", "오산시", ProvinceType.gyeonggi), + DistrictType._("31240", "시흥시", ProvinceType.gyeonggi), + DistrictType._("31250", "군포시", ProvinceType.gyeonggi), + DistrictType._("31260", "의왕시", ProvinceType.gyeonggi), + DistrictType._("31270", "하남시", ProvinceType.gyeonggi), + DistrictType._("31280", "용인시 처인구", ProvinceType.gyeonggi), + DistrictType._("31290", "용인시 기흥구", ProvinceType.gyeonggi), + DistrictType._("31300", "용인시 수지구", ProvinceType.gyeonggi), + DistrictType._("31310", "파주시", ProvinceType.gyeonggi), + DistrictType._("31320", "이천시", ProvinceType.gyeonggi), + DistrictType._("31330", "안성시", ProvinceType.gyeonggi), + DistrictType._("31340", "김포시", ProvinceType.gyeonggi), + DistrictType._("31350", "화성시", ProvinceType.gyeonggi), + DistrictType._("31360", "광주시", ProvinceType.gyeonggi), + DistrictType._("31370", "양주시", ProvinceType.gyeonggi), + DistrictType._("31380", "포천시", ProvinceType.gyeonggi), + DistrictType._("31390", "여주시", ProvinceType.gyeonggi), + DistrictType._("31410", "연천군", ProvinceType.gyeonggi), + DistrictType._("31420", "가평군", ProvinceType.gyeonggi), + DistrictType._("31430", "양평군", ProvinceType.gyeonggi), + ]; + + static const List gangwon = [ + DistrictType._("32010", "춘천시", ProvinceType.gangwon), + DistrictType._("32020", "원주시", ProvinceType.gangwon), + DistrictType._("32030", "강릉시", ProvinceType.gangwon), + DistrictType._("32040", "동해시", ProvinceType.gangwon), + DistrictType._("32050", "태백시", ProvinceType.gangwon), + DistrictType._("32060", "속초시", ProvinceType.gangwon), + DistrictType._("32070", "삼척시", ProvinceType.gangwon), + DistrictType._("32310", "홍천군", ProvinceType.gangwon), + DistrictType._("32320", "횡성군", ProvinceType.gangwon), + DistrictType._("32330", "영월군", ProvinceType.gangwon), + DistrictType._("32340", "평창군", ProvinceType.gangwon), + DistrictType._("32350", "정선군", ProvinceType.gangwon), + DistrictType._("32360", "철원군", ProvinceType.gangwon), + DistrictType._("32370", "화천군", ProvinceType.gangwon), + DistrictType._("32380", "양구군", ProvinceType.gangwon), + DistrictType._("32390", "인제군", ProvinceType.gangwon), + DistrictType._("32400", "고성군", ProvinceType.gangwon), + DistrictType._("32410", "양양군", ProvinceType.gangwon), + ]; + + static const List chungbuk = [ + DistrictType._("33010", "청주시 상당구", ProvinceType.chungbuk), + DistrictType._("33020", "청주시 서원구", ProvinceType.chungbuk), + DistrictType._("33030", "청주시 흥덕구", ProvinceType.chungbuk), + DistrictType._("33040", "청주시 청원구", ProvinceType.chungbuk), + DistrictType._("33050", "충주시", ProvinceType.chungbuk), + DistrictType._("33060", "제천시", ProvinceType.chungbuk), + DistrictType._("33310", "보은군", ProvinceType.chungbuk), + DistrictType._("33320", "옥천군", ProvinceType.chungbuk), + DistrictType._("33330", "영동군", ProvinceType.chungbuk), + DistrictType._("33340", "진천군", ProvinceType.chungbuk), + DistrictType._("33350", "괴산군", ProvinceType.chungbuk), + DistrictType._("33360", "음성군", ProvinceType.chungbuk), + DistrictType._("33370", "단양군", ProvinceType.chungbuk), + DistrictType._("33380", "증평군", ProvinceType.chungbuk), + ]; + + static const List chungnam = [ + DistrictType._("34010", "천안시 동남구", ProvinceType.chungnam), + DistrictType._("34020", "천안시 서북구", ProvinceType.chungnam), + DistrictType._("34030", "공주시", ProvinceType.chungnam), + DistrictType._("34040", "보령시", ProvinceType.chungnam), + DistrictType._("34050", "아산시", ProvinceType.chungnam), + DistrictType._("34060", "서산시", ProvinceType.chungnam), + DistrictType._("34070", "논산시", ProvinceType.chungnam), + DistrictType._("34080", "계룡시", ProvinceType.chungnam), + DistrictType._("34090", "당진시", ProvinceType.chungnam), + DistrictType._("34310", "금산군", ProvinceType.chungnam), + DistrictType._("34330", "부여군", ProvinceType.chungnam), + DistrictType._("34340", "서천군", ProvinceType.chungnam), + DistrictType._("34350", "청양군", ProvinceType.chungnam), + DistrictType._("34360", "홍성군", ProvinceType.chungnam), + DistrictType._("34370", "예산군", ProvinceType.chungnam), + DistrictType._("34380", "태안군", ProvinceType.chungnam), + ]; + + static const List jeonbuk = [ + DistrictType._("35010", "전주시 완산구", ProvinceType.jeonbuk), + DistrictType._("35020", "전주시 덕진구", ProvinceType.jeonbuk), + DistrictType._("35030", "군산시", ProvinceType.jeonbuk), + DistrictType._("35040", "익산시", ProvinceType.jeonbuk), + DistrictType._("35050", "정읍시", ProvinceType.jeonbuk), + DistrictType._("35060", "남원시", ProvinceType.jeonbuk), + DistrictType._("35070", "김제시", ProvinceType.jeonbuk), + DistrictType._("35310", "완주군", ProvinceType.jeonbuk), + DistrictType._("35320", "진안군", ProvinceType.jeonbuk), + DistrictType._("35330", "무주군", ProvinceType.jeonbuk), + DistrictType._("35340", "장수군", ProvinceType.jeonbuk), + DistrictType._("35350", "임실군", ProvinceType.jeonbuk), + DistrictType._("35360", "순창군", ProvinceType.jeonbuk), + DistrictType._("35370", "고창군", ProvinceType.jeonbuk), + DistrictType._("35380", "부안군", ProvinceType.jeonbuk), + ]; + + static const List jeonnam = [ + DistrictType._("36010", "목포시", ProvinceType.jeonnam), + DistrictType._("36020", "여수시", ProvinceType.jeonnam), + DistrictType._("36030", "순천시", ProvinceType.jeonnam), + DistrictType._("36040", "나주시", ProvinceType.jeonnam), + DistrictType._("36060", "광양시", ProvinceType.jeonnam), + DistrictType._("36310", "담양군", ProvinceType.jeonnam), + DistrictType._("36320", "곡성군", ProvinceType.jeonnam), + DistrictType._("36330", "구례군", ProvinceType.jeonnam), + DistrictType._("36350", "고흥군", ProvinceType.jeonnam), + DistrictType._("36360", "보성군", ProvinceType.jeonnam), + DistrictType._("36370", "화순군", ProvinceType.jeonnam), + DistrictType._("36380", "장흥군", ProvinceType.jeonnam), + DistrictType._("36390", "강진군", ProvinceType.jeonnam), + DistrictType._("36400", "해남군", ProvinceType.jeonnam), + DistrictType._("36410", "영암군", ProvinceType.jeonnam), + DistrictType._("36420", "무안군", ProvinceType.jeonnam), + DistrictType._("36430", "함평군", ProvinceType.jeonnam), + DistrictType._("36440", "영광군", ProvinceType.jeonnam), + DistrictType._("36450", "장성군", ProvinceType.jeonnam), + DistrictType._("36460", "완도군", ProvinceType.jeonnam), + DistrictType._("36470", "진도군", ProvinceType.jeonnam), + DistrictType._("36480", "신안군", ProvinceType.jeonnam), + ]; + + static const List gyeongbuk = [ + DistrictType._("37010", "포항시 남구", ProvinceType.gyeongbuk), + DistrictType._("37020", "포항시 북구", ProvinceType.gyeongbuk), + DistrictType._("37030", "경주시", ProvinceType.gyeongbuk), + DistrictType._("37040", "김천시", ProvinceType.gyeongbuk), + DistrictType._("37050", "안동시", ProvinceType.gyeongbuk), + DistrictType._("37060", "구미시", ProvinceType.gyeongbuk), + DistrictType._("37070", "영주시", ProvinceType.gyeongbuk), + DistrictType._("37080", "영천시", ProvinceType.gyeongbuk), + DistrictType._("37090", "상주시", ProvinceType.gyeongbuk), + DistrictType._("37100", "문경시", ProvinceType.gyeongbuk), + DistrictType._("37110", "경산시", ProvinceType.gyeongbuk), + DistrictType._("37310", "의성군", ProvinceType.gyeongbuk), + DistrictType._("37320", "청송군", ProvinceType.gyeongbuk), + DistrictType._("37330", "영양군", ProvinceType.gyeongbuk), + DistrictType._("37340", "영덕군", ProvinceType.gyeongbuk), + DistrictType._("37350", "청도군", ProvinceType.gyeongbuk), + DistrictType._("37360", "고령군", ProvinceType.gyeongbuk), + DistrictType._("37370", "성주군", ProvinceType.gyeongbuk), + DistrictType._("37380", "칠곡군", ProvinceType.gyeongbuk), + DistrictType._("37390", "예천군", ProvinceType.gyeongbuk), + DistrictType._("37400", "봉화군", ProvinceType.gyeongbuk), + DistrictType._("37410", "울진군", ProvinceType.gyeongbuk), + DistrictType._("37420", "울릉군", ProvinceType.gyeongbuk), + ]; + + static const List gyeongnam = [ + DistrictType._("38010", "창원시 의창구", ProvinceType.gyeongnam), + DistrictType._("38020", "창원시 성산구", ProvinceType.gyeongnam), + DistrictType._("38030", "창원시 마산합포구", ProvinceType.gyeongnam), + DistrictType._("38040", "창원시 마산회원구", ProvinceType.gyeongnam), + DistrictType._("38050", "창원시 진해구", ProvinceType.gyeongnam), + DistrictType._("38060", "진주시", ProvinceType.gyeongnam), + DistrictType._("38070", "통영시", ProvinceType.gyeongnam), + DistrictType._("38080", "사천시", ProvinceType.gyeongnam), + DistrictType._("38090", "김해시", ProvinceType.gyeongnam), + DistrictType._("38100", "밀양시", ProvinceType.gyeongnam), + DistrictType._("38110", "거제시", ProvinceType.gyeongnam), + DistrictType._("38120", "양산시", ProvinceType.gyeongnam), + DistrictType._("38310", "의령군", ProvinceType.gyeongnam), + DistrictType._("38320", "함안군", ProvinceType.gyeongnam), + DistrictType._("38330", "창녕군", ProvinceType.gyeongnam), + DistrictType._("38340", "고성군", ProvinceType.gyeongnam), + DistrictType._("38350", "남해군", ProvinceType.gyeongnam), + DistrictType._("38360", "하동군", ProvinceType.gyeongnam), + DistrictType._("38370", "산청군", ProvinceType.gyeongnam), + DistrictType._("38380", "함양군", ProvinceType.gyeongnam), + DistrictType._("38390", "거창군", ProvinceType.gyeongnam), + DistrictType._("38400", "합천군", ProvinceType.gyeongnam), + ]; + + static const List jeju = [ + DistrictType._("39010", "제주시", ProvinceType.jeju), + DistrictType._("39020", "서귀포시", ProvinceType.jeju), + ]; +} diff --git a/lib/features/profile/domain/type/image_type.dart b/lib/features/profile/domain/type/image_type.dart new file mode 100644 index 00000000..5b55c032 --- /dev/null +++ b/lib/features/profile/domain/type/image_type.dart @@ -0,0 +1,18 @@ +import 'dart:ui'; + +import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; + +enum ImageType { + red('빨강', DeColors.imageRed), + orange('주황', DeColors.imageOrange), + yellow('노랑', DeColors.imageYellow), + green('초록', DeColors.imageGreen), + blue('파랑', DeColors.imageBlue), + navy('네이비', DeColors.imageNavy), + purple('보라', DeColors.imagePurple); + + final String name; + final Color color; + + const ImageType(this.name, this.color); +} diff --git a/lib/features/profile/domain/type/province_type.dart b/lib/features/profile/domain/type/province_type.dart new file mode 100644 index 00000000..0e21ec4e --- /dev/null +++ b/lib/features/profile/domain/type/province_type.dart @@ -0,0 +1,24 @@ +enum ProvinceType { + seoul('11', '서울특별시'), + busan('21', '부산광역시'), + daegu('22', '대구광역시'), + incheon('23', '인천광역시'), + gwangju('24', '광주광역시'), + daejeon('25', '대전광역시'), + ulsan('26', '울산광역시'), + sejong('29', '세종특별자치시'), + gyeonggi('31', '경기도'), + gangwon('32', '강원특별자치도'), + chungbuk('33', '충청북도'), + chungnam('34', '충청남도'), + jeonbuk('35', '전북특별자치도'), + jeonnam('36', '전라남도'), + gyeongbuk('37', '경상북도'), + gyeongnam('38', '경상남도'), + jeju('39', '제주특별자치도'); + + final String code; + final String name; + + const ProvinceType(this.code, this.name); +} diff --git a/lib/features/profile/presentation/view_models/profile_image_view_model.dart b/lib/features/profile/presentation/view_models/profile_image_view_model.dart new file mode 100644 index 00000000..92dec444 --- /dev/null +++ b/lib/features/profile/presentation/view_models/profile_image_view_model.dart @@ -0,0 +1,54 @@ +import 'dart:math'; + +import 'package:debateseason_frontend_v1/features/profile/domain/type/image_type.dart'; +import 'package:debateseason_frontend_v1/utils/amplitude_util.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class ProfileImageViewModel extends GetxController { + late ScrollController scrollController; + + final _selectedImage = Rx(ImageType.red); + + ImageType get selectedImage => _selectedImage.value; + + @override + void onInit() { + super.onInit(); + + AmplitudeUtil.trackEvent(eventName: 'ProfileImage'); + scrollController = ScrollController(); + _setRandomImage(); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + void _setRandomImage() { + final random = Random(); + int index = random.nextInt(ImageType.values.length); + _selectedImage.value = ImageType.values[index]; + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToSelectedImage(); + }); + } + + void setImage({required ImageType imageType}) { + _selectedImage.value = imageType; + } + + void _scrollToSelectedImage() { + final index = ImageType.values.indexOf(_selectedImage.value); + if (index != -1 && scrollController.hasClients) { + final position = index * (41 + 8); + scrollController.animateTo( + position.toDouble(), + duration: Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + } + } +} diff --git a/lib/features/profile/presentation/view_models/profile_input_view_model.dart b/lib/features/profile/presentation/view_models/profile_input_view_model.dart index 20dc6373..3a0b0940 100644 --- a/lib/features/profile/presentation/view_models/profile_input_view_model.dart +++ b/lib/features/profile/presentation/view_models/profile_input_view_model.dart @@ -5,8 +5,11 @@ import 'package:debateseason_frontend_v1/features/profile/domain/entities/profil import 'package:debateseason_frontend_v1/features/profile/domain/repositories/community_repository.dart'; import 'package:debateseason_frontend_v1/features/profile/domain/repositories/profile_nickname_check_repository.dart'; import 'package:debateseason_frontend_v1/features/profile/domain/repositories/profile_repository.dart'; +import 'package:debateseason_frontend_v1/features/profile/domain/type/district_type.dart'; +import 'package:debateseason_frontend_v1/features/profile/domain/type/province_type.dart'; import 'package:debateseason_frontend_v1/features/profile/presentation/view_models/profile_view_model.dart'; import 'package:debateseason_frontend_v1/features/profile/profile_constants.dart'; +import 'package:debateseason_frontend_v1/utils/amplitude_util.dart'; import 'package:debateseason_frontend_v1/utils/base/ui_state.dart'; import 'package:debateseason_frontend_v1/utils/logger.dart'; import 'package:flutter/material.dart'; @@ -14,14 +17,16 @@ import 'package:get/get.dart'; class ProfileInputViewModel extends GetxController { late ProfileViewModel _profileViewModel; + late ProfileRepository _profileRepository; + late CommunityRepository _communityRepository; + late ProfileNicknameCheckRepository _profileNicknameCheckRepository; late TextEditingController nicknameController; late FocusNode nicknameFocusNode; late TextEditingController communityController; late TextEditingController communitySearchController; late TextEditingController ageController; - late ProfileRepository _profileRepository; - late CommunityRepository _communityRepository; - late ProfileNicknameCheckRepository _profileNicknameCheckRepository; + late TextEditingController residenceController; + late TextEditingController homeTownController; Timer? _debounceNickname; Timer? _debounceCommunity; final _profile = Rx(ProfileEntity( @@ -40,6 +45,10 @@ class ProfileInputViewModel extends GetxController { final _selectedAge = ''.obs; final _isModifyScreen = false.obs; final _isApiLoading = false.obs; + final _selectedResidenceProvince = Rx(ProvinceType.seoul); + final _selectedResidenceDistrict = Rx(null); + final _selectedHomeTownProvince = Rx(ProvinceType.seoul); + final _selectedHomeTownDistrict = Rx(null); ProfileEntity get profile => _profile.value; @@ -60,15 +69,28 @@ class ProfileInputViewModel extends GetxController { bool get isApiLoading => _isApiLoading.value; + ProvinceType get selectedResidenceProvince => + _selectedResidenceProvince.value; + + DistrictType? get selectedResidenceDistrict => + _selectedResidenceDistrict.value; + + ProvinceType get selectedHomeTownProvince => _selectedHomeTownProvince.value; + + DistrictType? get selectedHomeTownDistrict => _selectedHomeTownDistrict.value; + @override void onInit() { super.onInit(); + AmplitudeUtil.trackEvent(eventName: 'ProfileInput'); nicknameController = TextEditingController(); nicknameFocusNode = FocusNode(); communityController = TextEditingController(); communitySearchController = TextEditingController(); ageController = TextEditingController(); + residenceController = TextEditingController(); + homeTownController = TextEditingController(); _debounceNickname?.cancel(); _debounceCommunity?.cancel(); _profileRepository = Get.find(); @@ -104,6 +126,8 @@ class ProfileInputViewModel extends GetxController { communityController.dispose(); communitySearchController.dispose(); ageController.dispose(); + residenceController.dispose(); + homeTownController.dispose(); super.dispose(); } @@ -117,13 +141,13 @@ class ProfileInputViewModel extends GetxController { _profile.value = _profile.value.copyWith(nickname: nickname); _nicknameErrorText.value = ''; case 400: - _nicknameErrorText.value = ProfileConstants.validNickname; + _nicknameErrorText.value = ProfileConstants.VALID_NICKNAME; case 409: if (_previousNickname.value != nickname) { - _nicknameErrorText.value = ProfileConstants.validOverlapNickname; + _nicknameErrorText.value = ProfileConstants.VALID_OVERLAP_NICKNAME; } default: - _nicknameErrorText.value = ProfileConstants.validNickname; + _nicknameErrorText.value = ProfileConstants.VALID_NICKNAME; } } @@ -220,13 +244,13 @@ class ProfileInputViewModel extends GetxController { bool isValidNickname(String nickname) { if (nickname.length < 2 || nickname.length > 8) { - _nicknameErrorText.value = ProfileConstants.validNickname; + _nicknameErrorText.value = ProfileConstants.VALID_NICKNAME; return false; } - RegExp regex = RegExp(r'^[가-힣a-zA-Z]{1,8}$'); + RegExp regex = RegExp(ProfileConstants.PROFILE_NICK_NAME_VALIDITY_REG_EXP); if (regex.hasMatch(nickname) == false) { - _nicknameErrorText.value = ProfileConstants.validNickname; + _nicknameErrorText.value = ProfileConstants.VALID_NICKNAME; return false; } else { return true; @@ -244,4 +268,76 @@ class ProfileInputViewModel extends GetxController { return false; } } + + void setSelectedResidenceProvince({required ProvinceType province}) { + _selectedResidenceProvince.value = province; + _selectedResidenceDistrict.value = null; + } + + void setSelectedResidenceDistrict(DistrictType district) { + _selectedResidenceDistrict.value = district; + } + + void setSelectedHomeTownProvince({required ProvinceType province}) { + _selectedHomeTownProvince.value = province; + _selectedHomeTownDistrict.value = null; + } + + void setSelectedHomeTownDistrict(DistrictType district) { + _selectedHomeTownDistrict.value = district; + } + + void checkSameToResidence() { + if (_selectedResidenceDistrict.value != null) { + _selectedHomeTownProvince.value = _selectedResidenceProvince.value; + _selectedHomeTownDistrict.value = _selectedResidenceDistrict.value; + + homeTownController.text = + '${_selectedResidenceProvince.value.name} ${_selectedResidenceDistrict.value?.name}'; + } + } + + void uncheckSameToResidence() { + _selectedHomeTownProvince.value = ProvinceType.seoul; + _selectedHomeTownDistrict.value = null; + } + + List getDistrictList(ProvinceType province) { + switch (province) { + case ProvinceType.seoul: + return DistrictType.seoul; + case ProvinceType.busan: + return DistrictType.busan; + case ProvinceType.daegu: + return DistrictType.daegu; + case ProvinceType.incheon: + return DistrictType.incheon; + case ProvinceType.gwangju: + return DistrictType.gwangju; + case ProvinceType.daejeon: + return DistrictType.daejeon; + case ProvinceType.ulsan: + return DistrictType.ulsan; + case ProvinceType.sejong: + return DistrictType.sejong; + case ProvinceType.gyeonggi: + return DistrictType.gyeonggi; + case ProvinceType.gangwon: + return DistrictType.gangwon; + case ProvinceType.chungbuk: + return DistrictType.chungbuk; + case ProvinceType.chungnam: + return DistrictType.chungnam; + case ProvinceType.jeonbuk: + return DistrictType.jeonbuk; + case ProvinceType.jeonnam: + return DistrictType.jeonnam; + case ProvinceType.gyeongbuk: + return DistrictType.gyeongbuk; + case ProvinceType.gyeongnam: + return DistrictType.gyeongnam; + case ProvinceType.jeju: + return DistrictType.jeju; + } + } } diff --git a/lib/features/profile/presentation/view_models/profile_view_model.dart b/lib/features/profile/presentation/view_models/profile_view_model.dart index e552c4ee..e2038aec 100644 --- a/lib/features/profile/presentation/view_models/profile_view_model.dart +++ b/lib/features/profile/presentation/view_models/profile_view_model.dart @@ -4,6 +4,7 @@ import 'package:debateseason_frontend_v1/features/profile/domain/entities/profil import 'package:debateseason_frontend_v1/features/profile/domain/repositories/profile_repository.dart'; import 'package:debateseason_frontend_v1/features/profile/domain/repositories/users_logout_repository.dart'; import 'package:debateseason_frontend_v1/features/profile/domain/repositories/users_withdraw_repository.dart'; +import 'package:debateseason_frontend_v1/utils/amplitude_util.dart'; import 'package:debateseason_frontend_v1/utils/base/ui_state.dart'; import 'package:debateseason_frontend_v1/utils/logger.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -24,6 +25,7 @@ class ProfileViewModel extends GetxController { void onInit() { super.onInit(); + AmplitudeUtil.trackEvent(eventName: 'Profile'); _profileRepository = Get.find(); _usersLogoutRepository = Get.find(); _usersWithdrawRepository = Get.find(); diff --git a/lib/features/profile/presentation/view_models/terms_view_model.dart b/lib/features/profile/presentation/view_models/terms_view_model.dart index 0676289a..6a7c9705 100644 --- a/lib/features/profile/presentation/view_models/terms_view_model.dart +++ b/lib/features/profile/presentation/view_models/terms_view_model.dart @@ -2,6 +2,7 @@ import 'package:debateseason_frontend_v1/common/constants/error_constants.dart'; import 'package:debateseason_frontend_v1/features/profile/domain/entities/terms_agree_entity.dart'; import 'package:debateseason_frontend_v1/features/profile/domain/entities/terms_entity.dart'; import 'package:debateseason_frontend_v1/features/profile/domain/repositories/terms_repository.dart'; +import 'package:debateseason_frontend_v1/utils/amplitude_util.dart'; import 'package:debateseason_frontend_v1/utils/base/ui_state.dart'; import 'package:debateseason_frontend_v1/utils/logger.dart'; import 'package:get/get.dart'; @@ -22,6 +23,7 @@ class TermsViewModel extends GetxController { void onInit() async { super.onInit(); + AmplitudeUtil.trackEvent(eventName: 'Terms'); _profileStatus.value = Get.arguments as bool; _termsRepository = Get.find(); await getTerms(); diff --git a/lib/features/profile/presentation/views/profile_image_screen.dart b/lib/features/profile/presentation/views/profile_image_screen.dart new file mode 100644 index 00000000..ff76eb59 --- /dev/null +++ b/lib/features/profile/presentation/views/profile_image_screen.dart @@ -0,0 +1,126 @@ +import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; +import 'package:debateseason_frontend_v1/features/profile/domain/type/image_type.dart'; +import 'package:debateseason_frontend_v1/features/profile/presentation/view_models/profile_image_view_model.dart'; +import 'package:debateseason_frontend_v1/widgets/de_app_bar.dart'; +import 'package:debateseason_frontend_v1/widgets/de_gesture_detector.dart'; +import 'package:debateseason_frontend_v1/widgets/de_scaffold.dart'; +import 'package:debateseason_frontend_v1/widgets/de_text.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class ProfileImageScreen extends GetView { + const ProfileImageScreen({super.key}); + + @override + Widget build(BuildContext context) { + return DeScaffold( + appBar: _appBar(), + body: _body(), + ); + } + + DeAppBar _appBar() { + return DeAppBar( + title: Text( + '프로필 사진 등록하기', + style: DeFonts.header20B, + ), + isBack: false, + ); + } + + Widget _body() { + return Column( + children: [ + DeGaps.v40, + _widgetMainImage(), + DeGaps.v32, + _widgetImages(), + ], + ); + } + + Widget _widgetMainImage() { + return Obx(() { + final selectedImage = controller.selectedImage; + + return Container( + width: 120, + height: 120, + margin: EdgeInsets.symmetric(horizontal: 120), + decoration: BoxDecoration( + color: selectedImage.color, + shape: BoxShape.circle, + ), + ); + }); + } + + Widget _widgetImages() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: DeText( + '색상', + style: DeFonts.caption12SB.copyWith(color: DeColors.grey50), + ), + ), + DeGaps.v12, + SizedBox( + height: 107, + child: ListView.separated( + controller: controller.scrollController, + shrinkWrap: true, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: ImageType.values.length, + itemBuilder: (context, index) { + final imageItem = ImageType.values[index]; + + return _widgetImageCard(item: imageItem); + }, + separatorBuilder: (context, index) => DeGaps.h8, + ), + ) + ], + ); + } + + Widget _widgetImageCard({required ImageType item}) { + return DeGestureDetector( + onTap: () => controller.setImage(imageType: item), + child: Obx(() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18.5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: item == controller.selectedImage + ? Border.all(color: DeColors.brand, width: 2) + : Border.all(color: DeColors.grey90, width: 1), + ), + child: Column( + children: [ + Container( + width: item == controller.selectedImage ? 42 : 41, + height: item == controller.selectedImage ? 42 : 41, + decoration: BoxDecoration( + color: item.color, + shape: BoxShape.circle, + ), + ), + DeGaps.v6, + DeText( + item.name, + style: DeFonts.caption12SB, + ) + ], + ), + ); + }), + ); + } +} diff --git a/lib/features/profile/presentation/views/profile_input_page.dart b/lib/features/profile/presentation/views/profile_input_screen.dart similarity index 50% rename from lib/features/profile/presentation/views/profile_input_page.dart rename to lib/features/profile/presentation/views/profile_input_screen.dart index f0e8331f..fd0a53fa 100644 --- a/lib/features/profile/presentation/views/profile_input_page.dart +++ b/lib/features/profile/presentation/views/profile_input_screen.dart @@ -7,13 +7,14 @@ import 'package:debateseason_frontend_v1/core/routers/get_router_name.dart'; import 'package:debateseason_frontend_v1/features/profile/presentation/view_models/profile_input_view_model.dart'; import 'package:debateseason_frontend_v1/features/profile/presentation/widgets/import_profile.dart'; import 'package:debateseason_frontend_v1/features/profile/profile_constants.dart'; +import 'package:debateseason_frontend_v1/utils/amplitude_util.dart'; import 'package:debateseason_frontend_v1/utils/de_snack_bar.dart'; import 'package:debateseason_frontend_v1/widgets/import_de.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -class ProfileInputPage extends GetView { - const ProfileInputPage({super.key}); +class ProfileInputScreen extends GetView { + const ProfileInputScreen({super.key}); @override Widget build(BuildContext context) { @@ -29,8 +30,8 @@ class ProfileInputPage extends GetView { return DeAppBar( title: DeText( isModifyScreen - ? ProfileConstants.profileModifyAppbarText - : ProfileConstants.profileCreateAppbarText, + ? ProfileConstants.PROFILE_MODIFY_APPBAR_TEXT + : ProfileConstants.PROFILE_CREATE_APPBAR_TEXT, style: DeFonts.header20B, ), isBack: isModifyScreen, @@ -64,38 +65,12 @@ class ProfileInputPage extends GetView { _widgetGender(), DeGaps.v32, _widgetAge(), + // DeGaps.v32, + // _widgetResidence(), + // DeGaps.v32, + // _widgetHomeTown(), DeGaps.v40, - Obx(() { - return DeButtonLarge( - controller.isModifyScreen - ? ProfileConstants.profileModifyBtnText - : ProfileConstants.profileCreateBtnText, - onPressed: controller.isModifyScreen - ? () => controller.patchProfile().then((result) { - result.when(loading: () { - controller.setApiLoading(isApiLoading: true); - }, success: (_) { - controller.setApiLoading(isApiLoading: false); - Get.back(); - }, failure: (msg) { - controller.setApiLoading(isApiLoading: false); - deSnackBar(msg); - }); - }) - : () => controller.postProfile().then((result) { - result.when(loading: () { - controller.setApiLoading(isApiLoading: true); - }, success: (_) { - controller.setApiLoading(isApiLoading: false); - Get.offAllNamed(GetRouterName.issuemap); - }, failure: (msg) { - controller.setApiLoading(isApiLoading: false); - deSnackBar(msg); - }); - }), - enable: controller.isValidStartBtn(), - ); - }), + _widgetBottomButton(), DeGaps.v20, ], ), @@ -137,14 +112,22 @@ class ProfileInputPage extends GetView { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - DeText( - '닉네임', - style: DeFonts.body14Sb, + Row( + children: [ + DeText( + ProfileConstants.PROFILE_NICK_NAME, + style: DeFonts.body14Sb, + ), + DeText( + ProfileConstants.PROFILE_ESSENTIAL_STAR, + style: DeFonts.body14Sb.copyWith(color: DeColors.brand), + ), + ], ), DeGaps.v8, DeTextField( style: DeFonts.body16M, - hintText: '한글, 영문 조합 최대 8자', + hintText: ProfileConstants.PROFILE_NICK_NAME_HINT_TEXT, controller: controller.nicknameController, focusNode: controller.nicknameFocusNode, autofocus: false, @@ -178,21 +161,28 @@ class ProfileInputPage extends GetView { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - DeText( - '소속 커뮤니티', - style: DeFonts.body14Sb, + Row( + children: [ + DeText( + ProfileConstants.PROFILE_AFFILIATED_COMMUNITY, + style: DeFonts.body14Sb, + ), + DeText( + ProfileConstants.PROFILE_ESSENTIAL_STAR, + style: DeFonts.body14Sb.copyWith(color: DeColors.brand), + ) + ], ), DeGaps.v8, DeGestureDetector( onTap: () { - // 커뮤니티 선택 바텀시트 if (Get.context != null) { showModalBottomSheet( context: Get.context!, isScrollControlled: true, builder: (context) { return DeBottomSheet( - '소속 커뮤니티', + ProfileConstants.PROFILE_AFFILIATED_COMMUNITY, widget: ProfileCommunityBottomSheet(), ); }, @@ -209,7 +199,7 @@ class ProfileInputPage extends GetView { }, child: DeTextField( style: DeFonts.body16M, - hintText: '주로 활동하는 커뮤니티를 등록해 주세요.', + hintText: ProfileConstants.PROFILE_COMMUNITY_HINT_TEXT, controller: controller.communityController, enabled: false, isCleanIcon: false, @@ -220,20 +210,28 @@ class ProfileInputPage extends GetView { } Widget _widgetGender() { - const genderMan = '남성'; - const genderWomen = '여성'; - const genderOther = '무응답'; + const genderMan = ProfileConstants.PROFILE_GENDER_MAN; + const genderWomen = ProfileConstants.PROFILE_GENDER_WOMEN; + const genderOther = ProfileConstants.PROFILE_GENDER_OTHER; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - DeText( - '성별', - style: DeFonts.body14Sb, + Row( + children: [ + DeText( + ProfileConstants.PROFILE_GENDER, + style: DeFonts.body14Sb, + ), + DeText( + ProfileConstants.PROFILE_ESSENTIAL_STAR, + style: DeFonts.body14Sb.copyWith(color: DeColors.brand), + ) + ], ), DeGaps.v4, DeText( - '본 정보는 타인에게 공개되지 않습니다.', + ProfileConstants.PROFILE_PRIVACY_NOTICE, style: DeFonts.caption12M.copyWith(color: DeColors.grey50), ), DeGaps.v8, @@ -285,13 +283,21 @@ class ProfileInputPage extends GetView { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - DeText( - '나이대', - style: DeFonts.body14Sb, + Row( + children: [ + DeText( + ProfileConstants.PROFILE_AGE, + style: DeFonts.body14Sb, + ), + DeText( + ProfileConstants.PROFILE_ESSENTIAL_STAR, + style: DeFonts.body14Sb.copyWith(color: DeColors.brand), + ) + ], ), DeGaps.v4, DeText( - '본 정보는 타인에게 공개되지 않습니다.', + ProfileConstants.PROFILE_PRIVACY_NOTICE, style: DeFonts.caption12M.copyWith(color: DeColors.grey50), ), DeGaps.v8, @@ -303,7 +309,7 @@ class ProfileInputPage extends GetView { isScrollControlled: true, builder: (context) { return DeBottomSheet( - '나이대', + ProfileConstants.PROFILE_AGE, widget: ProfileAgeBottomSheet(), ); }, @@ -314,7 +320,7 @@ class ProfileInputPage extends GetView { }, child: DeTextField( style: DeFonts.body16M, - hintText: '나이대를 선택해주세요.', + hintText: ProfileConstants.PROFILE_AGE_HINT_TEXT, controller: controller.ageController, enabled: false, isCleanIcon: false, @@ -323,4 +329,164 @@ class ProfileInputPage extends GetView { ], ); } + +// Widget _widgetResidence() { +// return Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// Row( +// children: [ +// DeText( +// ProfileConstants.PROFILE_RESIDENCE, +// style: DeFonts.body14Sb, +// ), +// DeText( +// ProfileConstants.PROFILE_ESSENTIAL_STAR, +// style: DeFonts.body14Sb.copyWith(color: DeColors.brand), +// ) +// ], +// ), +// DeGaps.v4, +// DeText( +// ProfileConstants.PROFILE_PRIVACY_NOTICE, +// style: DeFonts.caption12M.copyWith(color: DeColors.grey50), +// ), +// DeGaps.v8, +// DeGestureDetector( +// onTap: () { +// if (Get.context != null) { +// showModalBottomSheet( +// context: Get.context!, +// isScrollControlled: true, +// builder: (context) { +// return ProfileLocationBottomSheet( +// title: ProfileConstants.PROFILE_RESIDENCE, +// ); +// }, +// ); +// } +// }, +// child: DeTextField( +// style: DeFonts.body16M, +// hintText: ProfileConstants.PROFILE_RESIDENCE_HINT_TEXT, +// controller: controller.residenceController, +// enabled: false, +// isCleanIcon: false, +// ), +// ), +// ], +// ); +// } +// +// Widget _widgetHomeTown() { +// return Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// Row( +// children: [ +// DeText( +// ProfileConstants.PROFILE_HOME_TOWN, +// style: DeFonts.body14Sb, +// ), +// DeText( +// ProfileConstants.PROFILE_ESSENTIAL_STAR, +// style: DeFonts.body14Sb.copyWith(color: DeColors.brand), +// ) +// ], +// ), +// DeGaps.v4, +// DeText( +// ProfileConstants.PROFILE_PRIVACY_NOTICE, +// style: DeFonts.caption12M.copyWith(color: DeColors.grey50), +// ), +// DeGaps.v8, +// DeGestureDetector( +// onTap: () { +// if (Get.context != null) { +// showModalBottomSheet( +// context: Get.context!, +// isScrollControlled: true, +// builder: (context) { +// return ProfileLocationBottomSheet( +// title: ProfileConstants.PROFILE_HOME_TOWN, +// ); +// }, +// ); +// } +// }, +// child: DeTextField( +// style: DeFonts.body16M, +// hintText: ProfileConstants.PROFILE_HOME_TOWN_HINT_TEXT, +// controller: controller.homeTownController, +// enabled: false, +// isCleanIcon: false, +// ), +// ), +// DeGaps.v12, +// Row( +// children: [ +// DeText( +// ProfileConstants.PROFILE_SAME_TO_RESIDENCE, +// style: DeFonts.body14M.copyWith(color: DeColors.grey50), +// ), +// const Spacer(), +// Obx(() { +// bool isChecked = false; +// isChecked = controller.selectedResidenceDistrict != null && +// controller.selectedResidenceProvince == +// controller.selectedHomeTownProvince && +// controller.selectedResidenceDistrict == +// controller.selectedHomeTownDistrict; +// +// return Checkbox( +// value: isChecked, +// onChanged: (value) { +// if (value ?? false) { +// controller.checkSameToResidence(); +// } else { +// controller.uncheckSameToResidence(); +// } +// }, +// ); +// }), +// ], +// ), +// ], +// ); +// } +// + Widget _widgetBottomButton() { + return Obx(() { + return DeButtonLarge( + controller.isModifyScreen + ? ProfileConstants.PROFILE_MODIFY_BTN_TEXT + : ProfileConstants.PROFILE_CREATE_BTN_TEXT, + onPressed: controller.isModifyScreen + ? () => controller.patchProfile().then((result) { + result.when(loading: () { + controller.setApiLoading(isApiLoading: true); + }, success: (_) { + controller.setApiLoading(isApiLoading: false); + Get.back(); + }, failure: (msg) { + controller.setApiLoading(isApiLoading: false); + deSnackBar(msg); + }); + }) + : () => controller.postProfile().then((result) { + result.when(loading: () { + controller.setApiLoading(isApiLoading: true); + }, success: (_) { + controller.setApiLoading(isApiLoading: false); + AmplitudeUtil.trackEvent(eventName: '토론철 시작하기'); + Get.offAllNamed(GetRouterName.issuemap); + }, failure: (msg) { + controller.setApiLoading(isApiLoading: false); + deSnackBar(msg); + }); + }), + enable: controller.isValidStartBtn(), + ); + }); + } } diff --git a/lib/features/profile/presentation/views/profile_screen.dart b/lib/features/profile/presentation/views/profile_screen.dart index 3be423ce..dc0c7352 100644 --- a/lib/features/profile/presentation/views/profile_screen.dart +++ b/lib/features/profile/presentation/views/profile_screen.dart @@ -241,7 +241,7 @@ class ProfileScreen extends GetView { child: Row( children: [ DeText( - ProfileConstants.profileSupportEmail, + ProfileConstants.PROFILE_SUPPORT_EMAIL, style: DeFonts.body14M.copyWith(color: DeColors.grey50), ), DeGaps.h8, diff --git a/lib/features/profile/presentation/views/profile_withdraw_page.dart b/lib/features/profile/presentation/views/profile_withdraw_screen.dart similarity index 75% rename from lib/features/profile/presentation/views/profile_withdraw_page.dart rename to lib/features/profile/presentation/views/profile_withdraw_screen.dart index caf7f396..14567c9d 100644 --- a/lib/features/profile/presentation/views/profile_withdraw_page.dart +++ b/lib/features/profile/presentation/views/profile_withdraw_screen.dart @@ -16,8 +16,8 @@ import 'package:debateseason_frontend_v1/widgets/de_text.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -class ProfileWithdrawPage extends GetView { - const ProfileWithdrawPage({super.key}); +class ProfileWithdrawScreen extends GetView { + const ProfileWithdrawScreen({super.key}); @override Widget build(BuildContext context) { @@ -45,45 +45,47 @@ class ProfileWithdrawPage extends GetView { children: [ DeGaps.v12, DeText( - ProfileConstants.profileWithdrawPolicyTitle, + ProfileConstants.PROFILE_WITHDRAW_POLICY_TITLE, style: DeFonts.header20B, ), DeGaps.v8, DeText( - ProfileConstants.profileWithdrawPolicyDescription, + ProfileConstants.PROFILE_WITHDRAW_POLICY_DESCRIPTION, style: DeFonts.caption12M.copyWith(color: DeColors.grey30), ), DeGaps.v20, DeText( - ProfileConstants.profileWithdrawProcedureTitle, + ProfileConstants.PROFILE_WITHDRAW_PROCEDURE_TITLE, style: DeFonts.header20B, ), DeGaps.v8, DeText( - ProfileConstants.profileWithdrawProcedureDescription, + ProfileConstants.PROFILE_WITHDRAW_PROCEDURE_DESCRIPTION, style: DeFonts.body14M.copyWith(color: DeColors.grey30), ), DeText( - ProfileConstants.profileWithdrawWarning, + ProfileConstants.PROFILE_WITHDRAW_WARNING, style: DeFonts.body14M.copyWith(color: DeColors.red), ), DeGaps.v20, DeText( - ProfileConstants.profileWithdrawDeletedInfoTitle, + ProfileConstants.PROFILE_WITHDRAW_DELETED_INFO_TITLE, style: DeFonts.header20B, ), DeGaps.v8, Text.rich( TextSpan( - text: ProfileConstants.profileWithdrawDeletedInfoDescription1, + text: ProfileConstants.PROFILE_WITHDRAW_DELETED_INFO_DESCRIPTION1, style: DeFonts.body14M.copyWith(color: DeColors.grey30), children: [ TextSpan( - text: ProfileConstants.profileWithdrawDeletedInfoDescription2, + text: ProfileConstants + .PROFILE_WITHDRAW_DELETED_INFO_DESCRIPTION2, style: DeFonts.body14M.copyWith(color: DeColors.brand), ), TextSpan( - text: ProfileConstants.profileWithdrawDeletedInfoDescription3, + text: ProfileConstants + .PROFILE_WITHDRAW_DELETED_INFO_DESCRIPTION3, style: DeFonts.body14M.copyWith(color: DeColors.grey30), ), ], @@ -93,10 +95,12 @@ class ProfileWithdrawPage extends GetView { DeGestureDetector( onTap: () { DeDialog.show( - dialogTitle: ProfileConstants.profileWithdrawDialogTitle, - dialogText: ProfileConstants.profileWithdrawDialogDescription, - cancelText: ProfileConstants.profileWithdrawDialogCancelText, - doneText: ProfileConstants.profileWithdrawDoneText, + dialogTitle: ProfileConstants.PROFILE_WITHDRAW_DIALOG_TITLE, + dialogText: + ProfileConstants.PROFILE_WITHDRAW_DIALOG_DESCRIPTION, + cancelText: + ProfileConstants.PROFILE_WITHDRAW_DIALOG_CANCEL_TEXT, + doneText: ProfileConstants.PROFILE_WITHDRAW_DONE_TEXT, onTapDone: () { controller.postWithdraw().then((result) { result.when( @@ -106,8 +110,8 @@ class ProfileWithdrawPage extends GetView { if (Platform.isAndroid) { controller.kakaoLogout().then((_) { Get.offAllNamed(GetRouterName.auth); - deSnackBar( - ProfileConstants.profileWithdrawSuccessMessage); + deSnackBar(ProfileConstants + .PROFILE_WITHDRAW_SUCCESS_MESSAGE); }); } else { Get.offAllNamed(GetRouterName.auth); @@ -130,7 +134,7 @@ class ProfileWithdrawPage extends GetView { ), child: Center( child: DeText( - ProfileConstants.profileWithdrawDoneText, + ProfileConstants.PROFILE_WITHDRAW_DONE_TEXT, style: DeFonts.body16M, )), ), diff --git a/lib/features/profile/presentation/views/terms_page.dart b/lib/features/profile/presentation/views/terms_screen.dart similarity index 97% rename from lib/features/profile/presentation/views/terms_page.dart rename to lib/features/profile/presentation/views/terms_screen.dart index 3e4c08e1..7c8c142e 100644 --- a/lib/features/profile/presentation/views/terms_page.dart +++ b/lib/features/profile/presentation/views/terms_screen.dart @@ -13,8 +13,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; -class TermsPage extends GetView { - const TermsPage({super.key}); +class TermsScreen extends GetView { + const TermsScreen({super.key}); @override Widget build(BuildContext context) { @@ -101,7 +101,7 @@ class TermsPage extends GetView { } Widget _termsTitle(String url, String type) { - String title = ProfileConstants.termsTitleMap[type] ?? ''; + String title = ProfileConstants.TERMS_TITLE_MAP[type] ?? ''; return DeGestureDetector( onTap: () { diff --git a/lib/features/profile/presentation/widgets/profile_age_bottom_sheet.dart b/lib/features/profile/presentation/widgets/profile_age_bottom_sheet.dart index cb3201b8..7aa10916 100644 --- a/lib/features/profile/presentation/widgets/profile_age_bottom_sheet.dart +++ b/lib/features/profile/presentation/widgets/profile_age_bottom_sheet.dart @@ -1,5 +1,6 @@ import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; import 'package:debateseason_frontend_v1/features/profile/presentation/view_models/profile_input_view_model.dart'; +import 'package:debateseason_frontend_v1/features/profile/profile_constants.dart'; import 'package:debateseason_frontend_v1/widgets/de_button_large.dart'; import 'package:debateseason_frontend_v1/widgets/de_gesture_detector.dart'; import 'package:flutter/material.dart'; @@ -14,18 +15,6 @@ class ProfileAgeBottomSheet extends GetView { @override Widget build(BuildContext context) { - final List ageRange = [ - '10대', - '20대', - '30대', - '40대', - '50대', - '60대', - '70대', - '80대', - '90대 이상', - ]; - return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -39,9 +28,9 @@ class ProfileAgeBottomSheet extends GetView { mainAxisSpacing: 8, childAspectRatio: 1, ), - itemCount: ageRange.length, + itemCount: ProfileConstants.PROFILE_AGE_RANGE.length, itemBuilder: (context, index) { - final age = ageRange[index]; + final age = ProfileConstants.PROFILE_AGE_RANGE[index]; return Obx(() { final selectedAge = controller.selectedAge; @@ -50,7 +39,8 @@ class ProfileAgeBottomSheet extends GetView { onTap: () => controller.setSelectedAge(selectedAge: age), child: ProfileTextCard( age, - isSelected: selectedAge == ageRange[index], + isSelected: + selectedAge == ProfileConstants.PROFILE_AGE_RANGE[index], ), ); }); @@ -61,7 +51,7 @@ class ProfileAgeBottomSheet extends GetView { final selectedAge = controller.selectedAge; return DeButtonLarge( - '선택하기', + ProfileConstants.PROFILE_CHOICE, onPressed: () { controller.setAgeRange(ageRange: selectedAge); Get.back(); diff --git a/lib/features/profile/presentation/widgets/profile_community_bottom_sheet.dart b/lib/features/profile/presentation/widgets/profile_community_bottom_sheet.dart index 6e3aba1a..c64ef38f 100644 --- a/lib/features/profile/presentation/widgets/profile_community_bottom_sheet.dart +++ b/lib/features/profile/presentation/widgets/profile_community_bottom_sheet.dart @@ -1,8 +1,9 @@ import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; -import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; import 'package:debateseason_frontend_v1/features/profile/domain/entities/community_entity.dart'; import 'package:debateseason_frontend_v1/features/profile/presentation/view_models/profile_input_view_model.dart'; +import 'package:debateseason_frontend_v1/features/profile/profile_constants.dart'; import 'package:debateseason_frontend_v1/widgets/de_button_large.dart'; import 'package:debateseason_frontend_v1/widgets/de_gesture_detector.dart'; import 'package:debateseason_frontend_v1/widgets/de_progress_indicator.dart'; @@ -83,7 +84,7 @@ class ProfileCommunityBottomSheet extends GetView { children: [ DeTextField( controller: controller.communitySearchController, - hintText: '내용을 입력해 주세요.', + hintText: ProfileConstants.PROFILE_EMPTY_HINT_TEXT, fillColor: DeColors.grey90, onChanged: (searchWord) => controller.onChangedCommunity(searchWord: searchWord), @@ -128,7 +129,7 @@ class ProfileCommunityBottomSheet extends GetView { final selectedCommunityId = controller.selectedCommunityId; return DeButtonLarge( - '등록하기', + ProfileConstants.PROFILE_REGISTER, onPressed: () { controller.setCommunityId( communityId: selectedCommunityId, diff --git a/lib/features/profile/presentation/widgets/profile_location_bottom_sheet.dart b/lib/features/profile/presentation/widgets/profile_location_bottom_sheet.dart new file mode 100644 index 00000000..01968cea --- /dev/null +++ b/lib/features/profile/presentation/widgets/profile_location_bottom_sheet.dart @@ -0,0 +1,223 @@ +import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_dimensions.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_icons.dart'; +import 'package:debateseason_frontend_v1/features/profile/domain/type/district_type.dart'; +import 'package:debateseason_frontend_v1/features/profile/domain/type/province_type.dart'; +import 'package:debateseason_frontend_v1/features/profile/presentation/view_models/profile_input_view_model.dart'; +import 'package:debateseason_frontend_v1/features/profile/profile_constants.dart'; +import 'package:debateseason_frontend_v1/widgets/import_de.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; + +class ProfileLocationBottomSheet extends GetView { + final String title; + + const ProfileLocationBottomSheet({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return DeGestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Container( + decoration: BoxDecoration( + color: DeColors.grey80, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center(child: _widgetGrabber()), + DeGaps.v16, + _widgetHeader(context: context), + Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: DeColors.grey90, width: 2), + bottom: BorderSide(color: DeColors.grey90, width: 2), + ), + ), + child: Row( + children: [ + Expanded( + child: _widgetProvince(context: context), + ), + Container( + width: 2, + height: MediaQuery.of(context).size.height * 0.7, + color: DeColors.grey90, + ), + Expanded( + child: _widgetDistrict(context: context), + ), + ], + ), + ), + _widgetBottomButton(), + ], + ), + ), + ); + } + + Widget _widgetGrabber() { + return Container( + width: 40, + height: 5, + margin: DeDimensions.all8, + decoration: BoxDecoration( + color: DeColors.grey50, + borderRadius: BorderRadius.circular(5), + ), + ); + } + + Widget _widgetHeader({required BuildContext context}) { + return Padding( + padding: EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Row( + children: [ + Expanded( + child: DeText( + title, + style: DeFonts.header18Sb, + ), + ), + DeGestureDetector( + onTap: () => Navigator.pop(context), + child: Padding( + padding: DeDimensions.all10, + child: SvgPicture.asset( + DeIcons.icXGrey50, + width: 20, + height: 20, + ), + ), + ), + ], + ), + ); + } + + Widget _widgetBottomButton() { + return Obx(() { + String selectedProvince = ProvinceType.seoul.name; + String selectedDistrict = ''; + + if (title == ProfileConstants.PROFILE_RESIDENCE) { + selectedProvince = controller.selectedResidenceProvince.name; + selectedDistrict = controller.selectedResidenceDistrict?.name ?? ''; + } else { + selectedProvince = controller.selectedHomeTownProvince.name; + selectedDistrict = controller.selectedHomeTownDistrict?.name ?? ''; + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: DeButtonLarge( + ProfileConstants.PROFILE_CHOICE, + onPressed: () { + if (title == ProfileConstants.PROFILE_RESIDENCE) { + controller.residenceController.text = + '$selectedProvince $selectedDistrict'; + } else { + controller.homeTownController.text = + '$selectedProvince $selectedDistrict'; + } + Get.back(); + }, + enable: selectedDistrict.isNotEmpty, + ), + ); + }); + } + + Widget _widgetProvince({required BuildContext context}) { + return SizedBox( + height: MediaQuery.of(context).size.height * 0.7, + child: ListView.builder( + itemCount: ProvinceType.values.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final province = ProvinceType.values[index]; + + return Obx(() { + var isSelected = false; + if (title == ProfileConstants.PROFILE_RESIDENCE) { + isSelected = controller.selectedResidenceProvince == province; + } else { + isSelected = controller.selectedHomeTownProvince == province; + } + + return DeGestureDetector( + onTap: () { + controller.setSelectedResidenceProvince(province: province); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + color: isSelected ? DeColors.grey80 : DeColors.grey90, + child: DeText( + province.name, + style: DeFonts.body16Sb.copyWith(color: DeColors.grey30), + textAlign: TextAlign.center, + ), + ), + ); + }); + }, + ), + ); + } + + Widget _widgetDistrict({required BuildContext context}) { + return Obx(() { + ProvinceType selectedProvince = ProvinceType.seoul; + DistrictType? selectedDistrict; + if (title == ProfileConstants.PROFILE_RESIDENCE) { + selectedProvince = controller.selectedResidenceProvince; + selectedDistrict = controller.selectedResidenceDistrict; + } else { + selectedProvince = controller.selectedHomeTownProvince; + selectedDistrict = controller.selectedHomeTownDistrict; + } + + return Container( + height: MediaQuery.of(context).size.height * 0.7, + color: DeColors.grey80, + child: ListView.builder( + itemCount: controller.getDistrictList(selectedProvince).length, + shrinkWrap: true, + itemBuilder: (context, index) { + final district = + controller.getDistrictList(selectedProvince)[index]; + final isSelected = selectedDistrict == district; + + return DeGestureDetector( + onTap: () { + if (title == ProfileConstants.PROFILE_RESIDENCE) { + controller.setSelectedResidenceDistrict(district); + } else { + controller.setSelectedHomeTownDistrict(district); + } + }, + child: Container( + color: isSelected ? DeColors.grey70 : DeColors.grey80, + padding: const EdgeInsets.symmetric(vertical: 16), + child: DeText( + district.name, + style: DeFonts.body16Sb.copyWith(color: DeColors.grey30), + textAlign: TextAlign.center, + ), + ), + ); + }, + ), + ); + }); + } +} diff --git a/lib/features/profile/profile_constants.dart b/lib/features/profile/profile_constants.dart index 6464630d..374ba30a 100644 --- a/lib/features/profile/profile_constants.dart +++ b/lib/features/profile/profile_constants.dart @@ -1,32 +1,66 @@ class ProfileConstants { - static const profileCreateAppbarText = '프로필 입력'; - static const profileModifyAppbarText = '프로필 수정'; - static const validNickname = '유효하지 않은 닉네임입니다.'; - static const validOverlapNickname = '중복된 닉네임입니다.'; - static const profileCreateBtnText = '토론철 시작하기'; - static const profileModifyBtnText = '완료'; - static const profileSupportEmail = '카카오채널'; - static const profileWithdrawPolicyTitle = '탈퇴 정책 안내'; - static const profileWithdrawPolicyDescription = '탈퇴 정책 안내'; - static const profileWithdrawProcedureTitle = '절차'; - static const profileWithdrawProcedureDescription = + static const PROFILE_CREATE_APPBAR_TEXT = '프로필 입력'; + static const PROFILE_MODIFY_APPBAR_TEXT = '프로필 수정'; + static const VALID_NICKNAME = '유효하지 않은 닉네임입니다.'; + static const VALID_OVERLAP_NICKNAME = '중복된 닉네임입니다.'; + static const PROFILE_CREATE_BTN_TEXT = '토론철 시작하기'; + static const PROFILE_MODIFY_BTN_TEXT = '완료'; + static const PROFILE_SUPPORT_EMAIL = '카카오채널'; + static const PROFILE_WITHDRAW_POLICY_TITLE = '탈퇴 정책 안내'; + static const PROFILE_WITHDRAW_POLICY_DESCRIPTION = '탈퇴 정책 안내'; + static const PROFILE_WITHDRAW_PROCEDURE_TITLE = '절차'; + static const PROFILE_WITHDRAW_PROCEDURE_DESCRIPTION = '회원 탈퇴를 신청하면 계정이 로그아웃되고 5일 뒤 탈퇴가 진행됩니다. 삭제 전 다시 로그인하면 탈퇴를 취소할 수 있습니다.'; - static const profileWithdrawWarning = '탈퇴가 완료될 경우, 해당 아이는 복구가 불가능합니다.'; - static const profileWithdrawDeletedInfoTitle = '삭제되는 정보'; - static const profileWithdrawDeletedInfoDescription1 = '탈퇴가 완료되면 '; - static const profileWithdrawDeletedInfoDescription2 = '고유아이디, 닉네임, 성별 정보'; - static const profileWithdrawDeletedInfoDescription3 = + static const PROFILE_WITHDRAW_WARNING = '탈퇴가 완료될 경우, 해당 아이는 복구가 불가능합니다.'; + static const PROFILE_WITHDRAW_DELETED_INFO_TITLE = '삭제되는 정보'; + static const PROFILE_WITHDRAW_DELETED_INFO_DESCRIPTION1 = '탈퇴가 완료되면 '; + static const PROFILE_WITHDRAW_DELETED_INFO_DESCRIPTION2 = '고유아이디, 닉네임, 성별 정보'; + static const PROFILE_WITHDRAW_DELETED_INFO_DESCRIPTION3 = '가 삭제됩니다. 내가 선택한 진영과 작성한 대화는 삭제되지 않습니다.'; - static const profileWithdrawDialogTitle = '회원 탈퇴 신청'; - static const profileWithdrawDialogDescription = + static const PROFILE_WITHDRAW_DIALOG_TITLE = '회원 탈퇴 신청'; + static const PROFILE_WITHDRAW_DIALOG_DESCRIPTION = '회원 탈퇴를 신청하면 계정이 로그아웃되고 5일 뒤 회원 정보가 완전히 삭제됩니다.\n' '삭제 전 다시 로그인하면 탈퇴를 취소할 수 있습니다.\n' '탈퇴를 신청하시겠습니까?'; - static const profileWithdrawDialogCancelText = '탈퇴 취소'; - static const profileWithdrawDoneText = '탈퇴 신청하기'; - static const profileWithdrawSuccessMessage = '회원탈퇴 되었습니다.'; - static const Map termsTitleMap = { + static const PROFILE_WITHDRAW_DIALOG_CANCEL_TEXT = '탈퇴 취소'; + static const PROFILE_WITHDRAW_DONE_TEXT = '탈퇴 신청하기'; + static const PROFILE_WITHDRAW_SUCCESS_MESSAGE = '회원탈퇴 되었습니다.'; + static const Map TERMS_TITLE_MAP = { 'SERVICE': '서비스 이용 약관', 'PRIVACY': '개인정보 수집/이용 동의', + 'THIRD_PARTY': '개인정보 제3자 제공 동의', }; -} \ No newline at end of file + + static const PROFILE_ESSENTIAL_STAR = '*'; + static const PROFILE_PRIVACY_NOTICE = '본 정보는 타인에게 공개되지 않습니다.'; + static const PROFILE_NICK_NAME = '닉네임'; + static const PROFILE_NICK_NAME_HINT_TEXT = '한글, 영문 조합 최대 8자'; + static const PROFILE_NICK_NAME_VALIDITY_REG_EXP = r'^[가-힣a-zA-Z]{1,8}$'; + static const PROFILE_AFFILIATED_COMMUNITY = '소속 커뮤니티'; + static const PROFILE_COMMUNITY_HINT_TEXT = '주로 활동하는 커뮤니티를 등록해 주세요.'; + static const PROFILE_GENDER = '성별'; + static const PROFILE_GENDER_MAN = '남성'; + static const PROFILE_GENDER_WOMEN = '여성'; + static const PROFILE_GENDER_OTHER = '무응답'; + static const PROFILE_AGE = '연령대'; + static const PROFILE_AGE_HINT_TEXT = '나이대를 선택해주세요.'; + static const PROFILE_AGE_RANGE = [ + '10대', + '20대', + '30대', + '40대', + '50대', + '60대', + '70대', + '80대', + '90대 이상', + ]; + static const PROFILE_RESIDENCE = '거주지'; + static const PROFILE_RESIDENCE_HINT_TEXT = '거주지를 선택해주세요.'; + static const PROFILE_HOME_TOWN = '출신지'; + static const PROFILE_HOME_TOWN_HINT_TEXT = '출신지를 선택해주세요.'; + static const PROFILE_SAME_TO_RESIDENCE = '거주지와 동일'; + static const PROFILE_CHOICE = '선택하기'; + static const PROFILE_REGISTER = '등록하기'; + static const PROFILE_EMPTY_HINT_TEXT = '내용을 입력해 주세요.'; +} diff --git a/lib/features/splash/presentation/splash_view_model.dart b/lib/features/splash/presentation/splash_view_model.dart index 1afcfa18..acaeb790 100644 --- a/lib/features/splash/presentation/splash_view_model.dart +++ b/lib/features/splash/presentation/splash_view_model.dart @@ -4,6 +4,7 @@ import 'package:debateseason_frontend_v1/core/services/secure_storage_service.da import 'package:debateseason_frontend_v1/core/services/shared_preferences_service.dart'; import 'package:debateseason_frontend_v1/features/splash/domain/app_version_entity.dart'; import 'package:debateseason_frontend_v1/features/splash/domain/app_version_repository.dart'; +import 'package:debateseason_frontend_v1/utils/amplitude_util.dart'; import 'package:debateseason_frontend_v1/utils/de_snack_bar.dart'; import 'package:debateseason_frontend_v1/utils/logger.dart'; import 'package:get/get.dart'; @@ -23,6 +24,7 @@ class SplashViewModel extends GetxController { void onInit() { super.onInit(); + AmplitudeUtil.trackEvent(eventName: 'Splash'); _appVersionRepository = Get.find(); _startSplash(); } @@ -44,11 +46,12 @@ class SplashViewModel extends GetxController { appVersionRes.when( loading: () {}, success: (appVersionRes) { - if (appVersionRes.versionCode > versionCode) { - appVersion.value = appVersionRes; - } else { - determineNextRoute(); - } + // if (appVersionRes.versionCode > versionCode) { + // appVersion.value = appVersionRes; + // } else { + // determineNextRoute(); + // } + determineNextRoute(); }, failure: (msg) { deSnackBar(ErrorConstants.SERVER_ERROR); @@ -70,7 +73,7 @@ class SplashViewModel extends GetxController { if (accessToken.isNotEmpty) { if (termsStatus) { if (profileStatus) { - nextRoute.value = GetRouterName.issuemap; + nextRoute.value = GetRouterName.main; } else { nextRoute.value = GetRouterName.profileInput; } diff --git a/lib/main.dart b/lib/main.dart index 8c048860..c56e5331 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'package:debateseason_frontend_v1/core/routers/get_router_name.dart'; +import 'package:debateseason_frontend_v1/core/services/pip_controller.dart'; import 'package:debateseason_frontend_v1/core/services/shared_preferences_service.dart'; import 'package:debateseason_frontend_v1/features/splash/bindings/splash_binding.dart'; import 'package:firebase_core/firebase_core.dart'; @@ -10,6 +11,7 @@ import 'package:get/get.dart'; import 'core/constants/de_colors.dart'; import 'core/routers/get_router.dart'; +import 'utils/amplitude_util.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -21,10 +23,13 @@ void main() async { await dotenv.load(fileName: '.env.dev'); } else { await dotenv.load(fileName: '.env.prod'); + AmplitudeUtil.init(); } _initUiSettings(); + Get.put(PipController()); + runApp(const MyApp()); } @@ -38,14 +43,12 @@ class MyApp extends StatelessWidget { initialBinding: SplashBinding(), initialRoute: GetRouterName.splash, getPages: GetRouter.getPages, - builder: (context, widget) { - // 시스템 폰트 영향 제거 - return MediaQuery( - data: MediaQuery.of(context) - .copyWith(textScaler: TextScaler.linear(1.0)), - child: widget!, - ); - }, + theme: ThemeData.light().copyWith( + textTheme: ThemeData.light().textTheme.apply( + fontSizeFactor: 1.0, + ), + ), + builder: (context, child) => child!, ); } } diff --git a/lib/utils/amplitude_util.dart b/lib/utils/amplitude_util.dart new file mode 100644 index 00000000..c146a2aa --- /dev/null +++ b/lib/utils/amplitude_util.dart @@ -0,0 +1,57 @@ +import 'package:amplitude_flutter/amplitude.dart'; +import 'package:amplitude_flutter/configuration.dart'; +import 'package:amplitude_flutter/default_tracking.dart'; +import 'package:amplitude_flutter/events/base_event.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +class AmplitudeUtil { + static late Amplitude amplitude; + + static void init() { + amplitude = Amplitude( + Configuration( + apiKey: dotenv.get('AMPLITUDE_API_KEY'), + defaultTracking: DefaultTrackingOptions.all(), + ), + ); + } + + static Future trackEvent({required String eventName}) async { + if (!kDebugMode) { + final event = BaseEvent(eventName); + await amplitude.track(event); + } + } + + static Future trackEventWithProperty({ + required String eventName, + required String propertyName, + required T propertyValue, + }) async { + if (!kDebugMode) { + final event = BaseEvent( + eventName, + eventProperties: {propertyName: propertyValue}, + ); + await amplitude.track(event); + } + } + + static Future trackEventWithProperties({ + required String eventName, + required Map properties, + }) async { + if (!kDebugMode) { + final event = BaseEvent( + eventName, + eventProperties: properties, + ); + await amplitude.track(event); + } + } + + static Future setUserId({required String userId}) async { + await amplitude.setUserId(userId); + } +} diff --git a/lib/widgets/de_bottom_sheet_notitle.dart b/lib/widgets/de_bottom_sheet_notitle.dart new file mode 100644 index 00000000..25e6d9d8 --- /dev/null +++ b/lib/widgets/de_bottom_sheet_notitle.dart @@ -0,0 +1,53 @@ +import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_dimensions.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; +import 'package:debateseason_frontend_v1/widgets/de_gesture_detector.dart'; +import 'package:flutter/material.dart'; + +class DeBottomSheetNoTitle extends StatelessWidget { + final Widget widget; + + const DeBottomSheetNoTitle({ + super.key, + required this.widget, + }); + + @override + Widget build(BuildContext context) { + return DeGestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Container( + padding: EdgeInsets.fromLTRB(24, 0, 24, 16), + decoration: BoxDecoration( + color: DeColors.grey80, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: _widgetGrabber(), + ), + DeGaps.v16, + widget, + ], + ), + ), + ); + } + + Widget _widgetGrabber() { + return Container( + width: 40, + height: 5, + margin: DeDimensions.all8, + decoration: BoxDecoration( + color: DeColors.grey50, + borderRadius: BorderRadius.circular(5), + ), + ); + } +} diff --git a/lib/widgets/de_dialog_with_single_button.dart b/lib/widgets/de_dialog_with_single_button.dart new file mode 100644 index 00000000..319ba29f --- /dev/null +++ b/lib/widgets/de_dialog_with_single_button.dart @@ -0,0 +1,73 @@ +import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_gaps.dart'; +import 'package:debateseason_frontend_v1/widgets/de_button_small.dart'; +import 'package:debateseason_frontend_v1/widgets/de_text.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class DeDialogWithSingleButton { + static Future show({ + bool barrierDismissible = true, + required String dialogTitle, + required List dialogTexts, + required doneText, + VoidCallback? onTapCancel, + required VoidCallback onTapDone, + }) { + return Get.dialog( + barrierDismissible: barrierDismissible, + Dialog( + backgroundColor: DeColors.grey80, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + padding: EdgeInsets.fromLTRB(16, 20, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DeText(dialogTitle, style: DeFonts.header18Sb), + DeGaps.v12, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: dialogTexts + .map( + (text) => Column(children: [ + DeText( + text, + style: DeFonts.body14M + .copyWith(color: DeColors.grey30), + textAlign: TextAlign.left, + ), + DeGaps.v4, + ]), + ) + .toList(), + ), + ), + DeGaps.v20, + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: DeButtonSmall( + doneText, + textColor: DeColors.brand, + onPressed: () { + onTapDone(); + Get.back(); + }, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/de_pip.dart b/lib/widgets/de_pip.dart new file mode 100644 index 00000000..eb56e29d --- /dev/null +++ b/lib/widgets/de_pip.dart @@ -0,0 +1,119 @@ +import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; +import 'package:debateseason_frontend_v1/core/services/pip_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:youtube_player_flutter/youtube_player_flutter.dart'; + +class DePip extends StatelessWidget { + const DePip({super.key}); + + @override + Widget build(BuildContext context) { + final pipController = Get.find(); + final screenSize = MediaQuery.of(context).size; + final pipWidth = screenSize.width * 0.5; + final pipHeight = pipWidth * 9 / 16; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (pipController.showPip.value) { + pipController.pipSize = Size(pipWidth, pipHeight); + } + }); + + return Obx(() { + if (!pipController.showPip.value || + pipController.youtubePlayerController == null) { + return const SizedBox.shrink(); + } + + final offset = pipController.pipOffset.value; + + return AnimatedPositioned( + duration: const Duration(milliseconds: 200), + top: offset.dy, + left: offset.dx, + child: GestureDetector( + onPanUpdate: (details) { + pipController.updateOffset(details.delta); + }, + onPanEnd: (_) { + pipController.snapToCorner(screenSize, Size(pipWidth, pipHeight)); + }, + child: Container( + width: pipWidth, + height: pipHeight, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(12), + ), + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: YoutubePlayer( + controller: pipController.youtubePlayerController!, + showVideoProgressIndicator: false, + bottomActions: const [], + ), + ), + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + pipController.showControls(); + }, + ), + ), + Align( + alignment: Alignment.center, + child: Obx(() { + if (!pipController.isControlVisible.value) { + return const SizedBox.shrink(); + } + return IconButton( + icon: Icon( + pipController.youtubePlayerController!.value.isPlaying + ? Icons.pause + : Icons.play_arrow, + color: DeColors.grey10, + size: 40, + ), + onPressed: () { + final ctrl = pipController.youtubePlayerController!; + ctrl.value.isPlaying ? ctrl.pause() : ctrl.play(); + }, + ); + }), + ), + Positioned( + top: 4, + right: 4, + child: Obx(() { + if (!pipController.isControlVisible.value) { + return const SizedBox.shrink(); + } + return GestureDetector( + onTap: pipController.hide, + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.black54, + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: 16, + ), + ), + ); + }), + ), + ], + ), + ), + ), + ); + }); + } +} diff --git a/lib/widgets/de_tab_short.dart b/lib/widgets/de_tab_short.dart new file mode 100644 index 00000000..dee53535 --- /dev/null +++ b/lib/widgets/de_tab_short.dart @@ -0,0 +1,75 @@ +import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; +import 'package:flutter/material.dart'; + +class DeTabShort extends StatefulWidget { + final List tabs; + final List tabViews; + + const DeTabShort({ + super.key, + required this.tabs, + required this.tabViews, + }) : assert(tabs.length == tabViews.length, "tabs와 tabViews의 길이가 같아야 함."); + + @override + State createState() => _DeTabShortState(); +} + +class _DeTabShortState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: widget.tabs.length, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _tabBar(), + Expanded(child: _tabBarView()), + ], + ); + } + + Widget _tabBar() { + return TabBar( + controller: _tabController, + isScrollable: true, + indicator: BoxDecoration( + borderRadius: BorderRadius.circular(0), + border: Border( + bottom: BorderSide(color: DeColors.grey10, width: 2), + ), + ), + dividerColor: DeColors.grey100, + dividerHeight: 1, + labelColor: DeColors.grey10, + labelStyle: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + labelPadding: const EdgeInsets.only(right:16), + unselectedLabelColor: DeColors.grey50, + unselectedLabelStyle: + TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + enableFeedback: false, + splashFactory: NoSplash.splashFactory, + tabAlignment: TabAlignment.start, + tabs: widget.tabs.map((tabTitle) => Tab(text: tabTitle)).toList(), + ); + } + + Widget _tabBarView() { + return TabBarView( + controller: _tabController, + children: widget.tabViews, + ); + } +} \ No newline at end of file diff --git a/lib/widgets/de_text_field.dart b/lib/widgets/de_text_field.dart index 58e83f08..817d87b7 100644 --- a/lib/widgets/de_text_field.dart +++ b/lib/widgets/de_text_field.dart @@ -1,7 +1,7 @@ import 'package:debateseason_frontend_v1/core/constants/de_colors.dart'; import 'package:debateseason_frontend_v1/core/constants/de_dimensions.dart'; -import 'package:debateseason_frontend_v1/core/constants/de_icons.dart'; import 'package:debateseason_frontend_v1/core/constants/de_fonts.dart'; +import 'package:debateseason_frontend_v1/core/constants/de_icons.dart'; import 'package:debateseason_frontend_v1/widgets/de_gesture_detector.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -27,6 +27,7 @@ class DeTextField extends StatelessWidget { final List? inputFormatters; final bool? enabled; final bool isCleanIcon; + final TextAlignVertical? textAlignVertical; const DeTextField({ super.key, @@ -49,6 +50,7 @@ class DeTextField extends StatelessWidget { this.inputFormatters, this.enabled, this.isCleanIcon = true, + this.textAlignVertical, }); @override @@ -88,8 +90,7 @@ class DeTextField extends StatelessWidget { onTap: () => controller.clear(), child: Padding( padding: DeDimensions.all10, - child: SvgPicture.asset( - DeIcons.icXGrey50), + child: SvgPicture.asset(DeIcons.icXGrey50), ), ) : null) @@ -118,6 +119,7 @@ class DeTextField extends StatelessWidget { }, inputFormatters: inputFormatters, enabled: enabled, + textAlignVertical: textAlignVertical, ), ); } diff --git a/pubspec.lock b/pubspec.lock index 762c914a..c64a6fb1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -14,6 +14,14 @@ packages: description: dart source: sdk version: "0.3.3" + amplitude_flutter: + dependency: "direct main" + description: + name: amplitude_flutter + sha256: b21110afd236d4fd597e29debfd6fac28adfe344178401e0c85cea33ac9de8dd + url: "https://pub.dev" + source: hosted + version: "4.3.1" analyzer: dependency: transitive description: @@ -597,6 +605,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.5" + html_unescape: + dependency: "direct main" + description: + name: html_unescape + sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3" + url: "https://pub.dev" + source: hosted + version: "2.0.0" http: dependency: "direct main" description: @@ -1474,6 +1490,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + youtube_player_flutter: + dependency: "direct main" + description: + name: youtube_player_flutter + sha256: "4d14aa47f9c84929b5400a87ade4dcfdab87a2ca2e0b18ecc2ef852b1440e123" + url: "https://pub.dev" + source: hosted + version: "9.1.1" sdks: dart: ">=3.6.0 <3.7.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1d6b736b..bc91e6c8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: debateseason_frontend_v1 description: "A new Flutter project." -version: 1.0.0+19 +version: 1.1.1+30 environment: sdk: '>=3.5.3 <3.7.0' @@ -33,19 +33,22 @@ dependencies: flutter_cache_manager: ^3.4.1 any_link_preview: ^3.0.3 flutter_linkify: ^6.0.0 + webview_flutter: ^4.10.0 + youtube_player_flutter: ^9.1.1 # Login firebase_core: ^3.10.0 kakao_flutter_sdk_user: ^1.9.7+3 sign_in_with_apple: ^6.1.4 # ETC.. intl: ^0.20.1 - package_info_plus: ^8.1.3 logger: ^2.5.0 connectivity_plus: ^6.1.3 change_app_package_name: ^1.4.0 url_launcher: ^6.3.1 - webview_flutter: ^4.10.0 - + html_unescape: ^2.0.0 + #GA + amplitude_flutter: ^4.3.1 + package_info_plus: ^8.1.3 # splash, icon flutter_native_splash: ^2.4.4