From 78736f6660b2df6964f909e5ba95b7529e739c00 Mon Sep 17 00:00:00 2001 From: Choi Sang Rok Date: Thu, 7 Sep 2023 23:53:55 +0900 Subject: [PATCH] =?UTF-8?q?quack=20v1=20->=20v2=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B0=8F=20target=20SDK?= =?UTF-8?q?=2034,=20compose=201.6.1=20=EB=8C=80=EC=9D=91=20(#627)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 팔로잉 페이지 프로필로 이동하기 구현 (#609) * feature/tag_edit 에 quack v2 적용 (#610) * bug: 추가한 태그 화면에서 우측 화살표 크기가 작게 나오는 이슈 해결 * refactor: feature/tagEdit 모듈 Quack v2 로 리펙터링 및 이전 TODO 작업 처리 - 이전 TODO: FavoriteTagSection QuackV2 로 대응 * bug: java.lang.IndexOutOfBoundsException 오류 수정 * bug: bottomSheet 열렸을 시, bottomSheet 를 닫고 화면은 닫지 않음 * feat: placeHolder 에 최대 10자 안내 및 태그 편집 화면에 텍스트 글자 수 제한 * 리뷰 반영(@EvergreenTree97): ImuutableList.copy() 활용 * feature/onboard 에 quack v2 적용 (1차) (#611) * refactor: feature/onboard 모듈 Quack v2 로 리펙터링 및 부수 작업, TODO 처리 * bug: 키보드 활성화 시 다음 버튼이 키보드 위로 올라오지 않는 현상 수정 * chore : develop rebase * Quack 11 로 마이그레이션 및 부수 작업 (#615) * chore: bump versionCode 19 to 25 * chore: target SDK 34 * chore: QuackQuack v10 , foundation 1.6.1 * refactor: PagerState 변경사항에 맞게 적용 * refactor: duckie logo 추가 * refactor: presentation 모듈 Quack v2 로 리펙터링 * bug: 컴파일 오류 수정 - QuackImage -> QuackIcon - QuackAnimatedVisibility import 코드 수정 * bug: AnimatedContent 크래시 오류 수정 1차 - QuackAnimatedContent 사용처 제거 * chore: quack v2 버전 alpha-11 로 올림 * refactor: QuackTopAppBar - Quack V1 으로부터 코드를 가져온 뒤, Quack v2 에 맞춰 코드 수정 * bug: 온보딩 화면에서 발생하는 crash 수정 * QuackLazyVerticalGridTag quack v2 로 전환 * Quack v1 문법을 사용하던 코드 개선 - toggle.kt - QuackSelectableImage.kt - SearchTagScreen.kt * refactor: home 모듈 Quack v2 로 리펙터링 * feat: Divider 에 색상 주입할 수 있도록 처리 * feat: quack v2 적용한 QuackMainTab 각 화면에 적용 * chore: 임시로 비활성화시킨 quack v1 다시 활성화 * refactor: rebase 빌드 오류 해결 * chore : 1.2.0으로 다시 버전 업 * bug: rebase 하면서 누락된 내용 재반영 * feat: 내 프로필, 타 유저 프로필 화면 crash 해결, 태그 편집 화면에서 LazyTag 정리 * Feature/설정 UI 업데이트 및 QuackV2 마이그레이션 (#616) * refactor: UserContentLayout을 재사용 가능한 버튼으로 변경 * feat: 설정 메인 화면 UI 업데이트 * feat: getFieldName extension * feat: 차단해제 비즈니스 로직 * feat: 유저 차단해제 기능 구현 * feat: IgnoreContentLayout 구현 * feat: 시험 차단하기 비즈니스 로직 * feat: 시험 차단하기 UI 구현 * refactor: 설정 페이지 QuackV2로 마이그레이션 * fix: conflict 해결 * fix: migrate QuackV2 * fix: 설정 잘못된 UI 개선 @EvergreenTree97 리뷰 반영 * chore: ./gradlew build --------- Co-authored-by: limsaehyun Co-authored-by: EvergreenTree97 * Refactor/friends migrate quack v2 (#618) * chore: quack-v1 의존성 제거 및 quack-v2 의존성 추가 * refactor: friend quack v2 마이그레이션 * Refactor/search migrate quack v2 (#619) * chore: quack-v1 의존성 제거 및 quack-v2 의존성 추가 * refactor: search quack v2 마이그레이션 * fix: 인기 태그에 FlowRow 적용 * Refactor/notification migrate quack v2 (#620) * fix: QuackTopAppBar icon type QuackV1Icon to ImageVector * refactor: migrate new QuackTopAppBar * chore: quack-v1 의존성 제거 및 quack-v2 의존성 추가 * feat: UI작동을 위한 최소한의 비즈니스 로직(notifications) 처리 * refactor: migrate notification quack v2 * refactor: lint style 적용 * 문제 시작 - 풀기 - 결과 화면 mirgation (#622) * refactor: start-exam migration * refactor: solve-problem migration * fix: FlexibleSubjectiveQuestionSection padding 적용 * chore: bump compose-foundation 1.6.0-alpha01 to 1.6.0-alpha03 * fix: flexibleSubjectiveQuestion 분기 잘못되어있던 문제 수정 * Refactor/exam result migrate (#621) * Refactor/friends migrate quack v2 (#618) * chore: quack-v1 의존성 제거 및 quack-v2 의존성 추가 * refactor: friend quack v2 마이그레이션 * Refactor/search migrate quack v2 (#619) * chore: quack-v1 의존성 제거 및 quack-v2 의존성 추가 * refactor: search quack v2 마이그레이션 * fix: 인기 태그에 FlowRow 적용 * Refactor/notification migrate quack v2 (#620) * fix: QuackTopAppBar icon type QuackV1Icon to ImageVector * refactor: migrate new QuackTopAppBar * chore: quack-v1 의존성 제거 및 quack-v2 의존성 추가 * feat: UI작동을 위한 최소한의 비즈니스 로직(notifications) 처리 * refactor: migrate notification quack v2 * refactor: lint style 적용 * refactor: start-exam migration * refactor: solve-problem migration * refactor: exam-result migration quackv2 * chore: delete quack-v1, add quack-v2 dependency * feat: QuackExpandedClickable fix: vertical, horizontal 파라미터 분리 * feat: QuackTopAppBar expendedClickable 적용 --------- Co-authored-by: EvergreenTree97 * refactor: gradlew build * feat: 이미지 로딩 구현 * refactor: gradlew build * refactor: ./gradlew build * refactor: 카카오로그인 로직 수정 * fix: 이미지 비율 최적화 --------- Co-authored-by: limsaehyun * fix: QuackTopAppBar로 인해 빌드 안되는 오류 수정 (#624) * feat: infinite rolling banner 구현 및 에러 수정 (#625) * 꽥 마이그레이션으로 인해 생긴 부수효과들 수정 (#626) * fix : 검색화면 디자인에 맞게 * feat: secondary button 임시 * fix: 프로필 디자인 안맞는 부분 수 * fix: 시험 시작 버튼 디자인 수정 * fix: 온보딩 버튼 크기 변경 * fix: 명예의전당 QuackTab으로 변경 * refactor: quacktab lagacy 제거 * refactor: gradlew build * chore: rebase missing code * v1.3.0 quack2 migration으로 인한 문제 1차 수정 (#628) * feat: 검색 화면에 시험 차단 가능 추가 * fix: 마이페이지 exam row 가려지던 문제 수정 * fix: 마이페이지 프로필 등록 사진 돌아가는 문제 수정 * fix: 탈퇴하기 크래시 해결 * refactor: 재웅님 QA 반영 (#629) * 진행중 UI 작업 1차 (홈 화면, 문제 생성 모듈명 변경) (#631) * feat: 피처플래그 초기 틀 작성 * feat: 홈 화면 상단 탭에 notice 버튼 추가 * feat: 진행중 탭 초기 구현 * chore: create-problem -> create-exam * bug: ./gradlew build fix * refactor: create-exam 모듈 quack v2 로 리펙터링 (#632) * feature/create-exam 에 quack v2 적용 (2차) (#633) * refactor: QuackDropDownCard 도 꽥꽥 v2 로 리펙터링 & quack v1 종속성 완전 삭제 * bug: TopAppBar leadingIcon 사이즈 고정 (가로 세로 44dp, 패딩 10dp) * refactor: 문제 항목 레이아웃 파일 별로 분리 * bug: ./gradlew build fix * Quack 1.3.1 2차 수정 (#630) * refactor: QuackErrorableTextField 구현 * fix: 상태 분기 잘못되어있던 문제 수정 * chore: lint formating --------- Co-authored-by: limsaehyun * Qa/migration 1.3.0 (#634) * refator: HorizntalPager auto scroll 포인터 유지 * refactor: profile onresume 이슈 * refactor: searchText 따로 StateFlow로 빼기 * refactor: pagingSource refresh key 설정 * 개발중이었던 기능들 롤백 (#639) * QA/ 1.3.0 3차. QA (#635) * refactor: 도로님 디자인 qa 반영 * feat: remove suit in search * fix: design qa * fix: typography design qa * WIP * fix: 에러 처리에서 KakaoCancelled 예외 처리 * chore: lint formating * Feature/검색 인기 태그 text focusing 제거 (#644) * fix: 인기 태그로 검색 진입 시 autoFocusing 예외 처리 * chore: lint formating * chore: update compose-foundation 1.6.0-alpha03 to 1.6.0-alpha04 (#646) * fix: pager 중간에 둔 후 탭 이동하고 돌아와도 페이지로 유지 * fix: 알림 화면 TODO 처리 * feat: 시험 시작 화면 문구 추가 * chore: upgrade versioncode * fix: 프로필 및 온보딩 패딩 조정 * fix: reycle 함수 두 번 사용하는 문제 해결 * chore: upgrade versioncode * fix: 프로필 크래시 수정 * chore: upgrade versioncode * fix: 응시자수 decimal format 적용 * feat: 키보드 이외 영역 터치 시 키보드 내려가는 기능 추가 * feat: 영역 클릭 시 키보드 내려가는 함수 추가 * fix: 잘못된 패딩 * feat: 검색화면 포커스 문제, 퀴즈 화면 패딩 수정 * feat: 시험 시작 화면 문구 수정 * fix: 전체보기 패딩 해결 * fix: 문제 풀기 패딩 해결 * fix: 검색 결과 화면 포커스 해결 * chore: versioncode upgrade * fix: QuackReactionTextArea constraints * fix: 재웅님 QA 반영 * chore: lint formating * refactor: 알림 화면 구현 준비중으로 대체 * chore: versionCode upgrade --------- Co-authored-by: 임세현 Co-authored-by: ricky_0_k --- .idea/vcs.xml | 1 - app/build.gradle.kts | 2 +- .../convention/ApplicationConstants.kt | 6 +- .../android/common/android/image/MediaUtil.kt | 60 +- .../common/android/timer/ProblemTimer.kt | 4 + .../android/common/android/ui/const/Extras.kt | 3 + .../app/android/common/compose/Keyboard.kt | 22 + .../app/android/common/compose/PagerState.kt | 4 + .../android/common/compose/PagingItemsKey.kt | 8 + .../app/android/common/compose/constants.kt | 9 + .../ui/BackPressedHeadLineTopAppBar.kt | 5 + .../app/android/common/compose/ui/Divider.kt | 25 + .../common/compose/ui/DuckExamCoverItem.kt | 5 +- .../ui/DuckieHorizontalPagerIndicator.kt | 16 +- .../android/common/compose/ui/EmptyText.kt | 1 + .../common/compose/ui/FavoriteTagSection.kt | 35 +- .../android/common/compose/ui/PhotoPicker.kt | 15 +- .../common/compose/ui/TextTabLayout.kt | 4 +- .../compose/ui/content/BasicContentLayout.kt | 166 +++++ .../compose/ui/content/IgnoreContentLayout.kt | 126 ++++ .../compose/ui/content/UserFollowingLayout.kt | 70 +++ .../ui/domain/DuckieTagAddBottomSheet.kt | 18 +- .../common/compose/ui/icon/v1/DuckieIcon.kt | 17 +- .../common/compose/ui/icon/v2/Clock.kt | 72 +++ .../ui/quack/QuackNoUnderlineTextField.kt | 32 +- .../compose/ui/quack/QuackProfileImage.kt | 12 +- .../compose/ui/quack/todo/QuackCircleTag.kt | 21 +- .../ui/quack/todo/QuackDropDownCard.kt | 92 +++ .../ui/quack/todo/QuackErrorableTextField.kt | 202 ++++++ .../ui/quack/todo/QuackLazyVerticalGridTag.kt | 120 +++- .../ui/quack/todo/QuackReactionTextArea.kt | 41 +- .../ui/quack/todo/QuackSelectableImage.kt | 116 +++- .../ui/quack/todo/QuackSingeLazyRowTag.kt | 118 +++- .../compose/ui/quack/todo/QuackSurface.kt | 112 ++++ .../compose/ui/quack/todo/QuackTopAppBar.kt | 351 ++++++++++- .../ui/quack/todo/animation/AnimationSpec.kt | 64 ++ .../compose/ui/quack/todo/animation/toggle.kt | 585 ++++++++++++++++++ .../compose/ui/screen/SearchTagScreen.kt | 10 +- .../ui/temp/TempFlexiblePrimaryLargeButton.kt | 66 ++ .../temp/TempFlexibleSecondaryLargButton.kt | 56 ++ .../common/compose/util/ExpendedClickable.kt | 47 -- .../common/compose/util/HandleKeyBoard.kt | 17 + .../src/main/res/drawable/ic_clock_12.xml | 9 - .../compose/src/main/res/values/strings.xml | 3 +- .../common/kotlin/exception/constant.kt | 5 + .../app/android/common/kotlin/number.kt | 14 + .../app/android/core/datastore/datastore.kt | 4 + .../app/android/data/exam/mapper/mapper.kt | 23 + .../data/exam/model/ExamMeBlocksResponse.kt | 27 + .../exam/paging/ProfileExamPagingSource.kt | 4 +- .../exam/repository/ExamRepositoryImpl.kt | 25 + .../paging/ProfileExamInstancePagingSource.kt | 4 +- .../kakao/repository/KakaoRepositoryImpl.kt | 31 +- .../data/me/repository/MeRepositoryImpl.kt | 19 + .../data/notification/mapper/mapper.kt | 5 +- .../search/paging/SearchExamPagingSource.kt | 4 +- .../android/data/terms/mapper/data2domain.kt | 2 +- .../data/user/datasource/UserDataSource.kt | 3 + .../datasource/UserRemoteDataSourceImpl.kt | 10 + .../android/data/user/mapper/data2domain.kt | 22 + .../data/user/model/UserBlockResponse.kt | 15 + .../data/user/model/UserMeIgnoreResponse.kt | 24 + .../user/repository/UserRepositoryImpl.kt | 5 + .../exam/model/DeleteExamBlockResponse.kt | 15 + .../android/domain/exam/model/ExamBlock.kt | 12 + .../android/domain/exam/model/IgnoreExam.kt | 18 + .../domain/exam/repository/ExamRepository.kt | 5 + .../exam/usecase/CancelExamIgnoreUseCase.kt | 22 + .../exam/usecase/GetExamIgnoresUseCase.kt | 22 + .../app/android/domain/me/MeRepository.kt | 2 + .../me/usecase/GetAllFeatureFlagsUseCase.kt | 22 + .../domain/notification/model/Notification.kt | 7 +- .../android/domain/user/model/IgnoreUser.kt | 16 + .../android/domain/user/model/UserBlock.kt | 12 + .../domain/user/repository/UserRepository.kt | 3 + .../user/usecase/FetchIgnoreUsersUseCase.kt | 23 + .../build.gradle.kts | 5 +- .../src/main/AndroidManifest.xml | 2 +- .../create/exam/CreateExamActivity.kt} | 59 +- .../exam/common/CreateExamBottomLayout.kt} | 62 +- .../exam}/common/FadeAnimatedVisibility.kt | 2 +- .../create/exam}/common/FindTagItem.kt | 13 +- .../create/exam}/common/NoLazyGridItems.kt | 2 +- .../create/exam}/common/TextfieldOptions.kt | 2 +- .../create/exam}/common/TitleAndComponent.kt | 4 +- .../feature/create/exam}/common/TopAppBar.kt | 25 +- .../create/exam/common/type/Constant.kt | 10 + .../exam/common/type/ImageChoiceLayout.kt | 196 ++++++ .../exam/common/type/ShortAnswerLayout.kt | 65 ++ .../exam/common/type/TextChoiceLayout.kt | 129 ++++ .../create/exam/common/type/TitleView.kt | 74 +++ .../impl/CreateProblemNavigatorImpl.kt | 6 +- .../module/CreateProblemNavigatorModule.kt | 4 +- .../exam}/screen/AdditionalInfoScreen.kt | 129 ++-- .../create/exam/screen/CreateExamScreen.kt} | 436 ++----------- .../create/exam/screen/CreateProblemScreen.kt | 42 ++ .../exam}/screen/ExamInformationScreen.kt | 145 +++-- .../create/exam}/screen/SearchTagScreen.kt | 48 +- .../exam}/viewmodel/CreateProblemViewModel.kt | 114 ++-- .../sideeffect/CreateProblemSideEffect.kt | 6 +- .../viewmodel/state/CreateProblemState.kt | 6 +- .../viewmodel/state/CreateProblemStep.kt | 4 +- .../src/main/res/values/strings.xml | 0 .../feature/detail/common/BottomContent.kt | 6 +- .../feature/detail/common/DetailContent.kt | 11 +- .../feature/detail/common/TopCustomBar.kt | 6 +- .../detail/screen/quiz/QuizDetailScreen.kt | 1 + feature/exam-result/build.gradle.kts | 2 +- .../feature/exam/result/ExamResultActivity.kt | 3 +- .../exam/result/common/LoadingIndicator.kt | 27 - .../exam/result/common/ResultBottomBar.kt | 24 +- .../exam/result/screen/ExamResultScreen.kt | 18 +- .../result/screen/ExamResultShareScreen.kt | 10 +- .../result/screen/quiz/QuizResultContent.kt | 21 +- .../screen/wronganswer/ChallengeComment.kt | 12 +- .../ChallengeCommentBottomSheetContent.kt | 2 +- .../wronganswer/ChallengeCommentSection.kt | 5 +- .../result/viewmodel/ExamResultViewModel.kt | 34 + feature/friends/build.gradle.kts | 3 +- .../feature/friends/FriendsActivity.kt | 8 +- .../android/feature/friends/FriendsScreen.kt | 62 +- feature/home/build.gradle.kts | 2 +- .../home/component/BaseBottomLayout.kt | 8 +- .../component/DuckTestBottomNavigation.kt | 40 +- .../home/component/HeadLineTopAppBar.kt | 2 +- .../feature/home/component/HomeTopAppBar.kt | 31 +- .../feature/home/constants/HomeStep.kt | 5 + .../feature/home/screen/MainActivity.kt | 21 +- .../android/feature/home/screen/MainScreen.kt | 9 +- .../screen/guide/HomeGuideFeatureScrren.kt | 45 +- .../home/screen/guide/HomeGuideScreen.kt | 56 +- .../home/screen/home/HomeProceedScreen.kt | 526 ++++++++++++++++ .../home/HomeRecommendFollowingExamScreen.kt | 70 ++- .../home/HomeRecommendFollowingScreen.kt | 24 +- .../home/screen/home/HomeRecommendScreen.kt | 80 ++- .../feature/home/screen/home/HomeScreen.kt | 26 +- .../home/screen/mypage/MypageScreen.kt | 4 +- .../home/screen/ranking/ExamSection.kt | 16 +- .../home/screen/ranking/ExamineeSection.kt | 21 +- .../home/screen/ranking/RankingScreen.kt | 35 +- .../home/screen/search/SearchMainScreen.kt | 72 ++- .../feature/home/viewmodel/MainSideEffect.kt | 6 +- .../feature/home/viewmodel/MainViewModel.kt | 13 +- .../feature/home/viewmodel/home/HomeState.kt | 6 +- .../home/viewmodel/home/HomeViewModel.kt | 42 +- .../feature/home/viewmodel/mapper/mapper.kt | 1 + .../src/main/res/drawable/home_ic_notice.png | Bin 0 -> 1267 bytes .../drawable/home_proceed_banner_right.png | Bin 0 -> 54772 bytes feature/home/src/main/res/values/strings.xml | 12 + feature/notification/build.gradle.kts | 3 +- .../notification/NotificationActivity.kt | 11 +- .../notification/screen/NoticationScreen.kt | 79 ++- .../viewmodel/NotificationViewModel.kt | 40 +- feature/onboard/build.gradle.kts | 4 +- .../feature/onboard/OnboardActivity.kt | 11 +- .../onboard/common/OnboardTopAppBar.kt | 19 +- .../onboard/common/TitleAndDescription.kt | 4 +- .../feature/onboard/screen/0_LoginScreen.kt | 58 +- .../feature/onboard/screen/1_ProfileScreen.kt | 289 ++++----- .../onboard/screen/2_CategoryScreen.kt | 81 ++- .../feature/onboard/screen/3_TagScreen.kt | 83 ++- .../onboard/viewmodel/OnboardViewModel.kt | 14 +- .../onboard/src/main/res/values/strings.xml | 3 +- feature/profile/build.gradle.kts | 1 - .../feature/profile/ProfileActivity.kt | 4 +- .../profile/component/EditTopAppBar.kt | 6 +- .../feature/profile/screen/MyProfileScreen.kt | 35 +- .../profile/screen/OtherProfileScreen.kt | 14 +- .../feature/profile/screen/ProfileScreen.kt | 7 +- .../profile/screen/edit/ProfileEditScreen.kt | 74 ++- .../profile/screen/section/ButtonSection.kt | 75 +-- .../profile/screen/section/EditSection.kt | 7 +- .../profile/screen/section/ExamSection.kt | 14 +- .../profile/screen/section/FavoriteSection.kt | 19 +- .../profile/screen/section/ProfileSection.kt | 8 +- .../profile/screen/viewall/ViewAllScreen.kt | 35 +- .../profile/viewmodel/ProfileEditViewModel.kt | 35 +- .../profile/viewmodel/ProfileViewModel.kt | 20 +- .../viewmodel/state/ProfileEditState.kt | 1 - .../profile/src/main/res/values/strings.xml | 1 + feature/search/build.gradle.kts | 5 +- .../feature/search/screen/SearchActivity.kt | 280 ++++++--- .../search/screen/SearchResultScreen.kt | 70 ++- .../feature/search/screen/SearchScreen.kt | 72 ++- .../search/viewmodel/SearchViewModel.kt | 66 +- .../viewmodel/sideeffect/SearchSideEffect.kt | 6 + .../search/viewmodel/state/SearchState.kt | 7 +- feature/setting/build.gradle.kts | 2 +- .../setting/component/SettingContentLayout.kt | 49 +- .../feature/setting/constans/SettingType.kt | 27 +- .../feature/setting/constans/Withdraweason.kt | 2 +- .../screen/SettingAccountInfoScreen.kt | 80 ++- .../feature/setting/screen/SettingActivity.kt | 23 +- .../setting/screen/SettingIgnoreExamScreen.kt | 63 ++ .../setting/screen/SettingIgnoreUserScreen.kt | 65 ++ .../setting/screen/SettingInquiryScreen.kt | 4 +- .../setting/screen/SettingMainPolicyScreen.kt | 5 +- .../setting/screen/SettingMainScreen.kt | 60 +- .../screen/SettingNotificationScreen.kt | 14 +- .../setting/screen/SettingWithdrawScreen.kt | 93 ++- .../setting/viewmodel/SettingViewModel.kt | 51 ++ .../setting/viewmodel/state/SettingState.kt | 9 + .../setting/src/main/res/values/strings.xml | 9 +- feature/solve-problem/build.gradle.kts | 2 +- .../solve/problem/SolveProblemActivity.kt | 12 +- .../solve/problem/answer/AnswerSection.kt | 14 +- .../solve/problem/answer/choice/AnswerBox.kt | 51 +- .../answer/shortanswer/ShortAnswerForm.kt | 2 +- .../feature/solve/problem/common/BottomBar.kt | 49 +- .../FlexibleSubjectiveQuestionSection.kt | 135 ---- .../solve/problem/common/LoadingIndicator.kt | 5 +- .../feature/solve/problem/common/TopBar.kt | 47 +- .../solve/problem/question/QuestionSection.kt | 50 +- .../problem/question/audio/Controller.kt | 37 +- .../solve/problem/question/image/ImageBox.kt | 112 +++- .../problem/question/video/Controller.kt | 37 +- .../solve/problem/question/video/Slider.kt | 10 +- .../solve/problem/screen/QuizScreen.kt | 112 ++-- .../problem/screen/SolveProblemScreen.kt | 98 +-- .../viewmodel/SolveProblemViewModel.kt | 7 +- .../src/main/res/values/strings.xml | 1 + feature/start-exam/build.gradle.kts | 2 +- .../start/exam/screen/StartExamActivity.kt | 8 +- .../start/exam/screen/StartExamScreen.kt | 8 +- .../exam/screen/exam/StartExamInputScreen.kt | 39 +- .../exam/screen/quiz/StartQuizInputScreen.kt | 94 ++- .../src/main/res/values/strings.xml | 5 +- feature/tag-edit/build.gradle.kts | 3 +- .../feature/tag/edit/TagEditActivity.kt | 10 +- .../feature/tag/edit/screen/TagEditScreen.kt | 54 +- .../tag/edit/viewmodel/TagEditViewModel.kt | 7 +- gradle/libs.versions.toml | 6 +- presentation/build.gradle.kts | 2 +- .../app/android/presentation/IntroActivity.kt | 14 +- .../presentation/screen/IntroScreen.kt | 38 +- .../main/res/drawable/duckie_text_logo.xml | 46 ++ settings.gradle.kts | 2 +- 237 files changed, 6864 insertions(+), 2583 deletions(-) create mode 100644 common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/BasicContentLayout.kt create mode 100644 common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/IgnoreContentLayout.kt create mode 100644 common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/UserFollowingLayout.kt create mode 100644 common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/icon/v2/Clock.kt create mode 100644 common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackDropDownCard.kt create mode 100644 common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackErrorableTextField.kt create mode 100644 common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSurface.kt create mode 100644 common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/animation/AnimationSpec.kt create mode 100644 common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/animation/toggle.kt create mode 100644 common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/temp/TempFlexiblePrimaryLargeButton.kt create mode 100644 common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/temp/TempFlexibleSecondaryLargButton.kt delete mode 100644 common/compose/src/main/res/drawable/ic_clock_12.xml create mode 100644 common/kotlin/src/main/kotlin/team/duckie/app/android/common/kotlin/number.kt create mode 100644 data/src/main/kotlin/team/duckie/app/android/data/exam/model/ExamMeBlocksResponse.kt create mode 100644 data/src/main/kotlin/team/duckie/app/android/data/user/model/UserBlockResponse.kt create mode 100644 data/src/main/kotlin/team/duckie/app/android/data/user/model/UserMeIgnoreResponse.kt create mode 100644 domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/DeleteExamBlockResponse.kt create mode 100644 domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/ExamBlock.kt create mode 100644 domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/IgnoreExam.kt create mode 100644 domain/src/main/kotlin/team/duckie/app/android/domain/exam/usecase/CancelExamIgnoreUseCase.kt create mode 100644 domain/src/main/kotlin/team/duckie/app/android/domain/exam/usecase/GetExamIgnoresUseCase.kt create mode 100644 domain/src/main/kotlin/team/duckie/app/android/domain/me/usecase/GetAllFeatureFlagsUseCase.kt create mode 100644 domain/src/main/kotlin/team/duckie/app/android/domain/user/model/IgnoreUser.kt create mode 100644 domain/src/main/kotlin/team/duckie/app/android/domain/user/model/UserBlock.kt create mode 100644 domain/src/main/kotlin/team/duckie/app/android/domain/user/usecase/FetchIgnoreUsersUseCase.kt rename feature/{create-problem => create-exam}/build.gradle.kts (87%) rename feature/{create-problem => create-exam}/src/main/AndroidManifest.xml (89%) rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/CreateProblemActivity.kt => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/CreateExamActivity.kt} (77%) rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/CreateProblemBottomLayout.kt => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/CreateExamBottomLayout.kt} (76%) rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam}/common/FadeAnimatedVisibility.kt (91%) rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam}/common/FindTagItem.kt (59%) rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam}/common/NoLazyGridItems.kt (98%) rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam}/common/TextfieldOptions.kt (92%) rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam}/common/TitleAndComponent.kt (92%) rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam}/common/TopAppBar.kt (71%) create mode 100644 feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/Constant.kt create mode 100644 feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/ImageChoiceLayout.kt create mode 100644 feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/ShortAnswerLayout.kt create mode 100644 feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/TextChoiceLayout.kt create mode 100644 feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/TitleView.kt rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam}/navigator/impl/CreateProblemNavigatorImpl.kt (77%) rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam}/navigator/module/CreateProblemNavigatorModule.kt (79%) rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam}/screen/AdditionalInfoScreen.kt (82%) rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/CreateProblemScreen.kt => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/CreateExamScreen.kt} (64%) create mode 100644 feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/CreateProblemScreen.kt rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam}/screen/ExamInformationScreen.kt (68%) rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam}/screen/SearchTagScreen.kt (81%) rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam}/viewmodel/CreateProblemViewModel.kt (92%) rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam}/viewmodel/sideeffect/CreateProblemSideEffect.kt (84%) rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam}/viewmodel/state/CreateProblemState.kt (95%) rename feature/{create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem => create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam}/viewmodel/state/CreateProblemStep.kt (87%) rename feature/{create-problem => create-exam}/src/main/res/values/strings.xml (100%) delete mode 100644 feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/common/LoadingIndicator.kt create mode 100644 feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeProceedScreen.kt create mode 100644 feature/home/src/main/res/drawable/home_ic_notice.png create mode 100644 feature/home/src/main/res/drawable/home_proceed_banner_right.png create mode 100644 feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingIgnoreExamScreen.kt create mode 100644 feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingIgnoreUserScreen.kt delete mode 100644 feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/FlexibleSubjectiveQuestionSection.kt create mode 100644 presentation/src/main/res/drawable/duckie_text_logo.xml diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 361fe5cb8..35eb1ddfb 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -2,6 +2,5 @@ - \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aac53f2f3..4d94f3921 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -95,7 +95,7 @@ dependencies { projects.feature.examResult, projects.feature.solveProblem, projects.feature.notification, - projects.feature.createProblem, + projects.feature.createExam, projects.feature.startExam, projects.feature.detail, projects.feature.profile, diff --git a/build-logic/src/main/kotlin/team/duckie/app/android/convention/ApplicationConstants.kt b/build-logic/src/main/kotlin/team/duckie/app/android/convention/ApplicationConstants.kt index ed3e1a391..3179eaea9 100644 --- a/build-logic/src/main/kotlin/team/duckie/app/android/convention/ApplicationConstants.kt +++ b/build-logic/src/main/kotlin/team/duckie/app/android/convention/ApplicationConstants.kt @@ -14,9 +14,9 @@ import org.gradle.api.JavaVersion */ internal object ApplicationConstants { const val minSdk = 23 - const val targetSdk = 33 - const val compileSdk = 33 - const val versionCode = 24 + const val targetSdk = 34 + const val compileSdk = 34 + const val versionCode = 30 const val versionName = "1.3.0" val javaVersion = JavaVersion.VERSION_17 } diff --git a/common/android/src/main/kotlin/team/duckie/app/android/common/android/image/MediaUtil.kt b/common/android/src/main/kotlin/team/duckie/app/android/common/android/image/MediaUtil.kt index 89734fe71..45a35c3ee 100644 --- a/common/android/src/main/kotlin/team/duckie/app/android/common/android/image/MediaUtil.kt +++ b/common/android/src/main/kotlin/team/duckie/app/android/common/android/image/MediaUtil.kt @@ -14,6 +14,8 @@ import android.graphics.Matrix import android.media.ExifInterface import android.net.Uri import android.os.Build +import com.google.firebase.crashlytics.FirebaseCrashlytics +import team.duckie.app.android.common.kotlin.AllowMagicNumber import java.io.BufferedInputStream import java.io.File import java.io.FileOutputStream @@ -24,13 +26,13 @@ import java.util.UUID * Media 처리용 유틸 클래스 * // TODO(riflockle7): 추후 ImageUtil 로 바꿀지 고민 */ +@AllowMagicNumber("for MediaUtil") object MediaUtil { private const val maxWidth = 1600 private const val maxHeight = 1600 private const val bitmapQuality = 100 - private const val rotate90 = 90 - private const val rotate180 = 180 - private const val rotate270 = 270 + + private var bitmap: Bitmap? = null /** * 업로드할 이미지를 가져온다. @@ -58,9 +60,9 @@ object MediaUtil { decodeBitmapFromUri(uri, applicationContext, maxSizeLimitEnable)?.apply { compress(Bitmap.CompressFormat.JPEG, bitmapQuality, fos) - recycle() } ?: throw NullPointerException("bitmap 생성 오류") + bitmap = null fos.flush() fos.close() @@ -76,7 +78,6 @@ object MediaUtil { // 인자 값으로 넘어온 입력 스트림을 나중에 사용하기 위해 저장하는 BufferedInputStream 사용 val input = BufferedInputStream(context.contentResolver.openInputStream(uri)) input.mark(input.available()) // 입력 스트림의 특정 위치를 기억 - var bitmap: Bitmap? BitmapFactory.Options().run { if (maxSizeLimitEnable) { @@ -130,23 +131,44 @@ object MediaUtil { ExifInterface(uri.path!!) } - return when ( - exif.getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL, - ) - ) { - ExifInterface.ORIENTATION_ROTATE_90 -> rotateImage(bitmap, rotate90) - ExifInterface.ORIENTATION_ROTATE_180 -> rotateImage(bitmap, rotate180) - ExifInterface.ORIENTATION_ROTATE_270 -> rotateImage(bitmap, rotate270) - else -> bitmap - } + val orientation = exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + return rotateBitmap(bitmap, orientation) } /** 이미지를 회전한다. */ - private fun rotateImage(bitmap: Bitmap, degree: Int): Bitmap? { + private fun rotateBitmap(bitmap: Bitmap, orientation: Int): Bitmap? { val matrix = Matrix() - matrix.postRotate(degree.toFloat()) - return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + when (orientation) { + ExifInterface.ORIENTATION_NORMAL -> return bitmap + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> { + matrix.setRotate(180f) + matrix.postScale(-1f, 1f) + } + + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.setRotate(90f) + matrix.postScale(-1f, 1f) + } + + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f) + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.setRotate(-90f) + matrix.postScale(-1f, 1f) + } + + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f) + else -> return bitmap + } + return try { + Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } catch (e: OutOfMemoryError) { + FirebaseCrashlytics.getInstance().recordException(e) + null + } } } diff --git a/common/android/src/main/kotlin/team/duckie/app/android/common/android/timer/ProblemTimer.kt b/common/android/src/main/kotlin/team/duckie/app/android/common/android/timer/ProblemTimer.kt index 85825c6f3..d138b0eed 100644 --- a/common/android/src/main/kotlin/team/duckie/app/android/common/android/timer/ProblemTimer.kt +++ b/common/android/src/main/kotlin/team/duckie/app/android/common/android/timer/ProblemTimer.kt @@ -44,4 +44,8 @@ class ProblemTimer( fun stop() { timerJob?.cancel() } + + fun setTotalTime(time: Float) { + _remainingTime.value = time + } } diff --git a/common/android/src/main/kotlin/team/duckie/app/android/common/android/ui/const/Extras.kt b/common/android/src/main/kotlin/team/duckie/app/android/common/android/ui/const/Extras.kt index da91eb0d5..cc9bc515b 100644 --- a/common/android/src/main/kotlin/team/duckie/app/android/common/android/ui/const/Extras.kt +++ b/common/android/src/main/kotlin/team/duckie/app/android/common/android/ui/const/Extras.kt @@ -21,6 +21,9 @@ object Extras { const val SearchTag = "ExtraSearchTag" const val StartGuide = "StartGuide" + /** using for SearchActivity */ + const val AutoFocusing = "AutoFocusing" + /** using for FriendsActivity */ const val FriendType = "ExtraFriendType" const val ProfileNickName = "ProfileNickName" diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/Keyboard.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/Keyboard.kt index cfa8b05cd..4d70ace24 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/Keyboard.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/Keyboard.kt @@ -5,17 +5,25 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalMaterialApi::class) + package team.duckie.app.android.common.compose import android.graphics.Rect import android.view.ViewTreeObserver +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.ModalBottomSheetValue import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import team.duckie.app.android.common.kotlin.AllowMagicNumber @@ -63,3 +71,17 @@ fun Modifier.composedWithKeyboardVisibility( .composed { if (keyboardVisible.value) whenKeyboardVisible() else this } .composed { if (!keyboardVisible.value) whenKeyboardHidden() else this } } + +@Composable +fun HideKeyboardWhenBottomSheetHidden(sheetState: ModalBottomSheetState) { + val keyboard = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + val sheetStateFlow = snapshotFlow { sheetState.currentValue } + sheetStateFlow.collect { state -> + if (state == ModalBottomSheetValue.Hidden) { + keyboard?.hide() + } + } + } +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/PagerState.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/PagerState.kt index e28999974..506df36f9 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/PagerState.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/PagerState.kt @@ -31,3 +31,7 @@ suspend inline fun PagerState.moveNextPage(maxPage: Int) { fun PagerState.isCurrentPage(targetIndex: Int): Boolean { return currentPageOffsetFraction == 0f && targetIndex == currentPage } + +fun PagerState.isTargetPage(targetIndex: Int): Boolean { + return targetIndex == targetPage +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/PagingItemsKey.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/PagingItemsKey.kt index c77aa1e9f..f7116edbc 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/PagingItemsKey.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/PagingItemsKey.kt @@ -30,3 +30,11 @@ fun itemsPagingKey( key(index) ?: index } } + +/** + * 서버측 에러로 PK가 중복으로 내려오는 경우 방치 + * + * @return PK + [secondId] 형식의 String + */ +fun Int?.getUniqueKey(secondId: Int): String = + this.toString() + secondId diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/constants.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/constants.kt index 8388c1a38..53d2dde1f 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/constants.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/constants.kt @@ -11,3 +11,12 @@ package team.duckie.app.android.common.compose // 328 x 240 비율에서 이미지의 높이값을 가져올 수 있는 비율값 const val GetHeightRatioW328H240 = 328.toFloat() / 240 + +// 4 x 1 비율에서 이미지의 높이값을 가져올 수 있는 비율값 +const val GetHeightRatioW360H90 = 4.toFloat() / 1 + +// 85 x 63 비율에서 이미지의 높이값을 가져올 수 있는 비율값 +const val GetHeightRatioW85H63 = 85.toFloat() / 63 + +// 129 x 84 비율에서 이미지의 높이값을 가져올 수 있는 비율값 +const val GetHeightRatioW129H84 = 129.toFloat() / 84 diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/BackPressedHeadLineTopAppBar.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/BackPressedHeadLineTopAppBar.kt index 11b69f507..e4c6bc0b6 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/BackPressedHeadLineTopAppBar.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/BackPressedHeadLineTopAppBar.kt @@ -72,6 +72,7 @@ fun BackPressedHeadLineTopAppBar( fun BackPressedHeadLine2TopAppBar( title: String, isLoading: Boolean = false, + trailingContent: (@Composable () -> Unit)? = null, onBackPressed: () -> Unit, ) { BackPressedTopAppBar(onBackPressed = onBackPressed) { @@ -79,5 +80,9 @@ fun BackPressedHeadLine2TopAppBar( modifier = Modifier.skeleton(isLoading), text = title, ) + Spacer(weight = 1f) + if (trailingContent != null) { + trailingContent() + } } } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/Divider.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/Divider.kt index f4cd2cf43..294e60293 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/Divider.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/Divider.kt @@ -54,3 +54,28 @@ fun DuckieDivider( .background(color = color.value), ) } + +/** + * QuackDivider 를 그리기 위한 리소스들을 정의합니다. + */ +private object QuackDividerDefaults { + val Color = QuackColor.Gray3 + val Height = 1.dp +} + +/** + * 덕키에서 사용되는 구분선(divider)을 그립니다. + * + * @param modifier 이 컴포넌트에 사용할 [Modifier] + */ +@Composable +public fun QuackDivider(modifier: Modifier = Modifier) { + with(QuackDividerDefaults) { + Box( + modifier = modifier + .fillMaxWidth() + .height(Height) + .background(color = Color.value), + ) + } +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/DuckExamCoverItem.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/DuckExamCoverItem.kt index 76e2ad1a8..abec7ead4 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/DuckExamCoverItem.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/DuckExamCoverItem.kt @@ -28,6 +28,7 @@ import coil.compose.AsyncImage import team.duckie.app.android.common.compose.R import team.duckie.app.android.common.compose.ui.icon.v1.MoreId import team.duckie.app.android.common.kotlin.runIf +import team.duckie.app.android.common.kotlin.toDecimalFormat import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.icon.QuackIcon @@ -140,7 +141,7 @@ internal fun DuckSmallCoverInternal( horizontalArrangement = Arrangement.spacedBy(2.dp), ) { QuackText( - text = "${stringResource(id = R.string.examinee)} ${duckTestCoverItem.solvedCount}", + text = "${stringResource(id = R.string.examinee)} ${duckTestCoverItem.solvedCount.toDecimalFormat()}", typography = QuackTypography.Body2.change(color = QuackColor.Gray2), ) QuackText( @@ -148,7 +149,7 @@ internal fun DuckSmallCoverInternal( typography = QuackTypography.Body2.change(color = QuackColor.Gray2), ) QuackText( - text = "${stringResource(id = R.string.heart)} ${duckTestCoverItem.heartCount}", + text = "${stringResource(id = R.string.heart)} ${duckTestCoverItem.heartCount.toDecimalFormat()}", typography = QuackTypography.Body2.change(color = QuackColor.Gray2), ) } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/DuckieHorizontalPagerIndicator.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/DuckieHorizontalPagerIndicator.kt index d9be1005a..569c0b04e 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/DuckieHorizontalPagerIndicator.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/DuckieHorizontalPagerIndicator.kt @@ -75,10 +75,20 @@ fun DuckieHorizontalPagerIndicator( Box( Modifier .offset { - val scrollPosition = (pagerState.currentPage + pagerState.currentPageOffsetFraction) - .coerceIn(0f, pagerState.currentPage.coerceAtLeast(0).toFloat()) + val scrollPosition = + (pagerState.currentPage + pagerState.currentPageOffsetFraction) + .coerceIn( + minimumValue = 0f, + maximumValue = pagerState.currentPage + .coerceAtLeast(0) + .toFloat(), + ) IntOffset( - x = ((spacingPx + indicatorWidthPx) * scrollPosition).toInt(), + x = if (pagerState.targetPage == 0) { + 0 + } else { + ((spacingPx + indicatorWidthPx) * scrollPosition).toInt() + }, y = 0, ) } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/EmptyText.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/EmptyText.kt index 98c419036..7fc50805c 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/EmptyText.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/EmptyText.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import team.duckie.quackquack.material.QuackColor diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/FavoriteTagSection.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/FavoriteTagSection.kt index f3b93e2f3..4eb3b04ee 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/FavoriteTagSection.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/FavoriteTagSection.kt @@ -14,16 +14,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import team.duckie.app.android.common.compose.ui.quack.todo.QuackLazyVerticalGridTag -import team.duckie.app.android.common.compose.ui.quack.todo.QuackSingeLazyRowTag import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.ui.QuackText import team.duckie.quackquack.ui.sugar.QuackTitle2 -import team.duckie.quackquack.ui.icon.QuackIcon as QuackV1Icon /** * 관심태그 섹션 @@ -48,10 +47,8 @@ fun FavoriteTagSection( title: String, horizontalPadding: PaddingValues = PaddingValues(0.dp), verticalArrangement: Arrangement.HorizontalOrVertical = Arrangement.spacedBy(0.dp), - // TODO(riflockle7): 다음 작업에서 QuackV2 로 대응하기 - trailingIcon: QuackV1Icon? = null, + trailingIcon: ImageVector? = null, onTrailingClick: ((Int) -> Unit)? = null, - singleLine: Boolean = true, emptySection: @Composable () -> Unit, tags: ImmutableList, onTagClick: (Int) -> Unit, @@ -74,25 +71,15 @@ fun FavoriteTagSection( // 태그가 없을 경우 표시되는 Section emptySection() } else { - if (singleLine) { - // 한 줄로 표현되는 태그 목록 - QuackSingeLazyRowTag( - items = tagList, - contentPadding = horizontalPadding, - tagTypeResId = trailingIcon?.drawableId, - onClick = { index -> onTagClick(index) }, - ) - } else { - // 여러 줄로 표현되는 태그 목록 - QuackLazyVerticalGridTag( - contentPadding = horizontalPadding, - horizontalSpace = 4.dp, - items = tagList, - tagTypeResId = trailingIcon?.drawableId, - onClick = { index -> onTagClick(index) }, - itemChunkedSize = 4, - ) - } + QuackLazyVerticalGridTag( + contentPadding = horizontalPadding, + horizontalSpace = 4.dp, + items = tagList, + trailingIcon = trailingIcon, + onTrailingClick = onTrailingClick, + onClick = { index -> onTagClick(index) }, + itemChunkedSize = 4, + ) } // 추가 버튼 diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/PhotoPicker.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/PhotoPicker.kt index 04bcc156e..45f8e31cd 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/PhotoPicker.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/PhotoPicker.kt @@ -33,7 +33,6 @@ import com.ujizin.camposer.state.rememberCameraState import kotlinx.collections.immutable.ImmutableList import team.duckie.app.android.common.compose.R import team.duckie.app.android.common.compose.ui.icon.v1.CameraId -import team.duckie.app.android.common.compose.ui.icon.v1.CloseId import team.duckie.app.android.common.compose.ui.quack.todo.QuackGridLayout import team.duckie.app.android.common.compose.ui.quack.todo.QuackSelectableImage import team.duckie.app.android.common.compose.ui.quack.todo.QuackTopAppBar @@ -42,6 +41,8 @@ import team.duckie.app.android.common.kotlin.runIf import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.Outlined +import team.duckie.quackquack.material.icon.quackicon.outlined.Close import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.ui.QuackImage import team.duckie.quackquack.ui.QuackText @@ -116,7 +117,11 @@ fun PhotoPicker( Column(modifier = modifier.zIndex(zIndex)) { QuackTopAppBar( - leadingIconResId = QuackIcon.CloseId, + modifier = Modifier.padding( + start = 12.dp, + end = 16.dp, + ), + leadingIcon = QuackIcon.Outlined.Close, onLeadingIconClick = onCloseClick, centerText = stringResource(R.string.topappbar_filter_full), trailingContent = { @@ -128,11 +133,7 @@ fun PhotoPicker( rippleEnabled = false, onClick = onAddClick, ) - } - .padding( - horizontal = 16.dp, - vertical = 15.dp, - ), + }, text = stringResource(R.string.topappbar_add), typography = QuackTypography.Subtitle.change( color = if (isAddable) { diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/TextTabLayout.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/TextTabLayout.kt index d4a7148fe..03cfa56e9 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/TextTabLayout.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/TextTabLayout.kt @@ -23,8 +23,8 @@ import team.duckie.quackquack.ui.QuackText @Composable fun TextTabLayout( titles: ImmutableList, - selectedTabStyle: QuackTypography = QuackTypography.HeadLine2, - tabStyle: QuackTypography = QuackTypography.Title2, + selectedTabStyle: QuackTypography = QuackTypography.HeadLine1, + tabStyle: QuackTypography = QuackTypography.HeadLine2, selectedTabIndex: Int, onTabSelected: (Int) -> Unit, space: Dp = 12.dp, diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/BasicContentLayout.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/BasicContentLayout.kt new file mode 100644 index 000000000..34a333291 --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/BasicContentLayout.kt @@ -0,0 +1,166 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.content + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.asLoose +import team.duckie.app.android.common.compose.centerVerticalWithMaxHeight +import team.duckie.app.android.common.compose.ui.skeleton +import team.duckie.app.android.common.kotlin.fastFirstOrNull +import team.duckie.app.android.common.kotlin.npe +import team.duckie.app.android.common.kotlin.runIf +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.sugar.QuackBody3 +import team.duckie.quackquack.ui.sugar.QuackSubtitle2 + +@Stable +private object UserContentWithButtonDefaults { + const val LeadingImageLayoutId = "LeadingImageLayoutId" + const val TitleLayoutId = "TitleLayoutId" + const val DescriptionLayoutId = "DescriptionLayoutId" + const val TrailingButtonLayoutId = "TrailingButtonLayoutId" +} + +/** + * 해당 버튼을 사용하기 위해선 [trailingButton] 에 modifier 를 넣어야 합니다. + */ +@Composable +internal fun BasicContentWithButtonLayout( + modifier: Modifier = Modifier, + contentId: Int, + nickname: String, + rippleEnabled: Boolean = true, + description: String, + onClickLayout: ((Int) -> Unit)? = null, + visibleTrailingButton: Boolean = false, + isTitleCenter: Boolean = false, + visibleHorizontalPadding: Boolean = true, + leadingImageContent: @Composable (modifier: Modifier) -> Unit, + trailingButton: @Composable (modifier: Modifier) -> Unit, + isLoading: Boolean = false, +) = with(UserContentWithButtonDefaults) { + Layout( + modifier = modifier + .quackClickable( + rippleEnabled = rippleEnabled, + onClick = { + if (onClickLayout != null) { + onClickLayout(contentId) + } + }, + ) + .fillMaxWidth() + .height(56.dp) + .padding(vertical = 12.dp) + .runIf(visibleHorizontalPadding) { + padding(horizontal = 16.dp) + }, + content = { + leadingImageContent( + modifier = modifier + .layoutId(LeadingImageLayoutId) + .skeleton(isLoading), + ) + QuackSubtitle2( + modifier = Modifier + .layoutId(TitleLayoutId) + .padding(start = 8.dp) + .skeleton(isLoading), + text = nickname, + ) + QuackBody3( + modifier = Modifier + .layoutId(DescriptionLayoutId) + .padding(start = 8.dp) + .skeleton(isLoading), + text = description, + ) + trailingButton.invoke( + modifier = modifier + .layoutId(TrailingButtonLayoutId) + .skeleton(isLoading), + ) + }, + measurePolicy = getUserContentLayoutMeasurePolicy( + isTitleCenter = isTitleCenter, + visibleTrailingButton = visibleTrailingButton, + ), + ) +} + +private fun getUserContentLayoutMeasurePolicy( + isTitleCenter: Boolean, + visibleTrailingButton: Boolean, +) = MeasurePolicy { measurables, constraints -> + with(UserContentWithButtonDefaults) { + val extraLooseConstraints = constraints.asLoose(width = true) + + val leadingImagePlaceable = measurables.fastFirstOrNull { measurable -> + measurable.layoutId == LeadingImageLayoutId + }?.measure(extraLooseConstraints) ?: npe() + + val titlePlaceable = measurables.fastFirstOrNull { measurable -> + measurable.layoutId == TitleLayoutId + }?.measure(extraLooseConstraints) ?: npe() + + val descriptionPlaceable = measurables.fastFirstOrNull { measurable -> + measurable.layoutId == DescriptionLayoutId + }?.measure(extraLooseConstraints) ?: npe() + + val trailingButtonPlaceable = measurables.fastFirstOrNull { measurable -> + measurable.layoutId == TrailingButtonLayoutId + }?.measure(extraLooseConstraints) ?: npe() + + layout( + width = constraints.maxWidth, + height = constraints.maxHeight, + ) { + leadingImagePlaceable.place( + x = 0, + y = 0, + ) + + titlePlaceable.place( + x = leadingImagePlaceable.width, + y = if (isTitleCenter) { + constraints.centerVerticalWithMaxHeight(titlePlaceable.height) + } else { + 0 + }, + ) + + descriptionPlaceable.place( + x = leadingImagePlaceable.width, + y = titlePlaceable.height, + ) + + if (visibleTrailingButton) { + trailingButtonPlaceable.place( + x = constraints.maxWidth - trailingButtonPlaceable.width, + y = constraints.centerVerticalWithMaxHeight(trailingButtonPlaceable.height), + ) + } + } + } +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/IgnoreContentLayout.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/IgnoreContentLayout.kt new file mode 100644 index 000000000..ede4f834e --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/IgnoreContentLayout.kt @@ -0,0 +1,126 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +@file:OptIn(ExperimentalQuackQuackApi::class) + +package team.duckie.app.android.common.compose.ui.content + +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.CoverImageRatio +import team.duckie.app.android.common.compose.R +import team.duckie.app.android.common.compose.ui.quack.QuackProfileImage +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackButton +import team.duckie.quackquack.ui.QuackButtonStyle +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi + +private val ProfileImageSize: DpSize = DpSize(32.dp, 32.dp) + +@Composable +fun ExamIgnoreLayout( + modifier: Modifier = Modifier, + examId: Int, + examThumbnailUrl: String, + name: String, + likeNum: Int? = null, + solverNum: Int? = null, + onClickUserProfile: ((Int) -> Unit)? = null, + visibleTrailingButton: Boolean = true, + onClickTrailingButton: (Int) -> Unit, + isLoading: Boolean = false, + rippleEnabled: Boolean = false, +) { + BasicContentWithButtonLayout( + isLoading = isLoading, + modifier = modifier, + contentId = examId, + nickname = name, + onClickLayout = onClickUserProfile, + description = buildString { + append(solverNum ?: "") + append(if (likeNum != null) "· 좋아요 $likeNum" else "") + }, + trailingButton = { + QuackButton( // FIXME(limsaehyun) enabled 버튼 모양이 disabled로 바뀌어버림 + modifier = it.quackClickable { + onClickTrailingButton(examId) + }, + text = stringResource(id = R.string.cancel_igonre), + style = QuackButtonStyle.PrimaryOutlinedSmall, + onClick = { }, + enabled = false, + ) + }, + visibleTrailingButton = visibleTrailingButton, + leadingImageContent = { + QuackImage( + modifier = it + .width(68.dp) + .aspectRatio(CoverImageRatio), + src = examThumbnailUrl, + contentScale = ContentScale.Crop, + ) + }, + isTitleCenter = likeNum == null && solverNum == null, + visibleHorizontalPadding = false, + rippleEnabled = rippleEnabled, + ) +} + +@Composable +fun UserIgnoreLayout( + modifier: Modifier = Modifier, + userId: Int, + profileImageIrl: String, + nickname: String, + favoriteTag: String, + rippleEnabled: Boolean = true, + tier: String, + onClickUserProfile: ((Int) -> Unit)? = null, + visibleTrailingButton: Boolean = true, + onClickTrailingButton: (Int) -> Unit, + isLoading: Boolean = false, +) { + BasicContentWithButtonLayout( + isLoading = isLoading, + modifier = modifier, + contentId = userId, + nickname = nickname, + description = tier + if (favoriteTag.isNotEmpty()) "· $favoriteTag" else "", + onClickLayout = onClickUserProfile, + trailingButton = { + QuackButton( // FIXME(limsaehyun) enabled 버튼 모양이 disabled로 바뀌어버림 + modifier = it.quackClickable { + onClickTrailingButton(userId) + }, + text = stringResource(id = R.string.cancel_igonre), + style = QuackButtonStyle.PrimaryOutlinedSmall, + onClick = { }, + enabled = false, + ) + }, + visibleTrailingButton = visibleTrailingButton, + leadingImageContent = { + QuackProfileImage( + modifier = it, + profileUrl = profileImageIrl, + size = ProfileImageSize, + ) + }, + isTitleCenter = tier.isEmpty() || favoriteTag.isEmpty(), + visibleHorizontalPadding = false, + rippleEnabled = rippleEnabled, + ) +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/UserFollowingLayout.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/UserFollowingLayout.kt new file mode 100644 index 000000000..1fbd390e9 --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/content/UserFollowingLayout.kt @@ -0,0 +1,70 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.content + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.R +import team.duckie.app.android.common.compose.ui.quack.QuackProfileImage +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.modifier.quackClickable + +private val ProfileImageSize: DpSize = DpSize(32.dp, 32.dp) + +@Composable +fun UserFollowingLayout( + modifier: Modifier = Modifier, + userId: Int, + profileImgUrl: String, + nickname: String, + favoriteTag: String, + tier: String, + isFollowing: Boolean, + onClickUserProfile: ((Int) -> Unit)? = null, + visibleTrailingButton: Boolean = false, + onClickTrailingButton: (Boolean) -> Unit, + isLoading: Boolean = false, +) { + BasicContentWithButtonLayout( + isLoading = isLoading, + modifier = modifier, + contentId = userId, + nickname = nickname, + description = tier + if (favoriteTag.isNotEmpty()) "· $favoriteTag" else "", + onClickLayout = onClickUserProfile, + trailingButton = { + QuackText( + modifier = it + .quackClickable( + onClick = { + onClickTrailingButton(!isFollowing) + }, + rippleEnabled = false, + ), + text = stringResource(id = if (isFollowing) R.string.following else R.string.follow), + typography = QuackTypography.Body2.change( + color = if (isFollowing) QuackColor.Gray1 else QuackColor.DuckieOrange, + ), + ) + }, + visibleTrailingButton = visibleTrailingButton, + leadingImageContent = { + QuackProfileImage( + modifier = it, + profileUrl = profileImgUrl, + size = ProfileImageSize, + ) + }, + isTitleCenter = tier.isEmpty() || favoriteTag.isEmpty(), + ) +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/domain/DuckieTagAddBottomSheet.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/domain/DuckieTagAddBottomSheet.kt index b7e2a8aa1..476348a18 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/domain/DuckieTagAddBottomSheet.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/domain/DuckieTagAddBottomSheet.kt @@ -48,11 +48,13 @@ import team.duckie.app.android.common.compose.invisible import team.duckie.app.android.common.compose.rememberToast import team.duckie.app.android.common.compose.systemBarPaddings import team.duckie.app.android.common.compose.ui.ImeSpacer +import team.duckie.app.android.common.compose.ui.QuackDivider import team.duckie.app.android.common.compose.ui.icon.v1.ArrowSendId -import team.duckie.app.android.common.compose.ui.icon.v1.CloseId import team.duckie.app.android.common.compose.ui.quack.QuackNoUnderlineTextField +import team.duckie.app.android.common.compose.ui.quack.blackAndPrimaryColor import team.duckie.app.android.common.compose.ui.quack.todo.QuackCircleTag import team.duckie.app.android.common.kotlin.fastForEachIndexed +import team.duckie.app.android.common.kotlin.takeBy import team.duckie.app.android.domain.tag.model.Tag import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography @@ -64,6 +66,8 @@ import team.duckie.quackquack.ui.QuackText import team.duckie.quackquack.ui.sugar.QuackTitle2 import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi +const val TagTitleMaxLength = 10 + /** * 태그를 추가할 수 있는 [ModalBottomSheetLayout] * primitive type 인 [String] 대신 [Tag] 를 직접 사용한다 @@ -198,7 +202,6 @@ private fun DuckieTagAddBottomSheetContent( QuackCircleTag( text = tag.name, isSelected = false, - trailingIconResId = QuackIcon.CloseId, ) { inputtedTags.remove(inputtedTags[index]) } @@ -215,17 +218,18 @@ private fun DuckieTagAddBottomSheetContent( } } } - - // TODO(riflockle7): 사용해도 괜찮을지 검토 필요 + QuackDivider() QuackNoUnderlineTextField( text = tagInput, - onTextChanged = { tagInput = it }, + onTextChanged = { + tagInput = it.takeBy(TagTitleMaxLength, tagInput) + }, placeholderText = stringResource(R.string.tag_add_manual_placeholder), - startPadding = 16.dp, - trailingEndPadding = 10.dp, trailingIcon = QuackIcon.ArrowSendId, trailingIconOnClick = ::updateTagInput, keyboardActions = KeyboardActions { updateTagInput() }, + trailingIconTint = blackAndPrimaryColor(tagInput).value, + paddingValues = PaddingValues(all = 16.dp), ) ImeSpacer() } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/icon/v1/DuckieIcon.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/icon/v1/DuckieIcon.kt index 4a512684c..96f9db0dc 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/icon/v1/DuckieIcon.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/icon/v1/DuckieIcon.kt @@ -8,8 +8,8 @@ package team.duckie.app.android.common.compose.ui.icon.v1 import team.duckie.app.android.common.compose.R -import team.duckie.quackquack.ui.icon.QuackIcon as QuackV1Icon import team.duckie.quackquack.material.icon.QuackIcon as QuackV2Icon +import team.duckie.quackquack.ui.icon.QuackIcon as QuackV1Icon val QuackV1Icon.Companion.DefaultProfile get() = R.drawable.ic_default_profile val QuackV1Icon.Companion.Notice get() = R.drawable.ic_notice_24 @@ -18,12 +18,12 @@ val QuackV1Icon.Companion.Create get() = R.drawable.ic_create_24 val QuackV1Icon.Companion.Crown get() = R.drawable.ic_crown_12 -val QuackV1Icon.Companion.Clock get() = R.drawable.ic_clock_12 - // Quack V2 의 QuackIcon 을 통해 Quack V1 의 drawable Resource 를 가져오는 확장 변수 val QuackV2Icon.ArrowBackId: Int get() = QuackV1Icon.ArrowBack.drawableId +val QuackV2Icon.ArrowRightId: Int get() = QuackV1Icon.ArrowRight.drawableId + val QuackV2Icon.ArrowSendId: Int get() = QuackV1Icon.ArrowSend.drawableId val QuackV2Icon.CloseId: Int get() = QuackV1Icon.Close.drawableId @@ -36,16 +36,27 @@ val QuackV2Icon.CameraId: Int get() = QuackV1Icon.Camera.drawableId val QuackV2Icon.TextLogoId: Int get() = QuackV1Icon.TextLogo.drawableId +val QuackV2Icon.ProfileId: Int get() = QuackV1Icon.Profile.drawableId + val QuackV2Icon.DefaultProfileId: Int get() = QuackV1Icon.DefaultProfile +val QuackV2Icon.CheckId: Int get() = QuackV1Icon.Check.drawableId + +val QuackV2Icon.CreateId: Int get() = QuackV1Icon.Create + +val QuackV2Icon.AreaId: Int get() = QuackV1Icon.Area.drawableId + val Int.toQuackV1Icon: QuackV1Icon? get() = when (this) { QuackV1Icon.ArrowBack.drawableId -> QuackV1Icon.ArrowBack + QuackV1Icon.ArrowRight.drawableId -> QuackV1Icon.ArrowRight QuackV1Icon.ArrowSend.drawableId -> QuackV1Icon.ArrowSend QuackV1Icon.Close.drawableId -> QuackV1Icon.Close QuackV1Icon.Search.drawableId -> QuackV1Icon.Search QuackV1Icon.More.drawableId -> QuackV1Icon.More QuackV1Icon.Camera.drawableId -> QuackV1Icon.Camera QuackV1Icon.TextLogo.drawableId -> QuackV1Icon.TextLogo + QuackV1Icon.Check.drawableId -> QuackV1Icon.Check + QuackV1Icon.Area.drawableId -> QuackV1Icon.Area else -> null } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/icon/v2/Clock.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/icon/v2/Clock.kt new file mode 100644 index 000000000..9e1f31294 --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/icon/v2/Clock.kt @@ -0,0 +1,72 @@ +package team.duckie.app.android.common.compose.ui.icon.v2 + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import team.duckie.quackquack.material.icon.QuackIcon + +public val QuackIcon.Clock: ImageVector + get() { + if (_vector != null) { + return _vector!! + } + _vector = Builder( + name = "Vector", + defaultWidth = 12.0.dp, + defaultHeight = 12.0.dp, + viewportWidth = 12.0f, + viewportHeight = 12.0f, + ).apply { + path( + fill = SolidColor(Color(0xFF222222)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero, + ) { + moveTo(6.0f, 1.2f) + curveTo(4.727f, 1.2f, 3.5061f, 1.7057f, 2.6059f, 2.6059f) + curveTo(1.7057f, 3.5061f, 1.2f, 4.727f, 1.2f, 6.0f) + curveTo(1.2f, 7.273f, 1.7057f, 8.4939f, 2.6059f, 9.3941f) + curveTo(3.5061f, 10.2943f, 4.727f, 10.8f, 6.0f, 10.8f) + curveTo(7.273f, 10.8f, 8.4939f, 10.2943f, 9.3941f, 9.3941f) + curveTo(10.2943f, 8.4939f, 10.8f, 7.273f, 10.8f, 6.0f) + curveTo(10.8f, 4.727f, 10.2943f, 3.5061f, 9.3941f, 2.6059f) + curveTo(8.4939f, 1.7057f, 7.273f, 1.2f, 6.0f, 1.2f) + close() + moveTo(0.0f, 6.0f) + curveTo(0.0f, 2.6862f, 2.6862f, 0.0f, 6.0f, 0.0f) + curveTo(9.3138f, 0.0f, 12.0f, 2.6862f, 12.0f, 6.0f) + curveTo(12.0f, 9.3138f, 9.3138f, 12.0f, 6.0f, 12.0f) + curveTo(2.6862f, 12.0f, 0.0f, 9.3138f, 0.0f, 6.0f) + close() + moveTo(6.0f, 2.4f) + curveTo(6.1591f, 2.4f, 6.3117f, 2.4632f, 6.4243f, 2.5757f) + curveTo(6.5368f, 2.6883f, 6.6f, 2.8409f, 6.6f, 3.0f) + verticalLineTo(5.7516f) + lineTo(8.2242f, 7.3758f) + curveTo(8.3335f, 7.489f, 8.394f, 7.6405f, 8.3926f, 7.7978f) + curveTo(8.3912f, 7.9552f, 8.3281f, 8.1056f, 8.2169f, 8.2169f) + curveTo(8.1056f, 8.3281f, 7.9552f, 8.3912f, 7.7978f, 8.3926f) + curveTo(7.6405f, 8.394f, 7.489f, 8.3335f, 7.3758f, 8.2242f) + lineTo(5.5758f, 6.4242f) + curveTo(5.4633f, 6.3117f, 5.4f, 6.1591f, 5.4f, 6.0f) + verticalLineTo(3.0f) + curveTo(5.4f, 2.8409f, 5.4632f, 2.6883f, 5.5757f, 2.5757f) + curveTo(5.6883f, 2.4632f, 5.8409f, 2.4f, 6.0f, 2.4f) + close() + } + } + .build() + return _vector!! + } + +private var _vector: ImageVector? = null diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/QuackNoUnderlineTextField.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/QuackNoUnderlineTextField.kt index f9a8a1d01..37e206af5 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/QuackNoUnderlineTextField.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/QuackNoUnderlineTextField.kt @@ -8,6 +8,7 @@ package team.duckie.app.android.common.compose.ui.quack import androidx.annotation.DrawableRes +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -20,8 +21,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -54,6 +55,14 @@ import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.icon.QuackIcon import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackText + +@NonRestartableComposable +@Composable +fun blackAndPrimaryColor(text: String) = animateColorAsState( + targetValue = if (text.isEmpty()) QuackColor.Unspecified.value else QuackColor.DuckieOrange.value, + label = "writeCommentButtonColor", +) @Composable fun QuackNoUnderlineTextField( @@ -62,10 +71,13 @@ fun QuackNoUnderlineTextField( onTextChanged: (text: String) -> Unit, placeholderText: String? = null, paddingValues: PaddingValues = PaddingValues( - vertical = 8.dp, - horizontal = 12.dp, + top = 8.dp, + bottom = 8.dp, + start = 12.dp, + end = 12.dp, ), startPadding: Dp = 0.dp, + leadingIconOnClick: (() -> Unit)? = null, @DrawableRes leadingIcon: Int? = null, trailingEndPadding: Dp = 0.dp, @DrawableRes trailingIcon: Int? = null, @@ -118,6 +130,7 @@ fun QuackNoUnderlineTextField( trailingIconOnClick = trailingIconOnClick, trailingIconSize = trailingIconSize, trailingIconTint = trailingIconTint, + leadingIconOnClick = leadingIconOnClick, ) }, ) @@ -135,13 +148,13 @@ private fun TextFieldDecoration( textField: @Composable () -> Unit, isPlaceholder: Boolean, placeholderText: String?, - @DrawableRes - leadingIcon: Int?, + @DrawableRes leadingIcon: Int?, trailingIcon: Int?, startPadding: Dp = 0.dp, leadingEndPadding: Dp = 0.dp, trailingStartPadding: Dp = 16.dp, trailingIconSize: Dp = 24.dp, + leadingIconOnClick: (() -> Unit)? = null, trailingIconOnClick: (() -> Unit)?, trailingIconTint: Color = Color.Unspecified, ) = with(TextFieldDecorationLayoutId) { @@ -155,7 +168,7 @@ private fun TextFieldDecoration( .size(DpSize(16.dp, 16.dp)) .padding(end = leadingEndPadding) .quackClickable( - onClick = trailingIconOnClick, + onClick = leadingIconOnClick, rippleEnabled = false, ) .padding(end = 16.dp), @@ -163,15 +176,14 @@ private fun TextFieldDecoration( ) } if (isPlaceholder && placeholderText != null) { - Text( + QuackText( modifier = Modifier .layoutId(PlaceholderId) .padding(start = startPadding), text = placeholderText, - style = QuackTypography.Body1.asComposeStyle().copy( - color = QuackColor.Gray2.value, + typography = QuackTypography.Body1.change( + color = QuackColor.Gray2, ), - maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, ) diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/QuackProfileImage.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/QuackProfileImage.kt index d6039b704..11c1156ff 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/QuackProfileImage.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/QuackProfileImage.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp import coil.ImageLoader import coil.compose.rememberAsyncImagePainter import team.duckie.app.android.common.compose.R +import team.duckie.app.android.common.kotlin.runIf import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.material.shape.SquircleShape @@ -60,12 +61,11 @@ fun QuackProfileImage( modifier = modifier .size(size) .clip(shape) - .quackClickable( - rippleEnabled = false, - ) { - if (onClick != null) { - onClick() - } + .runIf(onClick != null) { + quackClickable( + rippleEnabled = true, + onClick = onClick, + ) }, painter = asyncImagePainter, contentScale = contentScale, diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackCircleTag.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackCircleTag.kt index 0871986fd..a0b76a0fb 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackCircleTag.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackCircleTag.kt @@ -5,25 +5,30 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalQuackQuackApi::class) + package team.duckie.app.android.common.compose.ui.quack.todo import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import team.duckie.app.android.common.compose.ui.icon.v1.toQuackV1Icon +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.ui.QuackTag +import team.duckie.quackquack.ui.QuackTagStyle +import team.duckie.quackquack.ui.trailingIcon +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi @Composable fun QuackCircleTag( modifier: Modifier = Modifier, text: String, - trailingIconResId: Int? = null, isSelected: Boolean, onClick: (() -> Unit)? = null, ) { - team.duckie.quackquack.ui.component.QuackCircleTag( - modifier = modifier, + QuackTag( text = text, - trailingIcon = trailingIconResId?.toQuackV1Icon, - isSelected = isSelected, - onClick = onClick, - ) + style = QuackTagStyle.Outlined, + modifier = modifier.trailingIcon(OutlinedGroup.Close, onClick = onClick ?: {}), + selected = isSelected, + ) {} } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackDropDownCard.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackDropDownCard.kt new file mode 100644 index 000000000..7fe615f85 --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackDropDownCard.kt @@ -0,0 +1,92 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.quack.todo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.kotlin.runIf +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.ArrowDown +import team.duckie.quackquack.material.quackBorder +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.QuackText + +/** + * 덕키에서 Drop Down 을 표시하는 컴포넌트를 구현합니다. + * [QuackDropDownCard] 는 다음과 같은 특징을 갖습니다. + * + * - 항상 trailing content 로 [QuackIcon.ArrowDown] 을 갖습니다. + * - 단순 rounding 형태의 Card 를 갖기 때문에 이름에 Card 가 추가됐습니다. + * + * @param modifier 이 컴포넌트에 적용할 [Modifier] + * @param text 표시할 텍스트 + * @param showBorder 테두리를 표시할지 여부 + * @param onClick 클릭했을 때 호출될 람다 + */ +@Composable +fun QuackDropDownCard( + modifier: Modifier = Modifier, + text: String, + showBorder: Boolean = true, + onClick: (() -> Unit)? = null, +): Unit = Row( + modifier = modifier + .clip(shape = RoundedCornerShape(size = 8.dp)) + .quackClickable( + onClick = onClick, + ) + .background( + color = QuackColor.White.value, + shape = RoundedCornerShape(size = 8.dp), + ) + .runIf(showBorder) { + quackBorder( + border = QuackBorder( + color = QuackColor.Gray3, + ), + shape = RoundedCornerShape(size = 8.dp), + ) + }, + verticalAlignment = Alignment.CenterVertically, +) { + QuackText( + modifier = Modifier.padding( + PaddingValues( + top = 8.dp, + bottom = 8.dp, + start = 12.dp, + end = 4.dp, + ), + ), + text = text, + typography = QuackTypography.Body1, + singleLine = true, + ) + QuackIcon( + modifier = Modifier + .size(DpSize(16.dp, 16.dp)) + .padding(PaddingValues(end = 8.dp)), + icon = OutlinedGroup.ArrowDown, + tint = QuackColor.Gray1, + ) +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackErrorableTextField.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackErrorableTextField.kt new file mode 100644 index 000000000..85d31f76d --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackErrorableTextField.kt @@ -0,0 +1,202 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.quack.todo + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText + +sealed class QuackErrorableTextFieldState { + object Normal : QuackErrorableTextFieldState() + class Success(val successText: String) : QuackErrorableTextFieldState() + class Error(val errorText: String) : QuackErrorableTextFieldState() +} + +@Composable +fun QuackErrorableTextField( + modifier: Modifier = Modifier, + text: String, + onTextChanged: (text: String) -> Unit, + placeholderText: String, + maxLength: Int, + textFieldState: QuackErrorableTextFieldState, + imeAction: ImeAction = ImeAction.Done, + keyboardActions: KeyboardActions = KeyboardActions(), +) { + Column(modifier = modifier) { + BasicTextField( + modifier = modifier, + value = text, + onValueChange = onTextChanged, + keyboardOptions = KeyboardOptions( + imeAction = imeAction, + ), + keyboardActions = keyboardActions, + textStyle = QuackTypography.HeadLine2.asComposeStyle(), + singleLine = true, + ) { innerTextField -> + QuackTextFieldDecorationBox( + modifier = Modifier + .fillMaxWidth() + .bottomBorder( + strokeWidth = 1.dp, + textFieldState = textFieldState, + ), + leadingContent = { + Box( + modifier = Modifier + .weight(1f) + .padding(bottom = 8.dp), + ) { + if (text.isEmpty()) { + QuackText( + text = placeholderText, + typography = QuackTypography.HeadLine2.change( + color = QuackColor.Gray2, + ), + ) + } + innerTextField() + } + }, + trailingContent = { + Row( + modifier = Modifier.padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(1.dp), + ) { + QuackText( + text = "${text.length}", + typography = QuackTypography.Subtitle.change( + color = if (text.isEmpty()) { + QuackColor.Gray2 + } else { + QuackColor.Black + }, + ), + ) + QuackText( + text = "/", + typography = QuackTypography.Subtitle.change( + color = QuackColor.Gray2, + ), + ) + QuackText( + text = "$maxLength", + typography = QuackTypography.Subtitle.change( + color = QuackColor.Gray2, + ), + ) + } + }, + ) + } + Crossfade( + modifier = Modifier.padding(top = 4.dp), + targetState = textFieldState, + label = "", + ) { state -> + when (state) { + is QuackErrorableTextFieldState.Error -> { + QuackText( + text = state.errorText, + typography = QuackTypography.Body1.change( + color = QuackColor.Alert, + ), + ) + } + + is QuackErrorableTextFieldState.Success -> { + QuackText( + text = state.successText, + typography = QuackTypography.Body1.change( + color = QuackColor.Success, + ), + ) + } + + QuackErrorableTextFieldState.Normal -> {} + } + } + } +} + +@Composable +private fun QuackTextFieldDecorationBox( + modifier: Modifier = Modifier, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + leadingContent?.invoke() ?: Spacer(modifier = Modifier.weight(1f)) + trailingContent?.invoke() ?: Spacer(modifier = Modifier.weight(1f)) + } +} + +private fun Modifier.bottomBorder( + strokeWidth: Dp, + textFieldState: QuackErrorableTextFieldState, +) = composed( + factory = { + val color by animateColorAsState( + targetValue = when (textFieldState) { + is QuackErrorableTextFieldState.Error -> { + QuackColor.Alert.value + } + + QuackErrorableTextFieldState.Normal -> { + QuackColor.Gray2.value + } + + else -> QuackColor.Success.value + }, + label = "", + ) + val density = LocalDensity.current + val strokeWidthPx = density.run { strokeWidth.toPx() } + + Modifier.drawBehind { + val width = size.width + val height = size.height - strokeWidthPx / 2 + + drawLine( + color = color, + start = Offset(x = 0f, y = height), + end = Offset(x = width, y = height), + strokeWidth = strokeWidthPx, + ) + } + }, +) diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackLazyVerticalGridTag.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackLazyVerticalGridTag.kt index 9b2abd390..726689fce 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackLazyVerticalGridTag.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackLazyVerticalGridTag.kt @@ -5,16 +5,61 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalQuackQuackApi::class, ExperimentalQuackQuackApi::class) + package team.duckie.app.android.common.compose.ui.quack.todo +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.layout.LazyLayout +import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import team.duckie.app.android.common.compose.ui.icon.v1.toQuackV1Icon -import team.duckie.quackquack.ui.component.QuackTagType +import kotlinx.collections.immutable.ImmutableList +import team.duckie.app.android.common.kotlin.fastForEachIndexed +import team.duckie.app.android.common.kotlin.runtimeCheck +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackTag +import team.duckie.quackquack.ui.QuackTagStyle +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.trailingIcon +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi +/** + * [LazyVerticalGrid] 형식으로 주어진 태그들을 배치합니다. + * 이 컴포넌트는 항상 상위 컴포저블의 가로 길이만큼 width 가 지정되고, + * 한 줄에 최대 [itemChunkedSize]개가 들어갈 수 있습니다. 또한 가로와 세로 스크롤을 모두 지원합니다. + * + * 퍼포먼스 측면에서 [LazyLayout] 를 사용하는 것이 좋지만, 덕키의 경우 + * 표시해야 하는 태그의 개수가 많지 않기 때문에 컴포저블을 직접 그려도 + * 성능에 중대한 영향을 미치지 않을 것으로 판단하여 [LazyColumn] 과 + * [Row] + [Modifier.horizontalScroll] 를 사용하여 구현하였습니다. + * + * @param modifier 이 컴포넌트에 적용할 [Modifier] + * @param contentPadding 이 컴포넌트의 광역에 적용될 [PaddingValues] + * @param title 상단에 표시될 제목. 만약 null 을 제공할 시 표시되지 않습니다. + * @param items 표시할 태그들의 제목. **중복되는 태그 제목은 허용하지 않습니다.** + * 이 항목은 바뀔 수 있으므로 [ImmutableList] 가 아닌 일반 [List] 로 받습니다. + * @param itemSelections 태그들의 선택 여부. + * 이 항목은 바뀔 수 있으므로 [ImmutableList] 가 아닌 [List] 로 받습니다. + * @param itemChunkedSize 한 칸에 들어갈 최대 아이템의 개수 + * @param horizontalSpace 아이템들의 가로 간격 + * @param verticalSpace 아이템들의 세로 간격 + * @param trailingIconResId trailingIcon 에 들어갈 ResourceId. 없을 시 아이콘이 없습니다. + * @param onClick 사용자가 태그를 클릭했을 때 호출되는 람다. + * 람다식의 인자로는 선택된 태그의 index 가 들어옵니다. + */ @Composable fun QuackLazyVerticalGridTag( modifier: Modifier = Modifier, @@ -25,19 +70,64 @@ fun QuackLazyVerticalGridTag( itemChunkedSize: Int, horizontalSpace: Dp = 8.dp, verticalSpace: Dp = 8.dp, - tagTypeResId: Int?, + trailingIcon: ImageVector? = null, + onTrailingClick: ((Int) -> Unit)? = null, onClick: (index: Int) -> Unit, ) { - team.duckie.quackquack.ui.component.QuackLazyVerticalGridTag( - modifier = modifier, - contentPadding = contentPadding, - title = title, - items = items, - itemSelections = itemSelections, - itemChunkedSize = itemChunkedSize, - horizontalSpace = horizontalSpace, - verticalSpace = verticalSpace, - tagType = QuackTagType.Circle(tagTypeResId?.toQuackV1Icon), - onClick = onClick, - ) + if (itemSelections != null) { + runtimeCheck(items.size == itemSelections.size) { + "The size of items and the size of itemsSelection must always be the same. " + + "[items.size (${items.size}) != itemsSelection.size (${itemSelections.size})]" + } + } + + val chunkedItems = remember(items) { + items.chunked(itemChunkedSize) + } + + Column( + modifier = modifier + .fillMaxWidth() + .padding(contentPadding), + ) { + if (title != null) { + QuackText( + modifier = Modifier.padding(bottom = 12.dp), + text = title, + typography = QuackTypography.Title2, + singleLine = true, + ) + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(horizontalSpace), + ) { + chunkedItems.fastForEachIndexed { rowIndex, items -> + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(state = rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(verticalSpace), + ) { + items.fastForEachIndexed { index, item -> + val currentIndex = rowIndex * itemChunkedSize + index + QuackTag( + text = item, + style = QuackTagStyle.Filled, + modifier = if (trailingIcon != null) { + Modifier.trailingIcon( + trailingIcon, + onClick = { onTrailingClick?.invoke(index) }, + ) + } else { + Modifier + }, + selected = itemSelections?.get(currentIndex) ?: false, + onClick = { onClick(index) }, + ) + } + } + } + } + } } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackReactionTextArea.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackReactionTextArea.kt index 6cc0d5a09..335c56054 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackReactionTextArea.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackReactionTextArea.kt @@ -40,7 +40,7 @@ private fun getQuackReactionTextAreaMeasurePolicy( with(QuackReactionTextAreaLayoutId) { val quizReviewPlaceHolderPlaceable = - measurables.getPlaceable(QuizReviewPlaceHolder, looseConstraints) + measurables.getPlaceable(QuizReviewPlaceHolder, extraLooseConstraints) val quizReviewTextAreaPlaceable = measurables.getPlaceable(QuizReviewTextArea, extraLooseConstraints) val reactionLimitTextPlaceable = @@ -50,12 +50,12 @@ private fun getQuackReactionTextAreaMeasurePolicy( width = constraints.maxWidth, height = constraints.maxHeight, ) { - quizReviewTextAreaPlaceable.place(x = 0, y = 0) + quizReviewTextAreaPlaceable.placeRelative(x = 0, y = 0) if (placeholderVisible) { - quizReviewPlaceHolderPlaceable.place(x = 0, y = 0) + quizReviewPlaceHolderPlaceable.placeRelative(x = 0, y = 0) } - reactionLimitTextPlaceable.place( - x = constraints.maxWidth - reactionLimitTextPlaceable.width, + reactionLimitTextPlaceable?.placeRelative( + x = quizReviewTextAreaPlaceable.width - reactionLimitTextPlaceable.width, y = quizReviewTextAreaPlaceable.height - reactionLimitTextPlaceable.height, ) } @@ -64,12 +64,15 @@ private fun getQuackReactionTextAreaMeasurePolicy( @Composable fun QuackReactionTextArea( + modifier: Modifier = Modifier, reaction: String, onReactionChanged: (String) -> Unit, maxLength: Int = RANKER_REACTION_MAX_LENGTH, + placeHolderText: String = RANKER_REACTION_PLACE_HOLDER, + visibleCurrentLength: Boolean = true, ) = with(QuackReactionTextAreaLayoutId) { Layout( - modifier = Modifier.height(100.dp), + modifier = modifier.height(100.dp), measurePolicy = getQuackReactionTextAreaMeasurePolicy( placeholderVisible = reaction.isEmpty(), ), @@ -91,23 +94,25 @@ fun QuackReactionTextArea( modifier = Modifier .layoutId(QuizReviewPlaceHolder) .padding(all = 16.dp), - text = RANKER_REACTION_PLACE_HOLDER, + text = placeHolderText, typography = QuackTypography.Body1.change( color = QuackColor.Gray2, ), ) - QuackText( - modifier = Modifier - .layoutId(ReactionLimitText) - .padding( - bottom = 12.dp, - end = 12.dp, + if (visibleCurrentLength) { + QuackText( + modifier = Modifier + .layoutId(ReactionLimitText) + .padding( + bottom = 12.dp, + end = 12.dp, + ), + text = "${reaction.length} / $RANKER_REACTION_MAX_LENGTH", + typography = QuackTypography.Body1.change( + color = QuackColor.Gray2, ), - text = "${reaction.length} / $RANKER_REACTION_MAX_LENGTH", - typography = QuackTypography.Body1.change( - color = QuackColor.Gray2, - ), - ) + ) + } }, ) } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSelectableImage.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSelectableImage.kt index 89a03bc45..40e169f5a 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSelectableImage.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSelectableImage.kt @@ -7,39 +7,133 @@ package team.duckie.app.android.common.compose.ui.quack.todo +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.DpSize -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackSelectableImageType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import team.duckie.app.android.common.compose.ui.icon.v1.CheckId +import team.duckie.app.android.common.compose.ui.quack.todo.QuackSelectableImageType.CheckOverlay +import team.duckie.app.android.common.compose.ui.quack.todo.QuackSelectableImageType.TopEndCheckBox +import team.duckie.app.android.common.compose.ui.quack.todo.animation.QuackRoundCheckBox +import team.duckie.app.android.common.kotlin.runIf +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.ui.QuackImage +/** + * 오른쪽 상단에 체크박스와 함께 이미지 혹은 [QuackIcon] 을 표시합니다. + * + * @param modifier 이 컴포저블에서 사용할 [Modifier] + * @param isSelected 현재 이미지가 선택됐는지 여부 + * @param src 표시할 리소스. 만약 null 이 들어온다면 리소스를 그리지 않습니다. + * @param size 리소스의 크기를 지정합니다. null 이 들어오면 기본 크기로 표시합니다. + * @param tint 적용할 틴트 값 + * @param shape 컴포넌트의 모양 + * @param selectableType selection 이 표시될 방식 + * @param rippleEnabled 클릭됐을 때 ripple 발생 여부 + * @param onClick 클릭됐을 때 실행할 람다식 + * @param contentScale 적용할 content scale 정책 + * @param contentDescription 이미지의 설명 + */ @Composable fun QuackSelectableImage( modifier: Modifier = Modifier, isSelected: Boolean, src: Any?, size: DpSize? = null, - tint: QuackColor? = null, + tint: QuackColor = QuackColor.Unspecified, shape: Shape = RectangleShape, + selectableType: QuackSelectableImageType = TopEndCheckBox, rippleEnabled: Boolean = true, onClick: (() -> Unit)? = null, contentScale: ContentScale = ContentScale.FillBounds, contentDescription: String? = null, ) { - team.duckie.quackquack.ui.component.QuackSelectableImage( + QuackSurface( modifier = modifier, - isSelected = isSelected, - src = src, - size = size, - tint = tint, shape = shape, - selectableType = QuackSelectableImageType.TopEndCheckBox, + border = BorderStroke(1.dp, QuackColor.Gray3.value) + .takeIf { isSelected } + .takeIf { selectableType == TopEndCheckBox }, rippleEnabled = rippleEnabled, onClick = onClick, - contentScale = contentScale, - contentDescription = contentDescription, + contentAlignment = Alignment.TopEnd, + ) { + QuackImage( + src = src, + modifier = Modifier + .zIndex(1f) + .runIf(size != null) { size(size!!) }, + tint = tint, + contentScale = contentScale, + contentDescription = contentDescription, + ) + + when (selectableType) { + TopEndCheckBox -> { + QuackRoundCheckBox( + modifier = Modifier + .padding(paddingValues = PaddingValues(all = 7.dp)) + .zIndex(2f), + checked = isSelected, + ) + } + + CheckOverlay -> { + AnimatedVisibility( + modifier = Modifier + .matchParentSize() + .zIndex(2f), + visible = isSelected, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = QuackColor.Dimmed.value), + contentAlignment = Alignment.Center, + ) { + QuackImage( + src = QuackIcon.CheckId, + modifier = Modifier.size(selectableType.size!!), + tint = selectableType.tint!!, + ) + } + } + } + } + } +} + +/** + * [QuackSelectableImage] 에서 selection 이 표시될 방식을 나타냅니다. + * + * @property TopEndCheckBox 오른쪽 상단에 [QuackRoundCheckBox] 로 표시 + * @property CheckOverlay 이미지 전체에 [QuackIcon.Check] 로 오버레이 표시 및 + * [QuackColor.Dimmed] 로 dimmed 처리 + * + * @param size 만약 [CheckOverlay] 방식일 때 [QuackIcon.Check] 의 사이즈 + * @param tint 만약 [CheckOverlay] 방식일 때 [QuackIcon.Check] 의 틴트 + */ +sealed class QuackSelectableImageType( + internal val size: DpSize? = null, + internal val tint: QuackColor? = null, +) { + object TopEndCheckBox : QuackSelectableImageType() + object CheckOverlay : QuackSelectableImageType( + size = DpSize(width = 28.dp, height = 28.dp), + tint = QuackColor.White, ) } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSingeLazyRowTag.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSingeLazyRowTag.kt index b70f5194f..66d8fc550 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSingeLazyRowTag.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSingeLazyRowTag.kt @@ -5,39 +5,125 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalQuackQuackApi::class) + package team.duckie.app.android.common.compose.ui.quack.todo +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import team.duckie.app.android.common.compose.ui.icon.v1.toQuackV1Icon -import team.duckie.quackquack.ui.component.QuackTagType +import kotlinx.collections.immutable.ImmutableList +import team.duckie.app.android.common.kotlin.runtimeCheck +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackTag +import team.duckie.quackquack.ui.QuackTagStyle +import team.duckie.quackquack.ui.trailingIcon +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi +/** + * [LazyRow] 형식으로 주어진 태그들을 **한 줄로** 배치합니다. + * 이 컴포넌트는 항상 상위 컴포저블의 가로 길이만큼 width 가 지정됩니다. + * + * @param modifier 이 컴포넌트에 적용할 [Modifier] + * @param contentPadding 이 컴포넌트의 광역에 적용될 [PaddingValues] + * @param title 상단에 표시될 제목. 만약 null 을 제공할 시 표시되지 않습니다. + * @param items 표시할 태그들의 제목. **중복되는 태그 제목은 허용하지 않습니다.** + * 이 항목은 바뀔 수 있으므로 [ImmutableList] 가 아닌 일반 [List] 로 받습니다. + * @param itemSelections 태그들의 선택 여부. + * 이 항목은 바뀔 수 있으므로 [ImmutableList] 가 아닌 [List] 로 받습니다. + * @param horizontalSpace 아이템들의 가로 간격 + * @param tagType [QuackLazyVerticalGridTag] 에서 표시할 태그의 타입을 지정합니다. + * 여러 종류의 태그가 [QuackLazyVerticalGridTag] 으로 표시될 수 있게 태그의 타입을 따로 받습니다. + * @param key a factory of stable and unique keys representing the item. Using the same key + * for multiple items in the list is not allowed. Type of the key should be saveable + * via Bundle on Android. If null is passed the position in the list will represent the key. + * When you specify the key the scroll position will be maintained based on the key, which + * means if you add/remove items before the current visible item the item with the given key + * will be kept as the first visible one. + * @param contentType a factory of the content types for the item. The item compositions of + * the same type could be reused more efficiently. Note that null is a valid type and items of such + * type will be considered compatible. + * @param onClick 사용자가 태그를 클릭했을 때 호출되는 람다. + * 람다식의 인자로는 선택된 태그의 index 가 들어옵니다. + */ @Composable -fun QuackSingeLazyRowTag( +fun QuackOutLinedSingeLazyRowTag( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(all = 0.dp), title: String? = null, items: List, itemSelections: List? = null, horizontalSpace: Dp = 8.dp, - tagTypeResId: Int?, + trailingIcon: ImageVector? = null, key: ((index: Int, item: String) -> Any)? = null, contentType: (index: Int, item: String) -> Any? = { _, _ -> null }, + onTrailingIconClick: ((index: Int) -> Unit)? = null, onClick: (index: Int) -> Unit, ) { - team.duckie.quackquack.ui.component.QuackSingeLazyRowTag( - modifier = modifier, - contentPadding = contentPadding, - title = title, - items = items, - itemSelections = itemSelections, - horizontalSpace = horizontalSpace, - tagType = QuackTagType.Circle(tagTypeResId?.toQuackV1Icon), - key = key, - contentType = contentType, - onClick = onClick, - ) + if (itemSelections != null) { + runtimeCheck( + value = items.size == itemSelections.size, + ) { + "The size of items and the size of itemsSelection must always be the same. " + + "[items.size (${items.size}) != itemsSelection.size (${itemSelections.size})]" + } + } + + val layoutDirection = LocalLayoutDirection.current + + Column(modifier = modifier.fillMaxWidth()) { + if (title != null) { + team.duckie.quackquack.ui.QuackText( + modifier = Modifier.padding( + start = contentPadding.calculateStartPadding(layoutDirection), + end = contentPadding.calculateEndPadding(layoutDirection), + bottom = 12.dp, + ), + text = title, + typography = QuackTypography.Title2, + singleLine = true, + ) + } + + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(horizontalSpace), + ) { + itemsIndexed( + items = items, + key = key, + contentType = contentType, + ) { index, item -> + QuackTag( + text = item, + style = QuackTagStyle.Outlined, + modifier = if (trailingIcon != null) { + Modifier.trailingIcon( + trailingIcon, + onClick = { onTrailingIconClick?.invoke(index) }, + ) + } else { + Modifier + }, + selected = itemSelections?.get(index) ?: false, + onClick = { + onClick(index) + }, + ) + } + } + } } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSurface.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSurface.kt new file mode 100644 index 000000000..c3416a8f9 --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackSurface.kt @@ -0,0 +1,112 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.quack.todo + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.quack.todo.animation.QuackAnimationSpec +import team.duckie.app.android.common.kotlin.runIf +import team.duckie.quackquack.animation.animateQuackColorAsState +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.quackClickable + +/** + * 모든 Quack 컴포넌트에서 최하위로 사용되는 컴포넌트입니다. + * 컴포넌트의 기본 모양을 정의합니다. + * + * **애니메이션 가능한 모든 요소들에는 자동으로 애니메이션이 적용됩니다.** + * animationSpec 으로는 항상 [QuackAnimationSpec] 을 사용합니다. + * + * @param modifier 컴포저블에 적용할 [Modifier]. + * 기본값은 수정 없는 본질의 Modifier 입니다. + * @param shape 컴포저블의 [Shape]. 기본값은 [RectangleShape] 입니다. + * @param backgroundColor 컴포저블의 배경 색상. + * 기본값은 정의되지 않은 색상인 [QuackColor.Unspecified] 입니다. + * @param border 컴포저블의 테두리. + * null 이 입력된다면 테두리를 설정하지 않습니다. 기본값은 null 입니다. + * @param elevation 컴포저블의 그림자 고도. + * 기본값은 0 입니다. 즉, 그림자를 사용하지 않습니다. + * @param rippleEnabled 컴포저블이 클릭됐을 때 리플 효과를 적용할지 여부. + * 기본값은 true 입니다. + * @param rippleColor 컴포저블이 클릭됐을 때 리플 효과의 색상. + * 기본값은 정해지지 않은 색상인 [QuackColor.Unspecified] 입니다. + * [rippleEnabled] 이 켜져 있을 때만 사용됩니다. + * @param onClick 컴포저블이 클릭됐을 때 실행할 람다식. + * null 이 입력된다면 클릭 이벤트를 추가하지 않습니다. + * 기본값은 null 입니다. 즉, 클릭 이벤트를 추가하지 않습니다. + * @param contentAlignment 컴포저블의 정렬 상태. 기본값은 Center 입니다. + * @param propagateMinConstraints 최소 제약 조건을 전파할지 여부. 기본값은 false 입니다. + * @param content 표시할 컴포저블. [BoxScope] 를 receive 로 받습니다. + */ +// TODO: Modifier.quackSurface 로 변경 +// @NonRestartableComposable; 여기서 사용하는 Box 는 inline 됨 +@Composable +fun QuackSurface( + modifier: Modifier = Modifier, + shape: Shape = RectangleShape, + backgroundColor: QuackColor = QuackColor.Unspecified, + border: BorderStroke? = null, + elevation: Dp = 0.dp, + rippleEnabled: Boolean = true, + rippleColor: QuackColor = QuackColor.Unspecified, + onClick: (() -> Unit)? = null, + contentAlignment: Alignment = Alignment.Center, + propagateMinConstraints: Boolean = false, + content: @Composable BoxScope.() -> Unit, +) { + val backgroundColorAnimation by animateQuackColorAsState( + targetValue = backgroundColor, + ) + + Box( + modifier = modifier + .shadow( + elevation = elevation, + shape = shape, + clip = false, + ) + .clip( + shape = shape, + ) + .background( + color = backgroundColorAnimation.value, + shape = shape, + ) + .quackClickable( + onClick = onClick, + rippleEnabled = rippleEnabled, + rippleColor = rippleColor, + ) + .runIf(border != null) { + border( + border = border!!, + shape = shape, + ) + } + .animateContentSize( + animationSpec = QuackAnimationSpec(), + ), + contentAlignment = contentAlignment, + propagateMinConstraints = propagateMinConstraints, + content = content, + ) +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackTopAppBar.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackTopAppBar.kt index 7128f7dc6..accd645d5 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackTopAppBar.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/QuackTopAppBar.kt @@ -7,44 +7,353 @@ package team.duckie.app.android.common.compose.ui.quack.todo +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.icon.v1.TextLogoId import team.duckie.app.android.common.compose.ui.icon.v1.toQuackV1Icon +import team.duckie.app.android.common.compose.util.expendedQuackClickable +import team.duckie.app.android.common.kotlin.runtimeCheck +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.icon.QuackIcon as QuackV1Icon -/** 덕키에서 사용하는 TopAppBar */ +/** + * 덕키의 Top Navigation Bar 를 그립니다. + * [QuackTopAppBar] 는 몇몇 중요한 특징이 있습니다. + * + * - 최소 하나의 값이 제공돼야 합니다. + * - 항상 상위 컴포저블의 가로 길이에 꽉차게 그립니다. + * - [showLogoAtCenter] 이 true 라면 [centerText] 값은 무시됩니다. + * 로고 리소스로는 [QuackIcon.TextLogoId] 를 사용합니다. + * - [centerText] 값이 있다면 [showLogoAtCenter] 값은 무시됩니다. + * 또한 [centerText] 는 trailing content 로 아이콘을 배치할 수 있습니다. + * - [trailingExtraIcon] 과 [trailingIcon] 이 하나라도 들어왔다면 [trailingText] 는 무시되며, + * `[trailingExtraIcon] [trailingIcon]` 순서로 배치됩니다. + * - [trailingText] 값이 입력되면 [trailingExtraIcon] 과 [trailingIcon] 값은 무시됩니다. + * - [trailingContent] 이 있다면 [trailingIcon], [trailingExtraIcon], [trailingText], + * [onTrailingIconClick], [onTrailingExtraIconClick], [onTrailingTextClick] 값은 무시됩니다. + * + * @param modifier 이 컴포넌트에 적용할 [Modifier] + * @param leadingIcon leading content 로 배치할 아이콘 resId + * @param leadingText leading content 로 배치할 텍스트. 선택적으로 값을 받습니다. + * @param onLeadingIconClick [leadingIcon] 이 클릭됐을 때 실행될 람다 + * @param showLogoAtCenter center content 로 덕키의 로고를 배치할지 여부 + * @param centerText center content 에 로고 대신에 표시할 텍스트 + * @param centerTextTrailingIcon [centerText] 의 trailing content 로 배치할 아이콘 resId + * @param onCenterClick center content 가 클릭됐을 때 실행될 람다 + * @param trailingContent trailing content 로 배치할 컴포넌트 + * @param trailingIcon trailing content 로 배치할 아이콘 + * @param trailingExtraIcon trailing content 에 추가로 배치할 아이콘 + * @param trailingText trailing content 에 배치할 텍스트 + * @param onTrailingIconClick [trailingIcon] 이 클릭됐을 때 실행될 람다 + * @param onTrailingExtraIconClick [trailingExtraIcon] 이 클릭됐을 때 실행될 람다 + * @param onTrailingTextClick [trailingText] 가 클릭됐을 때 실행될 람다 + */ @Composable fun QuackTopAppBar( modifier: Modifier = Modifier, - leadingIconResId: Int? = null, + leadingIcon: ImageVector? = null, leadingText: String? = null, onLeadingIconClick: (() -> Unit)? = null, showLogoAtCenter: Boolean? = null, centerText: String? = null, - centerTextTrailingIconResId: Int? = null, + centerTextTrailingIcon: ImageVector? = null, onCenterClick: (() -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null, - trailingIconResId: Int? = null, - trailingExtraIconResId: Int? = null, + trailingIcon: ImageVector? = null, + trailingExtraIcon: ImageVector? = null, trailingText: String? = null, onTrailingIconClick: (() -> Unit)? = null, onTrailingExtraIconClick: (() -> Unit)? = null, onTrailingTextClick: (() -> Unit)? = null, ) { - team.duckie.quackquack.ui.component.QuackTopAppBar( - modifier = modifier, - leadingIcon = leadingIconResId?.toQuackV1Icon, - leadingText = leadingText, - onLeadingIconClick = onLeadingIconClick, - showLogoAtCenter = showLogoAtCenter, - centerText = centerText, - centerTextTrailingIcon = centerTextTrailingIconResId?.toQuackV1Icon, - onCenterClick = onCenterClick, - trailingContent = trailingContent, - trailingIcon = trailingIconResId?.toQuackV1Icon, - trailingExtraIcon = trailingExtraIconResId?.toQuackV1Icon, - trailingText = trailingText, - onTrailingIconClick = onTrailingIconClick, - onTrailingExtraIconClick = onTrailingExtraIconClick, - onTrailingTextClick = onTrailingTextClick, + runtimeCheck( + leadingIcon != null || + leadingText != null || + onLeadingIconClick != null || + showLogoAtCenter != null || + centerText != null || + centerTextTrailingIcon != null || + onCenterClick != null || + trailingContent != null || + trailingIcon != null || + trailingExtraIcon != null || + trailingText != null || + onTrailingIconClick != null || + onTrailingExtraIconClick != null || + onTrailingTextClick != null, + ) { + "At least one param setting is required." + } + + if (trailingContent != null) { + runtimeCheck( + trailingIcon == null && trailingExtraIcon == null && trailingText == null && + onTrailingIconClick == null && onTrailingExtraIconClick == null && + onTrailingTextClick == null, + ) { + "trailingContent 가 입력되었을 때는 다른 trailing content 인자들을 이용하실 수 없습니다." + } + } + + Row( + modifier = modifier + .fillMaxWidth() + .background(color = QuackTopAppBarDefaults.BackgroundColor.value), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + QuackTopAppBarDefaults.LeadingContent( + icon = leadingIcon, + text = leadingText, + onIconClick = onLeadingIconClick, + ) + QuackTopAppBarDefaults.CenterContent( + showLogo = showLogoAtCenter, + text = centerText, + textTrailingIcon = centerTextTrailingIcon, + onClick = onCenterClick, + ) + // https://github.com/duckie-team/quack-quack-android/issues/412 + // TrailingContent content 가 없어도 width 를 차지함 + if (trailingContent != null) { + trailingContent() + } else { + QuackTopAppBarDefaults.TrailingContent( + icon = trailingIcon, + extraIcon = trailingExtraIcon, + text = trailingText, + onIconClick = onTrailingIconClick, + onExtraIconClick = onTrailingExtraIconClick, + onTextClick = onTrailingTextClick, + ) + } + } +} + +/** + * QuackTopAppBar 를 그리기 위한 리소스들을 정의합니다. + */ +private object QuackTopAppBarDefaults { + val BackgroundColor = QuackColor.White + + private val CenterTypography = QuackTypography.Body1 + private val TrailingTypography = QuackTypography.Subtitle.change( + color = QuackColor.Gray2, ) + + private val LogoIcon: QuackV1Icon = QuackIcon.TextLogoId.toQuackV1Icon!! + private val LogoIconSize = DpSize( + width = 72.dp, + height = 24.dp, + ) + private val LogoPadding = PaddingValues( + vertical = 12.dp, + ) + + private val CenterTextPadding = PaddingValues( + vertical = 15.dp, + ) + private val CenterIconTint = QuackColor.Gray1 + + /** + * 모든 영역에서 사용되는 공통 아이콘 사이즈 + */ + private val IconSize: Dp = 24.dp + + /** + * leading content 를 배치합니다. + * + * @param icon 배치할 아이콘 + * @param text 배치할 텍스트. 선택적으로 값을 받습니다. + * @param onIconClick [icon] 이 클릭됐을 때 실행될 람다 + */ + @Composable + fun LeadingContent( + icon: ImageVector?, + text: String? = null, + onIconClick: (() -> Unit)? = null, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + icon?.let { + QuackIcon( + modifier = Modifier + .expendedQuackClickable( + rippleEnabled = true, + onClick = onIconClick, + ) + .size(DpSize(44.dp, 44.dp)) + .padding(10.dp), + icon = icon, + ) + } + text?.let { + // TODO: 최대 width 처리 + // 아마 커스텀 레이아웃 필요할 듯 + QuackText( + text = text, + typography = QuackTypography.HeadLine2, + singleLine = true, + ) + } + } + } + + /** + * center content 를 배치합니다. + * + * - [showLogo] 이 true 라면 [text] 값은 무시됩니다. + * 로고 리소스로는 [QuackIcon.TextLogoId] 를 사용합니다. + * - [text] 값이 있다면 [showLogo] 값은 무시됩니다. + * 또한 [text] 는 trailing icon 을 가질 수 있습니다. + * + * @param showLogo 덕키의 로고를 배치할지 여부 + * @param text 로고 대신에 표시할 텍스트 + * @param textTrailingIcon [text] 의 trailing content 로 표시될 아이콘 + * @param onClick center content 가 클릭됐을 때 실행될 람다 + */ + @Composable + fun CenterContent( + showLogo: Boolean? = null, + text: String? = null, + textTrailingIcon: ImageVector? = null, + onClick: (() -> Unit)? = null, + ) { + if (showLogo == true) { + runtimeCheck( + value = text == null, + ) { + "로고와 텍스트를 동시에 표시할 수 없습니다" + } + } + Row( + modifier = Modifier.quackClickable( + rippleEnabled = false, + onClick = onClick, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + if (showLogo == true) { + QuackImage( + modifier = Modifier + .size(LogoIconSize) + .padding(LogoPadding), + src = LogoIcon, + ) + } else { + text?.let { + QuackText( + modifier = Modifier.padding( + paddingValues = CenterTextPadding, + ), + text = text, + typography = CenterTypography, + singleLine = true, + ) + } + textTrailingIcon?.let { + QuackIcon( + icon = textTrailingIcon, + tint = CenterIconTint, + size = 24.dp, + ) + } + } + } + } + + /** + * trailing content 를 그립니다. + * + * - [extraIcon] 과 [icon] 이 하나라도 들어왔다면 [text] 는 무시되며, + * `[extraIcon] [icon]` 순서로 배치됩니다. + * - [text] 값이 입력되면 [extraIcon] 과 [icon] 값은 무시됩니다. + * + * @param icon 배치할 아이콘 + * @param extraIcon 추가로 배치할 아이콘 + * @param text 배치할 텍스트 + * @param onIconClick [icon] 이 클릭됐을 때 실행될 람다 + * @param onExtraIconClick [extraIcon] 이 클릭됐을 때 실행될 람다 + * @param onTextClick [text] 가 클릭됐을 때 실행될 람다 + */ + @Composable + fun TrailingContent( + icon: ImageVector? = null, + extraIcon: ImageVector? = null, + text: String? = null, + onIconClick: (() -> Unit)? = null, + onExtraIconClick: (() -> Unit)? = null, + onTextClick: (() -> Unit)? = null, + ) { + if (icon != null || extraIcon != null) { + runtimeCheck( + value = text == null, + ) { + "아이콘과 텍스트를 동시에 표시할 수 없습니다" + } + } + if (text != null) { + runtimeCheck( + value = icon == null && extraIcon == null, + ) { + "텍스트와 아이콘을 동시에 표시할 수 없습니다" + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + text?.let { + QuackText( + modifier = Modifier + .expendedQuackClickable( + rippleEnabled = true, + onClick = onTextClick, + ), + text = text, + typography = TrailingTypography, + singleLine = true, + ) + } + extraIcon?.let { + QuackIcon( + icon = extraIcon, + modifier = Modifier + .expendedQuackClickable( + rippleEnabled = true, + onClick = onExtraIconClick, + ), + ) + } + icon?.let { + QuackIcon( + icon = icon, + modifier = Modifier + .expendedQuackClickable( + rippleEnabled = true, + onClick = onIconClick, + ), + size = IconSize, + ) + } + } + } } diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/animation/AnimationSpec.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/animation/AnimationSpec.kt new file mode 100644 index 000000000..01a3fbf1d --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/animation/AnimationSpec.kt @@ -0,0 +1,64 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.quack.todo.animation + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.DurationBasedAnimationSpec +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.SnapSpec +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Stable + +/** + * 꽥꽥에서 사용할 [AnimationSpec] 에 대한 정보 + */ +object QuackAnimationSpec { + /** + * 일부 환경에서는 애니메이션이 없이 진행돼야 할 때도 있습니다. + * 이 값을 true 로 설정하면 모든 애니메이션을 무시합니다. + * + * **이 값의 변경은 모든 애니메이션에 영향을 미치므로 신중하게 사용해야 합니다.** + */ + var snapMode: Boolean = false + + /** + * 꽥꽥에서 사용할 [애니메이션의 기본 스팩][AnimationSpec] + * + * @return 덕키에서 사용할 [AnimationSpec]. [snapMode] 에 따라 반환값이 달라집니다. + * false 라면 덕키에서 사용하는 애니메이션 스팩인 [TweenSpec] 이 반환되고, + * true 라면 [SnapSpec] 이 반환됩니다. + * + * @see snapMode + */ + operator fun invoke(): DurationBasedAnimationSpec = when (snapMode) { + true -> snap() + else -> tween( + durationMillis = 250, + easing = LinearEasing, + ) + } +} + +/** + * [QuackAnimationSpec.snapMode] 대신에 한 번만 선택적으로 애니메이션 여부를 + * 결정하기 위해 사용할 수 있습니다. + * + * @param useAnimation 애니메이션을 사용할지 여부 + * + * @return [useAnimation] 여부에 따른 [DurationBasedAnimationSpec] + */ +@Suppress("FunctionName") +@Stable +public fun QuackOptionalAnimationSpec( + useAnimation: Boolean, +): DurationBasedAnimationSpec = when (useAnimation) { + true -> QuackAnimationSpec() + else -> snap() +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/animation/toggle.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/animation/toggle.kt new file mode 100644 index 000000000..0c37863e5 --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/quack/todo/animation/toggle.kt @@ -0,0 +1,585 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/quack-quack-android/blob/master/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.quack.todo.animation + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathMeasure +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.quack.todo.QuackSurface +import team.duckie.app.android.common.compose.ui.quack.todo.animation.QuackToggleIconSize.Compact +import team.duckie.app.android.common.compose.ui.quack.todo.animation.QuackToggleIconSize.Normal +import team.duckie.app.android.common.compose.ui.quack.todo.animation.QuackToggleIconSize.Small +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackText +import kotlin.math.floor + +/** + * [QuackToggleButton] 에서 표시할 아이콘의 사이즈를 정의합니다. + * + * @property Normal 보통 사이즈로 표시합니다. (24 dp) + * @property Small 약간 축소된 사이즈로 표시합니다. (18 dp) + * @property Compact 많이 축소된 사이즈로 표시합니다. (14 dp) + */ +enum class QuackToggleIconSize( + val size: DpSize, +) { + Normal( + size = DpSize( + width = 24.dp, + height = 24.dp, + ), + ), + Small( + size = DpSize( + width = 18.dp, + height = 18.dp, + ), + ), + Compact( + size = DpSize( + width = 14.dp, + height = 14.dp, + ), + ), +} + +/** + * QuackToggle 을 그리는데 필요한 리소스를 구성합니다. + */ +private object QuackToggleDefaults { + // Copied from AOSP + object DrawConstaints { + const val TransitionLabel = "CheckTransition" + const val BoxOutDuration = 100 + const val StopLocation = 0.5f + + val StrokeWidth = 1.5.dp + const val CheckCrossX = 0.4f + const val CheckCrossY = 0.7f + const val LeftX = 0.25f + const val LeftY = 0.55f + const val RightX = 0.75f + const val RightY = 0.35f + } + + object RoundCheck { + /** + * 주어진 상황에 맞는 테두리를 계산합니다. + * + * @param isChecked 현재 선택된 상태인지 여부 + * + * @return [isChecked] 여부에 따른 [QuackBorder] + */ + @Stable + fun borderFor( + isChecked: Boolean, + ) = BorderStroke( + width = 1.dp, + color = when (isChecked) { + true -> QuackColor.DuckieOrange.value + else -> QuackColor.White.value + }, + ) + + /** + * 주어진 상황에 맞는 배경 색상을 계산합니다. + * + * @param isChecked 현재 선택된 상태인지 여부 + * + * @return [isChecked] 여부에 따른 [QuackColor] + */ + @Stable + fun backgroundColorFor( + isChecked: Boolean, + ) = when (isChecked) { + true -> QuackColor.DuckieOrange + else -> QuackColor.Black.change( + alpha = 0.2f, + ) + } + + val ContainerSize = DpSize( + width = 24.dp, + height = 24.dp, + ) + val ContainerShape = CircleShape + + val CheckColor = QuackColor.White + val CheckSize = DpSize( + width = 18.dp, + height = 18.dp, + ) + } + + object SquareCheck { + /** + * 주어진 상황에 맞는 배경 색상을 계산합니다. + * + * @param isChecked 현재 선택된 상태인지 여부 + * + * @return [isChecked] 여부에 따른 [QuackColor] + */ + @Stable + fun backgroundColorFor( + isChecked: Boolean, + ) = when (isChecked) { + true -> QuackColor.DuckieOrange + else -> QuackColor.Gray3 + } + + val ContainerSize = DpSize( + width = 24.dp, + height = 24.dp, + ) + val ContainerShape = RoundedCornerShape( + size = 4.dp, + ) + + val CheckColor = QuackColor.White + val CheckSize = DpSize( + width = 18.dp, + height = 18.dp, + ) + } + + object ToggleButton { + val IconSize = Small + val Typography = QuackTypography.Body2.change( + color = QuackColor.Gray1, + ) + + /** + * ``` + * [Icon][Typography] + * ``` + * + * 에서 `Icon` 과 `Typography` 간의 사이 간격을 나타냅니다. + */ + val ItemSpacedBy = 4.dp + } +} + +/** + * 덕키의 원형 CheckBox 를 구현합니다. + * + * @param modifier 이 컴포넌트에 적용할 [Modifier] + * @param checked 체크되었는지 여부 + * @param onClick 체크시 호출되는 콜백 + */ +@Composable +fun QuackRoundCheckBox( + modifier: Modifier = Modifier, + checked: Boolean, + onClick: (() -> Unit)? = null, +): Unit = with( + receiver = QuackToggleDefaults.RoundCheck, +) { + QuackSurface( + modifier = modifier.size( + size = ContainerSize, + ), + shape = ContainerShape, + backgroundColor = backgroundColorFor( + isChecked = checked, + ), + border = borderFor( + isChecked = checked, + ), + onClick = onClick, + ) { + Check( + value = ToggleableState( + value = checked, + ), + checkColor = CheckColor, + size = CheckSize, + ) + } +} + +/** + * 덕키의 원형 CheckBox 를 구현합니다. + * [QuackRoundCheckBox] 보다 작습니다. + * + * @param modifier 이 컴포넌트에 적용할 [Modifier] + * @param checked 체크되었는지 여부 + * @param checkedText 체크시 하단에 표시할 텍스트 + * @param onClick 체크시 호출되는 콜백 + */ +@Composable +public fun QuackSmallRoundCheckBox( + modifier: Modifier = Modifier, + checked: Boolean, + checkedText: String, + onClick: (() -> Unit)? = null, +): Unit = with(QuackToggleDefaults.RoundCheck) { + AnimatedContent( + targetState = checked, + label = "AnimatedContent", + ) { showUnderText -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + QuackSurface( + modifier = modifier.size(DpSize(width = 18.dp, height = 18.dp)), + shape = ContainerShape, + backgroundColor = backgroundColorFor( + isChecked = checked, + ), + border = borderFor( + isChecked = checked, + ), + onClick = onClick, + ) { + Check( + value = ToggleableState( + value = checked, + ), + checkColor = CheckColor, + size = DpSize(width = 12.dp, height = 12.dp), + ) + } + if (showUnderText) { + QuackText( + modifier = Modifier.padding(top = 2.dp), + text = checkedText, + typography = QuackTypography.Body3.change(color = QuackColor.DuckieOrange), + ) + } + } + } +} + +/** + * 덕키의 사각형 CheckBox 를 구현합니다. + * + * @param modifier 이 컴포넌트에 적용할 [Modifier] + * @param checked 체크되었는지 여부 + * @param onClick 체크시 호출되는 콜백 + */ +@Composable +public fun QuackSquareCheckBox( + modifier: Modifier = Modifier, + checked: Boolean, + onClick: (() -> Unit)? = null, +): Unit = with( + receiver = QuackToggleDefaults.SquareCheck, +) { + QuackSurface( + modifier = modifier.size( + size = ContainerSize, + ), + shape = ContainerShape, + backgroundColor = backgroundColorFor( + isChecked = checked, + ), + onClick = onClick, + ) { + Check( + value = ToggleableState( + value = checked, + ), + checkColor = CheckColor, + size = CheckSize, + ) + } +} + +/** + * Checked 여부에 따라 조건에 맞는 아이콘을 표시합니다. + * + * @param modifier 이 컴포넌트에 적용할 [Modifier] + * @param checkedIcon 체크 상태에서 표시할 아이콘 + * @param uncheckedIcon 체크 상태가 아닐 때 표시할 아이콘 + * @param iconSize 아이콘을 표시할 크기. + * 기본값은 [QuackToggleIconSize.Normal] 이며, [trailingText] 이 존재할 때는 + * [QuackToggleIconSize.Small] 로 강제됩니다. + * @param checked 현재 체크 상태에 있는지 여부 + * @param trailingText trailing content 로 배치될 텍스트. + * 만약 null 이 입력될 시 trailing content 를 배치하지 않습니다. + * @param onClick 아이콘을 클릭했을 때 실행될 람다 + */ +@Composable +fun QuackToggleButton( + modifier: Modifier = Modifier, + checkedIcon: QuackIcon, + uncheckedIcon: QuackIcon, + iconSize: QuackToggleIconSize = Normal, + checked: Boolean, + trailingText: String? = null, + onClick: (() -> Unit)? = null, +): Unit = with( + receiver = QuackToggleDefaults.ToggleButton, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy( + space = ItemSpacedBy, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + QuackImage( + src = when (checked) { + true -> checkedIcon + else -> uncheckedIcon + }, + modifier = Modifier + .quackClickable( + rippleEnabled = false, + onClick = onClick, + ) + .size((iconSize.takeIf { trailingText == null } ?: IconSize).size), + ) + trailingText?.let { + QuackText( + text = trailingText, + typography = Typography, + singleLine = true, + ) + } + } +} + +/** + * [Canvas] 에 Check 모양을 그립니다. + * + * animationSpec 으로 항상 [QuackAnimationSpec] 을 사용합니다. + * + * @param value 현재 토글 상태를 의미하는 [ToggleableState] + * @param checkColor Check 색상 + * @param size Check 의 크기 + */ +@Composable +private fun Check( + value: ToggleableState, + checkColor: QuackColor, + size: DpSize, +): Unit = with( + receiver = QuackToggleDefaults.DrawConstaints, +) { + val transition = updateTransition( + targetState = value, + label = TransitionLabel, + ) + val checkDrawFraction by transition.animateFloat( + transitionSpec = { + QuackAnimationSpec() + }, + label = TransitionLabel, + ) { toggleableState -> + when (toggleableState) { + ToggleableState.On -> 1f + ToggleableState.Off -> 0f + ToggleableState.Indeterminate -> 1f + } + } + val checkCenterGravitationShiftFraction by transition.animateFloat( + transitionSpec = { + when { + initialState == ToggleableState.Off -> snap() + targetState == ToggleableState.Off -> snap( + delayMillis = BoxOutDuration, + ) + + else -> QuackAnimationSpec() + } + }, + label = TransitionLabel, + ) { toggleableState -> + when (toggleableState) { + ToggleableState.On -> 0f + ToggleableState.Off -> 0f + ToggleableState.Indeterminate -> 1f + } + } + val checkCache = remember { + CheckDrawingCache() + } + Canvas( + modifier = Modifier + .wrapContentSize( + align = Alignment.Center, + ) + .requiredSize( + size = size, + ), + ) { + val strokeWidthPx = floor( + x = StrokeWidth.toPx(), + ) + drawCheck( + checkColor = checkColor.value, + checkFraction = checkDrawFraction, + crossCenterGravitation = checkCenterGravitationShiftFraction, + strokeWidthPx = strokeWidthPx, + drawingCache = checkCache, + ) + } +} + +// Copied from AOSP +// TODO: documentation +@Immutable +private class CheckDrawingCache( + val checkPath: Path = Path(), + val pathMeasure: PathMeasure = PathMeasure(), + val pathToDraw: Path = Path(), +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + + if (javaClass != other?.javaClass) return false + + other as CheckDrawingCache + + if (checkPath != other.checkPath) return false + if (pathMeasure != other.pathMeasure) return false + if (pathToDraw != other.pathToDraw) return false + + return true + } + + override fun hashCode(): Int { + var result = checkPath.hashCode() + result = 31 * result + pathMeasure.hashCode() + result = 31 * result + pathToDraw.hashCode() + return result + } +} + +/** + * [start], [stop] 사이의 임의의 지점 f(p) 를 반환합니다. + * [fraction] 은 임의의 점 p 까지의 거리를 의미합니다. + * + * @param start 시작 지점 + * @param stop 도착 지점 + * @param fraction 임의의 점 p 까지의 거리 + * + * @return [start], [stop] 사이의 임의의 지점 f(p) + */ +@Suppress("SameParameterValue") +private fun linearInterpolation( + start: Float, + stop: Float, + fraction: Float, +) = (1 - fraction) * start + fraction * stop + +/** + * [DrawScope] 에 체크 표시를 그립니다. + * [crossCenterGravitation] 의 값이 0f -> 1f -> 0f 이므로, + * 중심지점일수록 그려지는 속도가 빨라집니다. + * + * @param checkColor 체크 표시의 색상 + * @param checkFraction 체크 표시가 끝나는 지점까지의 거리 + * @param crossCenterGravitation 중심지점의 중력 + * @param strokeWidthPx 선의 굵기 + * @param drawingCache 그려질 선의 경로를 저장하는 캐시 + */ +@Stable +private fun DrawScope.drawCheck( + checkColor: Color, + checkFraction: Float, + crossCenterGravitation: Float, + strokeWidthPx: Float, + drawingCache: CheckDrawingCache, +): Unit = with( + receiver = QuackToggleDefaults.DrawConstaints, +) { + val stroke = Stroke( + width = strokeWidthPx, + cap = StrokeCap.Round, + ) + val width = size.width + + val gravitatedCrossX = linearInterpolation( + start = CheckCrossX, + stop = StopLocation, + fraction = crossCenterGravitation, + ) + val gravitatedCrossY = linearInterpolation( + start = CheckCrossY, + stop = StopLocation, + fraction = crossCenterGravitation, + ) + + val gravitatedLeftY = linearInterpolation( + start = LeftY, + stop = StopLocation, + fraction = crossCenterGravitation, + ) + val gravitatedRightY = linearInterpolation( + start = RightY, + stop = StopLocation, + fraction = crossCenterGravitation, + ) + + with( + receiver = drawingCache, + ) { + checkPath.reset() + checkPath.moveTo( + x = width * LeftX, + y = width * gravitatedLeftY, + ) + checkPath.lineTo( + x = width * gravitatedCrossX, + y = width * gravitatedCrossY, + ) + checkPath.lineTo( + x = width * RightX, + y = width * gravitatedRightY, + ) + pathMeasure.setPath( + path = checkPath, + forceClosed = false, + ) + pathToDraw.reset() + pathMeasure.getSegment( + startDistance = 0f, + stopDistance = pathMeasure.length * checkFraction, + destination = pathToDraw, + startWithMoveTo = true, + ) + } + drawPath( + path = drawingCache.pathToDraw, + color = checkColor, + style = stroke, + ) +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/screen/SearchTagScreen.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/screen/SearchTagScreen.kt index 335b95c40..3abd32170 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/screen/SearchTagScreen.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/screen/SearchTagScreen.kt @@ -8,6 +8,7 @@ package team.duckie.app.android.common.compose.ui.screen import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -32,14 +33,14 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch import team.duckie.app.android.common.compose.R import team.duckie.app.android.common.compose.ui.ImeSpacer -import team.duckie.app.android.common.compose.ui.icon.v1.CloseId import team.duckie.app.android.common.compose.ui.icon.v1.SearchId import team.duckie.app.android.common.compose.ui.quack.QuackNoUnderlineTextField import team.duckie.app.android.common.compose.ui.quack.todo.QuackLazyVerticalGridTag import team.duckie.app.android.common.compose.ui.quack.todo.QuackTopAppBar -import team.duckie.quackquack.animation.QuackAnimatedVisibility import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.Outlined +import team.duckie.quackquack.material.icon.quackicon.outlined.Close import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.ui.QuackText @@ -86,7 +87,7 @@ fun SearchTagScreen( ) { QuackTopAppBar( leadingText = title, - trailingIconResId = QuackIcon.CloseId, + trailingIcon = QuackIcon.Outlined.Close, onTrailingIconClick = onCloseClick, ) @@ -100,7 +101,6 @@ fun SearchTagScreen( ), horizontalSpace = 4.dp, items = tags, - tagTypeResId = QuackIcon.CloseId, onClick = { onTagClick(it) }, itemChunkedSize = 3, ) @@ -129,7 +129,7 @@ fun SearchTagScreen( ), ) - QuackAnimatedVisibility( + AnimatedVisibility( modifier = Modifier.padding( top = 8.dp, ), diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/temp/TempFlexiblePrimaryLargeButton.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/temp/TempFlexiblePrimaryLargeButton.kt new file mode 100644 index 000000000..03dce6e9e --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/temp/TempFlexiblePrimaryLargeButton.kt @@ -0,0 +1,66 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.temp + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.modifier.quackClickable + +/** + * QuackPrimaryLargeButton의 크키가 조정되지 않는 오류를 대처하기 위한 임시 버튼 + * + * TODO(limsaehyun): 추후 QuackQuackV2 로 대체되어야 함 + */ +@Composable +fun TempFlexiblePrimaryLargeButton( + modifier: Modifier = Modifier, + text: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { + Box( + modifier = modifier + .heightIn(44.dp) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundFor(enabled)) + .quackClickable(onClick = onClickFor(enabled, onClick)), + contentAlignment = Alignment.Center, + ) { + QuackText( + text = text, + typography = QuackTypography.Subtitle.change( + color = QuackColor.White, + ), + ) + } +} + +private fun backgroundFor(enabled: Boolean) = if (enabled) { + QuackColor.DuckieOrange.value +} else { + QuackColor.Gray2.value +} + +private fun onClickFor( + enabled: Boolean, + onClick: () -> Unit, +) = if (enabled) { + onClick +} else { + null +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/temp/TempFlexibleSecondaryLargButton.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/temp/TempFlexibleSecondaryLargButton.kt new file mode 100644 index 000000000..860c79e5e --- /dev/null +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/ui/temp/TempFlexibleSecondaryLargButton.kt @@ -0,0 +1,56 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.compose.ui.temp + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackBorder +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.modifier.quackClickable + +/** + * QuackPrimaryLargeButton의 크키가 조정되지 않는 오류를 대처하기 위한 임시 버튼 + * + * TODO(limsaehyun): 추후 QuackQuackV2 로 대체되어야 함 + */ +@Composable +fun TempFlexibleSecondaryLargeButton( + modifier: Modifier = Modifier, + text: String, + onClick: () -> Unit, +) { + Box( + modifier = modifier + .heightIn(44.dp) + .quackBorder( + border = QuackBorder(color = QuackColor.Gray3), + shape = RoundedCornerShape(8.dp), + ) + .clip(RoundedCornerShape(8.dp)) + .background(QuackColor.White.value) + .quackClickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + QuackText( + text = text, + typography = QuackTypography.Subtitle.change( + color = QuackColor.Black, + ), + ) + } +} diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/util/ExpendedClickable.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/util/ExpendedClickable.kt index 5a3d9715e..902f70dfa 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/util/ExpendedClickable.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/util/ExpendedClickable.kt @@ -7,60 +7,13 @@ package team.duckie.app.android.common.compose.util -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.layout -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.quackClickable -@Preview -@Composable -fun PreviewExpandedClickable() { - Column( - modifier = Modifier - .fillMaxSize() - .background( - color = Color.White, - ) - .padding( - horizontal = 16.dp, - ), - ) { - Box( - modifier = Modifier - .background(Color.Black) - .size(50.dp), - ) - Box( - modifier = Modifier - .background(QuackColor.Gray4.value) - .size(50.dp) - .expendedQuackClickable { - }, - ) - Box( - modifier = Modifier - .background(QuackColor.Gray4.value) - .size(50.dp) - .expendedQuackClickable( - verticalExpendedSize = 24.dp, - horizontalExpendedSize = 24.dp, - ) { - }, - ) - } -} - fun Modifier.expendedQuackClickable( verticalExpendedSize: Dp = 12.dp, horizontalExpendedSize: Dp = 12.dp, diff --git a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/util/HandleKeyBoard.kt b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/util/HandleKeyBoard.kt index 52f4ed6df..b2b5dac7d 100644 --- a/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/util/HandleKeyBoard.kt +++ b/common/compose/src/main/kotlin/team/duckie/app/android/common/compose/util/HandleKeyBoard.kt @@ -9,12 +9,17 @@ package team.duckie.app.android.common.compose.util +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.ModalBottomSheetValue import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import team.duckie.app.android.common.compose.rememberKeyboardVisible @@ -29,3 +34,15 @@ fun HandleKeyboardVisibilityWithSheet(sheetState: ModalBottomSheetState) { } } } + +fun Modifier.addFocusCleaner( + doOnClear: () -> Unit = {}, +): Modifier = composed { + val focusManager = LocalFocusManager.current + pointerInput(Unit) { + detectTapGestures(onTap = { + doOnClear() + focusManager.clearFocus() + },) + } +} diff --git a/common/compose/src/main/res/drawable/ic_clock_12.xml b/common/compose/src/main/res/drawable/ic_clock_12.xml deleted file mode 100644 index 60d169843..000000000 --- a/common/compose/src/main/res/drawable/ic_clock_12.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/common/compose/src/main/res/values/strings.xml b/common/compose/src/main/res/values/strings.xml index 637a17642..7c07eef4b 100644 --- a/common/compose/src/main/res/values/strings.xml +++ b/common/compose/src/main/res/values/strings.xml @@ -18,6 +18,7 @@ 응시자 추천 좋아요 + 차단해제 해당 기능은 개발 예정입니다.\n기대해주세요 최소 2 이상의 columns가 필요합니다. @@ -42,5 +43,5 @@ 네트워크 연결이 불안정해요\n잠시 후 다시 시도해 주세요. 추가한 태그 완료 - 태그 입력하기 + 태그 입력하기 (최대 10자) diff --git a/common/kotlin/src/main/kotlin/team/duckie/app/android/common/kotlin/exception/constant.kt b/common/kotlin/src/main/kotlin/team/duckie/app/android/common/kotlin/exception/constant.kt index c73e39f30..cc9584e2d 100644 --- a/common/kotlin/src/main/kotlin/team/duckie/app/android/common/kotlin/exception/constant.kt +++ b/common/kotlin/src/main/kotlin/team/duckie/app/android/common/kotlin/exception/constant.kt @@ -44,6 +44,8 @@ object ExceptionCode { "KAKAOTALK_NOT_SUPPORT_EXCEPTION" const val HEART_NOT_FOUND = "HEART_NOT_FOUND" + + const val KAKAO_CANCELLED = "KAKAO_CANCELLED" } val Throwable.isHeartNotFound: Boolean @@ -83,3 +85,6 @@ val Throwable.isKakaoTalkNotConnectedAccount: Boolean val Throwable.isKakaoTalkNotSupportAccount: Boolean get() = (this as? DuckieThirdPartyException)?.code == ExceptionCode.KAKAOTALK_NOT_SUPPORT_EXCEPTION + +val Throwable.isKakaoCancelled: Boolean + get() = (this as? DuckieThirdPartyException)?.code == ExceptionCode.KAKAO_CANCELLED diff --git a/common/kotlin/src/main/kotlin/team/duckie/app/android/common/kotlin/number.kt b/common/kotlin/src/main/kotlin/team/duckie/app/android/common/kotlin/number.kt new file mode 100644 index 000000000..acb582cd9 --- /dev/null +++ b/common/kotlin/src/main/kotlin/team/duckie/app/android/common/kotlin/number.kt @@ -0,0 +1,14 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.common.kotlin + +import java.text.DecimalFormat + +private val decimalFormat = DecimalFormat("#,###") + +fun Int.toDecimalFormat() = decimalFormat.format(this) diff --git a/core/datastore/src/main/kotlin/team/duckie/app/android/core/datastore/datastore.kt b/core/datastore/src/main/kotlin/team/duckie/app/android/core/datastore/datastore.kt index 1c323cbfd..b0bcf9ccb 100644 --- a/core/datastore/src/main/kotlin/team/duckie/app/android/core/datastore/datastore.kt +++ b/core/datastore/src/main/kotlin/team/duckie/app/android/core/datastore/datastore.kt @@ -40,6 +40,10 @@ object PreferenceKey { object DevMode { val IsStage = booleanPreferencesKey(buildPreferenceKey(type = "devMode", token = "isStage")) } + + object FeatureFlag { + val IsProceedEnable = booleanPreferencesKey(buildPreferenceKey(type = "feature", token = "isProceedEnable")) + } } val Context.dataStore by preferencesDataStore( diff --git a/data/src/main/kotlin/team/duckie/app/android/data/exam/mapper/mapper.kt b/data/src/main/kotlin/team/duckie/app/android/data/exam/mapper/mapper.kt index 791cffd95..3026b449f 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/exam/mapper/mapper.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/exam/mapper/mapper.kt @@ -10,16 +10,20 @@ package team.duckie.app.android.data.exam.mapper import kotlinx.collections.immutable.toImmutableList import team.duckie.app.android.common.kotlin.AllowCyclomaticComplexMethod import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe +import team.duckie.app.android.common.kotlin.exception.duckieSimpleResponseFieldNpe import team.duckie.app.android.common.kotlin.fastMap import team.duckie.app.android.data.category.mapper.toDomain import team.duckie.app.android.data.exam.model.AnswerData import team.duckie.app.android.data.exam.model.ChoiceData +import team.duckie.app.android.data.exam.model.ExamBlockDetailResponse +import team.duckie.app.android.data.exam.model.ExamBlockResponse import team.duckie.app.android.data.exam.model.ExamBodyData import team.duckie.app.android.data.exam.model.ExamData import team.duckie.app.android.data.exam.model.ExamInfoEntity import team.duckie.app.android.data.exam.model.ExamInstanceBodyData import team.duckie.app.android.data.exam.model.ExamInstanceSubmitBodyData import team.duckie.app.android.data.exam.model.ExamInstanceSubmitData +import team.duckie.app.android.data.exam.model.ExamMeBlocksResponse import team.duckie.app.android.data.exam.model.ExamMeFollowingResponseData import team.duckie.app.android.data.exam.model.ExamThumbnailBodyData import team.duckie.app.android.data.exam.model.ExamsData @@ -37,6 +41,8 @@ import team.duckie.app.android.data.user.mapper.toDomain import team.duckie.app.android.domain.exam.model.Answer import team.duckie.app.android.domain.exam.model.ChoiceModel import team.duckie.app.android.domain.exam.model.Exam +import team.duckie.app.android.domain.exam.model.ExamBlock +import team.duckie.app.android.domain.exam.model.IgnoreExam import team.duckie.app.android.domain.exam.model.ExamBody import team.duckie.app.android.domain.exam.model.ExamInfo import team.duckie.app.android.domain.exam.model.ExamInstanceBody @@ -282,3 +288,20 @@ internal fun SolutionData.toDomain() = Solution( title = title, description = description, ) + +internal fun ExamMeBlocksResponse.toDomain(): List = + exams?.fastMap { it.toDomain() } ?: duckieSimpleResponseFieldNpe("exams") + +internal fun ExamBlockDetailResponse.toDomain() = IgnoreExam( + id = id ?: duckieSimpleResponseFieldNpe("exams"), + title = title ?: duckieSimpleResponseFieldNpe("title"), + thumbnailUrl = thumbnailUrl + ?: duckieSimpleResponseFieldNpe("thumbnailUrl"), + user = user?.toDomain() ?: duckieSimpleResponseFieldNpe("user"), + examBlock = examBlock?.toDomain() + ?: duckieSimpleResponseFieldNpe("examBlock"), +) + +internal fun ExamBlockResponse.toDomain() = ExamBlock( + id = id ?: duckieSimpleResponseFieldNpe("id"), +) diff --git a/data/src/main/kotlin/team/duckie/app/android/data/exam/model/ExamMeBlocksResponse.kt b/data/src/main/kotlin/team/duckie/app/android/data/exam/model/ExamMeBlocksResponse.kt new file mode 100644 index 000000000..4d7d9eb3e --- /dev/null +++ b/data/src/main/kotlin/team/duckie/app/android/data/exam/model/ExamMeBlocksResponse.kt @@ -0,0 +1,27 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.data.exam.model + +import com.fasterxml.jackson.annotation.JsonProperty +import team.duckie.app.android.data.user.model.UserResponse + +data class ExamMeBlocksResponse( + @field:JsonProperty("exams") val exams: List?, +) + +data class ExamBlockDetailResponse( + @field:JsonProperty("id") val id: Int?, + @field:JsonProperty("title") val title: String?, + @field:JsonProperty("thumbnailUrl") val thumbnailUrl: String?, + @field:JsonProperty("user") val user: UserResponse?, + @field:JsonProperty("examBlock") val examBlock: ExamBlockResponse?, +) + +data class ExamBlockResponse( + @field:JsonProperty("id") val id: Int?, +) diff --git a/data/src/main/kotlin/team/duckie/app/android/data/exam/paging/ProfileExamPagingSource.kt b/data/src/main/kotlin/team/duckie/app/android/data/exam/paging/ProfileExamPagingSource.kt index cfee2e23f..9f8c95098 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/exam/paging/ProfileExamPagingSource.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/exam/paging/ProfileExamPagingSource.kt @@ -35,7 +35,7 @@ class ProfileExamPagingSource( } override fun getRefreshKey(state: PagingState): Int? { - return ((state.anchorPosition ?: 0) - state.config.initialLoadSize / 2) - .coerceAtLeast(0) + return ((state.anchorPosition ?: STARTING_KEY) - state.config.initialLoadSize / 2) + .coerceAtLeast(STARTING_KEY) } } diff --git a/data/src/main/kotlin/team/duckie/app/android/data/exam/repository/ExamRepositoryImpl.kt b/data/src/main/kotlin/team/duckie/app/android/data/exam/repository/ExamRepositoryImpl.kt index f834dd1f4..92bc3f173 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/exam/repository/ExamRepositoryImpl.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/exam/repository/ExamRepositoryImpl.kt @@ -11,6 +11,7 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import com.github.kittinunf.fuel.Fuel +import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.request.post @@ -25,17 +26,20 @@ import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe import team.duckie.app.android.data._datasource.client import team.duckie.app.android.data._exception.util.responseCatching import team.duckie.app.android.data._exception.util.responseCatchingFuel +import team.duckie.app.android.data._util.jsonBody import team.duckie.app.android.data._util.toStringJsonMap import team.duckie.app.android.data.exam.datasource.ExamInfoDataSource import team.duckie.app.android.data.exam.mapper.toData import team.duckie.app.android.data.exam.mapper.toDomain import team.duckie.app.android.data.exam.model.ExamData import team.duckie.app.android.data.exam.model.ExamInfoEntity +import team.duckie.app.android.data.exam.model.ExamMeBlocksResponse import team.duckie.app.android.data.exam.model.ExamMeFollowingResponseData import team.duckie.app.android.data.exam.model.ProfileExamDatas import team.duckie.app.android.data.exam.paging.ExamMeFollowingPagingSource import team.duckie.app.android.data.exam.paging.ProfileExamPagingSource import team.duckie.app.android.domain.exam.model.Exam +import team.duckie.app.android.domain.exam.model.IgnoreExam import team.duckie.app.android.domain.exam.model.ExamBody import team.duckie.app.android.domain.exam.model.ExamInfo import team.duckie.app.android.domain.exam.model.ExamThumbnailBody @@ -176,6 +180,27 @@ class ExamRepositoryImpl @Inject constructor( ).flow } + override suspend fun getIgnoreExams(): List { + val response = client.get("exams/me/blocks") + return responseCatching( + response = response, + parse = ExamMeBlocksResponse::toDomain, + ) + } + + override suspend fun cancelExamIgnore(examId: Int): Boolean { + val response = client.delete("exam-block") { + jsonBody { + "targetId" withInt examId + } + } + + return responseCatching(response.status.value, response.bodyAsText()) { body -> + val json = body.toStringJsonMap() + json["success"]?.toBoolean() ?: duckieResponseFieldNpe("success") + } + } + override suspend fun getMadeExams(): List { return examInfoDataSource.getMadeExams().map(ExamInfoEntity::toDomain) } diff --git a/data/src/main/kotlin/team/duckie/app/android/data/examInstance/paging/ProfileExamInstancePagingSource.kt b/data/src/main/kotlin/team/duckie/app/android/data/examInstance/paging/ProfileExamInstancePagingSource.kt index 2e4c57b8e..abb35d36b 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/examInstance/paging/ProfileExamInstancePagingSource.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/examInstance/paging/ProfileExamInstancePagingSource.kt @@ -36,7 +36,7 @@ class ProfileExamInstancePagingSource( } override fun getRefreshKey(state: PagingState): Int? { - return ((state.anchorPosition ?: 0) - state.config.initialLoadSize / 2) - .coerceAtLeast(0) + return ((state.anchorPosition ?: STARTING_KEY) - state.config.initialLoadSize / 2) + .coerceAtLeast(STARTING_KEY) } } diff --git a/data/src/main/kotlin/team/duckie/app/android/data/kakao/repository/KakaoRepositoryImpl.kt b/data/src/main/kotlin/team/duckie/app/android/data/kakao/repository/KakaoRepositoryImpl.kt index 672a26f0e..d061962d3 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/kakao/repository/KakaoRepositoryImpl.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/kakao/repository/KakaoRepositoryImpl.kt @@ -9,13 +9,14 @@ package team.duckie.app.android.data.kakao.repository import android.content.Context import com.kakao.sdk.common.model.AuthError +import com.kakao.sdk.common.model.ClientError +import com.kakao.sdk.common.model.ClientErrorCause import com.kakao.sdk.user.UserApiClient import dagger.hilt.android.qualifiers.ActivityContext import kotlinx.coroutines.suspendCancellableCoroutine import team.duckie.app.android.common.kotlin.exception.DuckieThirdPartyException import team.duckie.app.android.common.kotlin.exception.ExceptionCode import team.duckie.app.android.domain.kakao.repository.KakaoRepository -import java.lang.ref.WeakReference import javax.inject.Inject import kotlin.Result.Companion.failure import kotlin.Result.Companion.success @@ -23,6 +24,10 @@ import kotlin.coroutines.resume private val KakaoLoginException = IllegalStateException("Kakao API response is nothing.") +private val KakaoCancelledException = DuckieThirdPartyException( + code = ExceptionCode.KAKAO_CANCELLED, +) + private val KakaoTalkNotSupportException = DuckieThirdPartyException( code = ExceptionCode.KAKAOTALK_NOT_SUPPORT_EXCEPTION, ) @@ -34,11 +39,9 @@ private const val KakaoNotSupportStatusCode: Int = 302 class KakaoRepositoryImpl @Inject constructor( @ActivityContext private val activityContext: Context, ) : KakaoRepository { - private val _activity = WeakReference(activityContext) - private val activity get() = _activity.get()!! override suspend fun getAccessToken(): String { - return if (UserApiClient.instance.isKakaoTalkLoginAvailable(activity)) { + return if (UserApiClient.instance.isKakaoTalkLoginAvailable(activityContext)) { loginWithKakaoTalk() } else { loginWithWebView() @@ -47,7 +50,7 @@ class KakaoRepositoryImpl @Inject constructor( private suspend fun loginWithKakaoTalk(): String { return suspendCancellableCoroutine { continuation -> - UserApiClient.instance.loginWithKakaoTalk(activity) { token, error -> + UserApiClient.instance.loginWithKakaoTalk(activityContext) { token, error -> continuation.resume( when { error != null -> { @@ -60,6 +63,10 @@ class KakaoRepositoryImpl @Inject constructor( } } + is ClientError -> { + failure(filterKakaoClientError(error)) + } + else -> failure(error) } } @@ -74,10 +81,13 @@ class KakaoRepositoryImpl @Inject constructor( override suspend fun loginWithWebView(): String { return suspendCancellableCoroutine { continuation -> - UserApiClient.instance.loginWithKakaoAccount(activity) { token, error -> + UserApiClient.instance.loginWithKakaoAccount(activityContext) { token, error -> continuation.resume( when { - error != null -> failure(error) + error != null -> when (error) { + is ClientError -> failure(filterKakaoClientError(error)) + else -> failure(error) + } token != null -> success(token.accessToken) else -> failure(KakaoLoginException) }, @@ -85,4 +95,11 @@ class KakaoRepositoryImpl @Inject constructor( } }.getOrThrow() } + + private fun filterKakaoClientError(clientError: ClientError): RuntimeException { + return when (clientError.reason) { + ClientErrorCause.Cancelled -> KakaoCancelledException + else -> clientError + } + } } diff --git a/data/src/main/kotlin/team/duckie/app/android/data/me/repository/MeRepositoryImpl.kt b/data/src/main/kotlin/team/duckie/app/android/data/me/repository/MeRepositoryImpl.kt index e2c89183c..765369ab7 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/me/repository/MeRepositoryImpl.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/me/repository/MeRepositoryImpl.kt @@ -31,6 +31,7 @@ class MeRepositoryImpl @Inject constructor( private val dataStore: DataStore, ) : MeRepository { private var isStageChecked: Boolean = false + private var isProceedEnabled: Boolean? = null private var me: User? = null override suspend fun getMe(): User { // 0. DevMode 에서 API @@ -39,6 +40,9 @@ class MeRepositoryImpl @Inject constructor( devModeDataSource.setApiEnvironment(getIsStage()) } + // 1. 피처 플래그 갱신 + featureFlagCheck() + // 1. DataStore 에 토큰 값이 있는지 체크 val meToken = getMeToken() ?: duckieClientLogicProblemException(code = ClientMeTokenNull) @@ -67,6 +71,15 @@ class MeRepositoryImpl @Inject constructor( } } + /** featureFlag 값을 체크하여, 각 플래그 항목들을 갱신한다. */ + private suspend fun featureFlagCheck() { + isProceedEnabled = if (isProceedEnabled == null) { + dataStore.data.first()[PreferenceKey.FeatureFlag.IsProceedEnable] ?: false + } else { + false + } + } + override suspend fun setMe(newMe: User) { me = newMe } @@ -94,4 +107,10 @@ class MeRepositoryImpl @Inject constructor( // ref: https://medium.com/androiddevelopers/datastore-and-synchronous-work-576f3869ec4c return dataStore.data.first()[PreferenceKey.DevMode.IsStage] ?: false } + + override suspend fun getIsProceedEnable(): Boolean { + // TODO(riflockle7): 더 좋은 구현 방법이 있을까? + // ref: https://medium.com/androiddevelopers/datastore-and-synchronous-work-576f3869ec4c + return dataStore.data.first()[PreferenceKey.FeatureFlag.IsProceedEnable] ?: false + } } diff --git a/data/src/main/kotlin/team/duckie/app/android/data/notification/mapper/mapper.kt b/data/src/main/kotlin/team/duckie/app/android/data/notification/mapper/mapper.kt index f0d0a6f32..309490182 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/notification/mapper/mapper.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/notification/mapper/mapper.kt @@ -7,7 +7,6 @@ package team.duckie.app.android.data.notification.mapper -import team.duckie.app.android.data._util.toDate import team.duckie.app.android.data.notification.model.NotificationResponse import team.duckie.app.android.data.notification.model.NotificationsResponse import team.duckie.app.android.domain.notification.model.Notification @@ -20,10 +19,10 @@ internal fun NotificationsResponse.toDomain() = internal fun NotificationResponse.toDomain() = Notification( id = id ?: duckieResponseFieldNpe("${this::class.java.simpleName}.id"), - title = title ?: duckieResponseFieldNpe("${this::class.java.simpleName}.title"), + title = title, body = body ?: duckieResponseFieldNpe("${this::class.java.simpleName}.body"), thumbnailUrl = thumbnailUrl ?: duckieResponseFieldNpe("${this::class.java.simpleName}.thumbnailUrl"), - createdAt = createdAt?.toDate() + createdAt = createdAt ?: duckieResponseFieldNpe("${this::class.java.simpleName}.createdAt"), ) diff --git a/data/src/main/kotlin/team/duckie/app/android/data/search/paging/SearchExamPagingSource.kt b/data/src/main/kotlin/team/duckie/app/android/data/search/paging/SearchExamPagingSource.kt index 596567a5e..947a7b660 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/search/paging/SearchExamPagingSource.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/search/paging/SearchExamPagingSource.kt @@ -36,7 +36,7 @@ internal class SearchExamPagingSource( } override fun getRefreshKey(state: PagingState): Int { - return ((state.anchorPosition ?: 0) - state.config.initialLoadSize / 2) - .coerceAtLeast(0) + return ((state.anchorPosition ?: SearchExamStartingKey) - state.config.initialLoadSize / 2) + .coerceAtLeast(SearchExamStartingKey) } } diff --git a/data/src/main/kotlin/team/duckie/app/android/data/terms/mapper/data2domain.kt b/data/src/main/kotlin/team/duckie/app/android/data/terms/mapper/data2domain.kt index 0314bb13c..b30994c0c 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/terms/mapper/data2domain.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/terms/mapper/data2domain.kt @@ -7,10 +7,10 @@ package team.duckie.app.android.data.terms.mapper +import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe import team.duckie.app.android.data._util.toDate import team.duckie.app.android.data.terms.model.TermsResponseData import team.duckie.app.android.domain.terms.model.Terms -import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe internal fun TermsResponseData.toDomain() = Terms( id = id ?: duckieResponseFieldNpe("id"), diff --git a/data/src/main/kotlin/team/duckie/app/android/data/user/datasource/UserDataSource.kt b/data/src/main/kotlin/team/duckie/app/android/data/user/datasource/UserDataSource.kt index b88682ee5..1bc3fc96b 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/user/datasource/UserDataSource.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/user/datasource/UserDataSource.kt @@ -9,6 +9,7 @@ package team.duckie.app.android.data.user.datasource import team.duckie.app.android.domain.category.model.Category import team.duckie.app.android.domain.tag.model.Tag +import team.duckie.app.android.domain.user.model.IgnoreUser import team.duckie.app.android.domain.user.model.User import team.duckie.app.android.domain.user.model.UserFollowings import team.duckie.app.android.domain.user.model.UserProfile @@ -39,4 +40,6 @@ interface UserDataSource { suspend fun fetchUserFollowings(userId: Int): List suspend fun fetchUserFollowers(userId: Int): List + + suspend fun fetchIgnoreUsers(): List } diff --git a/data/src/main/kotlin/team/duckie/app/android/data/user/datasource/UserRemoteDataSourceImpl.kt b/data/src/main/kotlin/team/duckie/app/android/data/user/datasource/UserRemoteDataSourceImpl.kt index 183e3c200..82cc18c45 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/user/datasource/UserRemoteDataSourceImpl.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/user/datasource/UserRemoteDataSourceImpl.kt @@ -35,6 +35,8 @@ import team.duckie.app.android.common.kotlin.ExperimentalApi import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe import team.duckie.app.android.common.kotlin.fastMap import team.duckie.app.android.common.kotlin.runtimeCheck +import team.duckie.app.android.data.user.model.UserMeIgnoreResponse +import team.duckie.app.android.domain.user.model.IgnoreUser import javax.inject.Inject class UserRemoteDataSourceImpl @Inject constructor( @@ -155,4 +157,12 @@ class UserRemoteDataSourceImpl @Inject constructor( parse = UsersResponse::toDomain, ) } + + override suspend fun fetchIgnoreUsers(): List { + val response = client.get("users/me/blocks") + return responseCatching( + response = response.body(), + parse = UserMeIgnoreResponse::toDomain, + ) + } } diff --git a/data/src/main/kotlin/team/duckie/app/android/data/user/mapper/data2domain.kt b/data/src/main/kotlin/team/duckie/app/android/data/user/mapper/data2domain.kt index 8875a4899..8b173841a 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/user/mapper/data2domain.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/user/mapper/data2domain.kt @@ -32,7 +32,14 @@ import team.duckie.app.android.domain.user.model.UserFollowings import team.duckie.app.android.domain.user.model.UserProfile import team.duckie.app.android.domain.user.model.toUserAuthStatus import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe +import team.duckie.app.android.common.kotlin.exception.duckieSimpleResponseFieldNpe +import team.duckie.app.android.common.kotlin.exception.getFieldName import team.duckie.app.android.common.kotlin.fastMap +import team.duckie.app.android.data.user.model.IgnoreUserResponse +import team.duckie.app.android.data.user.model.UserBlockResponse +import team.duckie.app.android.data.user.model.UserMeIgnoreResponse +import team.duckie.app.android.domain.user.model.IgnoreUser +import team.duckie.app.android.domain.user.model.UserBlock import kotlin.random.Random private const val NicknameSuffixMaxLength = 10_000 @@ -96,3 +103,18 @@ internal fun UserProfileData.toDomain() = UserProfile( heartExams = heartExams?.map(ProfileExamData::toDomain), user = user?.toDomain(), ) + +internal fun UserBlockResponse.toDomain() = UserBlock( + id = id ?: duckieResponseFieldNpe(getFieldName("id")), +) + +internal fun IgnoreUserResponse.toDomain() = IgnoreUser( + id = id ?: duckieSimpleResponseFieldNpe("id"), + nickName = nickName ?: duckieSimpleResponseFieldNpe("nickName"), + profileImageUrl = profileImageUrl ?: duckieSimpleResponseFieldNpe("profileImageUrl"), + duckPower = duckPower?.toDomain(), + userBlock = userBlock?.toDomain() ?: duckieSimpleResponseFieldNpe("userBlock"), +) + +internal fun UserMeIgnoreResponse.toDomain(): List = + users?.fastMap { it.toDomain() } ?: duckieSimpleResponseFieldNpe("users") diff --git a/data/src/main/kotlin/team/duckie/app/android/data/user/model/UserBlockResponse.kt b/data/src/main/kotlin/team/duckie/app/android/data/user/model/UserBlockResponse.kt new file mode 100644 index 000000000..b763282c9 --- /dev/null +++ b/data/src/main/kotlin/team/duckie/app/android/data/user/model/UserBlockResponse.kt @@ -0,0 +1,15 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.data.user.model + +import com.fasterxml.jackson.annotation.JsonProperty + +data class UserBlockResponse( + @field:JsonProperty("id") + val id: Int? = null, +) diff --git a/data/src/main/kotlin/team/duckie/app/android/data/user/model/UserMeIgnoreResponse.kt b/data/src/main/kotlin/team/duckie/app/android/data/user/model/UserMeIgnoreResponse.kt new file mode 100644 index 000000000..4047c7c42 --- /dev/null +++ b/data/src/main/kotlin/team/duckie/app/android/data/user/model/UserMeIgnoreResponse.kt @@ -0,0 +1,24 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.data.user.model + +import com.fasterxml.jackson.annotation.JsonProperty + +data class UserMeIgnoreResponse( + @field:JsonProperty("users") + val users: List? = null, +) + +data class IgnoreUserResponse( + @field:JsonProperty("id") val id: Int? = null, + @field:JsonProperty("nickName") val nickName: String? = null, + @field:JsonProperty("profileImageUrl") val profileImageUrl: String? = null, + @field:JsonProperty("duckPower") val duckPower: DuckPowerResponse? = null, + @field:JsonProperty("userBlock") val userBlock: UserBlockResponse? = null, + @field:JsonProperty("permissions") val permissions: List? = null, +) diff --git a/data/src/main/kotlin/team/duckie/app/android/data/user/repository/UserRepositoryImpl.kt b/data/src/main/kotlin/team/duckie/app/android/data/user/repository/UserRepositoryImpl.kt index 3c11d1ee2..b506b0e66 100644 --- a/data/src/main/kotlin/team/duckie/app/android/data/user/repository/UserRepositoryImpl.kt +++ b/data/src/main/kotlin/team/duckie/app/android/data/user/repository/UserRepositoryImpl.kt @@ -12,6 +12,7 @@ import team.duckie.app.android.common.kotlin.ExperimentalApi import team.duckie.app.android.data.user.datasource.UserDataSource import team.duckie.app.android.domain.category.model.Category import team.duckie.app.android.domain.tag.model.Tag +import team.duckie.app.android.domain.user.model.IgnoreUser import team.duckie.app.android.domain.user.model.User import team.duckie.app.android.domain.user.model.UserFollowings import team.duckie.app.android.domain.user.model.UserProfile @@ -66,4 +67,8 @@ class UserRepositoryImpl @Inject constructor( override suspend fun fetchUserFollowers(userId: Int): List { return userDataSource.fetchUserFollowers(userId) } + + override suspend fun fetchIgnoreUsers(): List { + return userDataSource.fetchIgnoreUsers() + } } diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/DeleteExamBlockResponse.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/DeleteExamBlockResponse.kt new file mode 100644 index 000000000..88613b4cb --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/DeleteExamBlockResponse.kt @@ -0,0 +1,15 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.exam.model + +import com.fasterxml.jackson.annotation.JsonProperty + +data class DeleteExamBlockResponse( + @field:JsonProperty("success") + val success: Boolean, +) diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/ExamBlock.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/ExamBlock.kt new file mode 100644 index 000000000..099bd7529 --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/ExamBlock.kt @@ -0,0 +1,12 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.exam.model + +data class ExamBlock( + val id: Int, +) diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/IgnoreExam.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/IgnoreExam.kt new file mode 100644 index 000000000..cf6f385db --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/model/IgnoreExam.kt @@ -0,0 +1,18 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.exam.model + +import team.duckie.app.android.domain.user.model.User + +data class IgnoreExam( + val id: Int, + val title: String, + val thumbnailUrl: String, + val user: User, + val examBlock: ExamBlock, +) diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/exam/repository/ExamRepository.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/repository/ExamRepository.kt index 098b50f9e..5d9733d6b 100644 --- a/domain/src/main/kotlin/team/duckie/app/android/domain/exam/repository/ExamRepository.kt +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/repository/ExamRepository.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Immutable import androidx.paging.PagingData import kotlinx.coroutines.flow.Flow import team.duckie.app.android.domain.exam.model.Exam +import team.duckie.app.android.domain.exam.model.IgnoreExam import team.duckie.app.android.domain.exam.model.ExamBody import team.duckie.app.android.domain.exam.model.ExamInfo import team.duckie.app.android.domain.exam.model.ExamThumbnailBody @@ -37,4 +38,8 @@ interface ExamRepository { suspend fun getHeartExam(userId: Int): Flow> suspend fun getSubmittedExam(userId: Int): Flow> + + suspend fun getIgnoreExams(): List + + suspend fun cancelExamIgnore(examId: Int): Boolean } diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/exam/usecase/CancelExamIgnoreUseCase.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/usecase/CancelExamIgnoreUseCase.kt new file mode 100644 index 000000000..be25c1cd7 --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/usecase/CancelExamIgnoreUseCase.kt @@ -0,0 +1,22 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.exam.usecase + +import androidx.compose.runtime.Immutable +import team.duckie.app.android.domain.exam.repository.ExamRepository +import javax.inject.Inject + +@Immutable +class CancelExamIgnoreUseCase @Inject constructor( + private val examRepository: ExamRepository, +) { + + suspend operator fun invoke(examId: Int) = runCatching { + examRepository.cancelExamIgnore(examId = examId) + } +} diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/exam/usecase/GetExamIgnoresUseCase.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/usecase/GetExamIgnoresUseCase.kt new file mode 100644 index 000000000..9a5a247cc --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/exam/usecase/GetExamIgnoresUseCase.kt @@ -0,0 +1,22 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.exam.usecase + +import androidx.compose.runtime.Immutable +import team.duckie.app.android.domain.exam.repository.ExamRepository +import javax.inject.Inject + +@Immutable +class GetExamIgnoresUseCase @Inject constructor( + private val examRepository: ExamRepository, +) { + + suspend operator fun invoke() = runCatching { + examRepository.getIgnoreExams() + } +} diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/me/MeRepository.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/me/MeRepository.kt index af0e45c5f..1c2853996 100644 --- a/domain/src/main/kotlin/team/duckie/app/android/domain/me/MeRepository.kt +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/me/MeRepository.kt @@ -18,4 +18,6 @@ interface MeRepository { suspend fun clearMeToken() suspend fun getIsStage(): Boolean + + suspend fun getIsProceedEnable(): Boolean } diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/me/usecase/GetAllFeatureFlagsUseCase.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/me/usecase/GetAllFeatureFlagsUseCase.kt new file mode 100644 index 000000000..805c8d312 --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/me/usecase/GetAllFeatureFlagsUseCase.kt @@ -0,0 +1,22 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.me.usecase + +import androidx.compose.runtime.Immutable +import team.duckie.app.android.domain.me.MeRepository +import javax.inject.Inject + +// TODO(riflockle7): 그냥 모든 피처 플래그를 Map 형태로 받아내는 건 어떨까? +@Immutable +class GetIsProceedEnableUseCase @Inject constructor( + private val repository: MeRepository, +) { + suspend operator fun invoke(): Result { + return runCatching { repository.getIsProceedEnable() } + } +} diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/notification/model/Notification.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/notification/model/Notification.kt index 989aae21f..143321a7c 100644 --- a/domain/src/main/kotlin/team/duckie/app/android/domain/notification/model/Notification.kt +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/notification/model/Notification.kt @@ -8,15 +8,14 @@ package team.duckie.app.android.domain.notification.model import androidx.compose.runtime.Immutable -import java.util.Date @Immutable data class Notification( val id: Int, - val title: String, + val title: String?, val body: String, val thumbnailUrl: String, - val createdAt: Date, + val createdAt: String, ) { companion object { fun empty(id: Int = 0) = Notification( @@ -24,7 +23,7 @@ data class Notification( title = "", body = "", thumbnailUrl = "", - createdAt = Date(0), + createdAt = "", ) } } diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/user/model/IgnoreUser.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/user/model/IgnoreUser.kt new file mode 100644 index 000000000..c283a9096 --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/user/model/IgnoreUser.kt @@ -0,0 +1,16 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.user.model + +data class IgnoreUser( + val id: Int, + val nickName: String, + val profileImageUrl: String, + val duckPower: DuckPower?, + val userBlock: UserBlock, +) diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/user/model/UserBlock.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/user/model/UserBlock.kt new file mode 100644 index 000000000..6fe083bfd --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/user/model/UserBlock.kt @@ -0,0 +1,12 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.user.model + +data class UserBlock( + val id: Int, +) diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/user/repository/UserRepository.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/user/repository/UserRepository.kt index 0e504533b..26c187584 100644 --- a/domain/src/main/kotlin/team/duckie/app/android/domain/user/repository/UserRepository.kt +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/user/repository/UserRepository.kt @@ -10,6 +10,7 @@ package team.duckie.app.android.domain.user.repository import androidx.compose.runtime.Immutable import team.duckie.app.android.domain.category.model.Category import team.duckie.app.android.domain.tag.model.Tag +import team.duckie.app.android.domain.user.model.IgnoreUser import team.duckie.app.android.domain.user.model.User import team.duckie.app.android.domain.user.model.UserFollowings import team.duckie.app.android.domain.user.model.UserProfile @@ -41,4 +42,6 @@ interface UserRepository { suspend fun fetchUserFollowings(userId: Int): List suspend fun fetchUserFollowers(userId: Int): List + + suspend fun fetchIgnoreUsers(): List } diff --git a/domain/src/main/kotlin/team/duckie/app/android/domain/user/usecase/FetchIgnoreUsersUseCase.kt b/domain/src/main/kotlin/team/duckie/app/android/domain/user/usecase/FetchIgnoreUsersUseCase.kt new file mode 100644 index 000000000..17dd0891d --- /dev/null +++ b/domain/src/main/kotlin/team/duckie/app/android/domain/user/usecase/FetchIgnoreUsersUseCase.kt @@ -0,0 +1,23 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.domain.user.usecase + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.toImmutableList +import team.duckie.app.android.domain.user.repository.UserRepository +import javax.inject.Inject + +@Immutable +class FetchIgnoreUsersUseCase @Inject constructor( + private val userRepository: UserRepository, +) { + + suspend operator fun invoke() = runCatching { + userRepository.fetchIgnoreUsers().toImmutableList() + } +} diff --git a/feature/create-problem/build.gradle.kts b/feature/create-exam/build.gradle.kts similarity index 87% rename from feature/create-problem/build.gradle.kts rename to feature/create-exam/build.gradle.kts index 7b5a69b6c..3af91becb 100644 --- a/feature/create-problem/build.gradle.kts +++ b/feature/create-exam/build.gradle.kts @@ -14,7 +14,7 @@ plugins { } android { - namespace = "team.duckie.app.android.feature.create.problem" + namespace = "team.duckie.app.android.feature.create.exam" } dependencies { @@ -32,7 +32,8 @@ dependencies { libs.ktx.lifecycle.runtime, libs.compose.lifecycle.runtime, libs.compose.ui.material, // needs for Scaffold - libs.quack.ui.components, + libs.quack.v2.ui, + libs.kotlin.collections.immutable, libs.firebase.crashlytics, ) } diff --git a/feature/create-problem/src/main/AndroidManifest.xml b/feature/create-exam/src/main/AndroidManifest.xml similarity index 89% rename from feature/create-problem/src/main/AndroidManifest.xml rename to feature/create-exam/src/main/AndroidManifest.xml index d606d1dd0..632826cd7 100644 --- a/feature/create-problem/src/main/AndroidManifest.xml +++ b/feature/create-exam/src/main/AndroidManifest.xml @@ -10,7 +10,7 @@ diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/CreateProblemActivity.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/CreateExamActivity.kt similarity index 77% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/CreateProblemActivity.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/CreateExamActivity.kt index ed9e78e1a..822ada064 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/CreateProblemActivity.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/CreateExamActivity.kt @@ -5,17 +5,19 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem +package team.duckie.app.android.feature.create.exam import android.os.Bundle import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.CircularProgressIndicator import androidx.compose.runtime.LaunchedEffect @@ -30,27 +32,27 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.orbitmvi.orbit.compose.collectAsState -import team.duckie.app.android.feature.create.problem.screen.AdditionalInformationScreen -import team.duckie.app.android.feature.create.problem.screen.CreateProblemScreen -import team.duckie.app.android.feature.create.problem.screen.ExamInformationScreen -import team.duckie.app.android.feature.create.problem.screen.SearchTagScreen -import team.duckie.app.android.feature.create.problem.viewmodel.CreateProblemViewModel -import team.duckie.app.android.feature.create.problem.viewmodel.sideeffect.CreateProblemSideEffect -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemStep -import team.duckie.app.android.common.compose.ui.ErrorScreen -import team.duckie.app.android.common.compose.ui.LoadingScreen import team.duckie.app.android.common.android.exception.handling.reporter.reportToToast -import team.duckie.app.android.common.kotlin.exception.DuckieResponseException import team.duckie.app.android.common.android.ui.BaseActivity import team.duckie.app.android.common.android.ui.finishWithAnimation -import team.duckie.quackquack.ui.animation.QuackAnimatedContent -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackTitle1 -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.app.android.common.compose.ui.ErrorScreen +import team.duckie.app.android.common.compose.ui.LoadingScreen +import team.duckie.app.android.common.kotlin.exception.DuckieResponseException +import team.duckie.app.android.feature.create.exam.screen.AdditionalInformationScreen +import team.duckie.app.android.feature.create.exam.screen.CreateExamScreen +import team.duckie.app.android.feature.create.exam.screen.ExamInformationScreen +import team.duckie.app.android.feature.create.exam.screen.SearchTagScreen +import team.duckie.app.android.feature.create.exam.viewmodel.CreateProblemViewModel +import team.duckie.app.android.feature.create.exam.viewmodel.sideeffect.CreateProblemSideEffect +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemStep +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.material.theme.QuackTheme +import team.duckie.quackquack.ui.QuackText @AndroidEntryPoint -class CreateProblemActivity : BaseActivity() { +class CreateExamActivity : BaseActivity() { private val viewModel: CreateProblemViewModel by viewModels() @@ -58,13 +60,13 @@ class CreateProblemActivity : BaseActivity() { super.onCreate(savedInstanceState) setContent { val rootState = viewModel.collectAsState().value - val createProblemStep = rootState.createProblemStep + val createExamStep = rootState.createExamStep val isMakeExamUploading = remember(rootState.isMakeExamUploading) { rootState.isMakeExamUploading } BackHandler { - when (createProblemStep) { + when (createExamStep) { CreateProblemStep.Loading, CreateProblemStep.Error -> finishWithAnimation() else -> {} } @@ -77,11 +79,12 @@ class CreateProblemActivity : BaseActivity() { } QuackTheme { - QuackAnimatedContent( + AnimatedContent( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor), - targetState = createProblemStep, + .background(color = QuackColor.White.value), + targetState = createExamStep, + label = "AnimatedContent", ) { step: CreateProblemStep -> when (step) { CreateProblemStep.Loading -> LoadingScreen( @@ -103,7 +106,7 @@ class CreateProblemActivity : BaseActivity() { .statusBarsPadding(), ) - CreateProblemStep.CreateProblem -> CreateProblemScreen( + CreateProblemStep.CreateExam -> CreateExamScreen( modifier = Modifier .fillMaxSize() .statusBarsPadding(), @@ -131,20 +134,20 @@ class CreateProblemActivity : BaseActivity() { modifier = Modifier .quackClickable(rippleEnabled = false) {} .fillMaxSize() - .background(color = QuackColor.Dimmed.composeColor), + .background(color = QuackColor.Dimmed.value), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { // 로딩 바 CircularProgressIndicator( - color = QuackColor.DuckieOrange.composeColor, + color = QuackColor.DuckieOrange.value, ) // 제목 - QuackTitle1( + QuackText( + modifier = Modifier.padding(PaddingValues(top = 8.dp)), text = stringResource(id = R.string.make_exam_loading), - color = QuackColor.White, - padding = PaddingValues(top = 8.dp), + typography = QuackTypography.Title1.change(QuackColor.White), ) } } diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/CreateProblemBottomLayout.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/CreateExamBottomLayout.kt similarity index 76% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/CreateProblemBottomLayout.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/CreateExamBottomLayout.kt index 5e7ecabd4..00b751ce0 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/CreateProblemBottomLayout.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/CreateExamBottomLayout.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.common +package team.duckie.app.android.feature.create.exam.common import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -13,27 +13,28 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.MeasurePolicy import androidx.compose.ui.layout.layoutId import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import team.duckie.app.android.common.compose.asLoose +import team.duckie.app.android.common.compose.ui.QuackDivider import team.duckie.app.android.common.kotlin.fastFirstOrNull import team.duckie.app.android.common.kotlin.npe -import team.duckie.quackquack.ui.border.QuackBorder -import team.duckie.quackquack.ui.border.applyAnimatedQuackBorder -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackDivider -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.component.QuackSubtitle2 -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackBorder +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.QuackText internal fun getCreateProblemMeasurePolicy( topLayoutId: String, @@ -55,7 +56,7 @@ internal fun getCreateProblemMeasurePolicy( // TODO(riflockle7): 왜 이걸 더해야하는지 모르겠음.. padding 을 더하면 이렇게 됨... val bottomLayoutHeight = topAppBarMeasurable.height + 72.toDp().toPx().toInt() - // 3. createProblemButton 높이값 측정 + // 3. createExamButton 높이값 측정 val contentThresholdHeight = constraints.maxHeight - topAppBarHeight - bottomLayoutHeight val contentConstraints = constraints.copy( minHeight = contentThresholdHeight, @@ -88,7 +89,7 @@ internal fun getCreateProblemMeasurePolicy( @Composable internal fun CreateProblemBottomLayout( modifier: Modifier, - leftButtonLeadingIcon: QuackIcon? = null, + leftButtonLeadingIcon: ImageVector? = null, leftButtonText: String? = null, leftButtonClick: (() -> Unit)? = null, tempSaveButtonText: String? = null, @@ -99,7 +100,7 @@ internal fun CreateProblemBottomLayout( isValidateCheck: () -> Boolean, ) { val isValidate = isValidateCheck() - Column(modifier = modifier.background(QuackColor.White.composeColor)) { + Column(modifier = modifier.background(QuackColor.White.value)) { QuackDivider() Row( modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), @@ -119,10 +120,21 @@ internal fun CreateProblemBottomLayout( .padding(4.dp), ) { // TODO(riflockle7): 추후 비활성화 될 때의 resouce 이미지 필요 - leftButtonLeadingIcon?.let { QuackImage(src = it, size = DpSize(16.dp, 16.dp)) } - QuackSubtitle2( + leftButtonLeadingIcon?.let { + QuackIcon( + modifier = modifier.size(DpSize(16.dp, 16.dp)), + icon = it, + ) + } + QuackText( text = leftButtonText, - color = if (isCreateProblemValidate) QuackColor.Black else QuackColor.Gray2, + typography = QuackTypography.Subtitle2.change( + if (isCreateProblemValidate) { + QuackColor.Black + } else { + QuackColor.Gray2 + }, + ), ) } } @@ -132,30 +144,30 @@ internal fun CreateProblemBottomLayout( // 임시저장 버튼 tempSaveButtonClick?.let { requireNotNull(tempSaveButtonText) - QuackSubtitle( + QuackText( modifier = Modifier .clip(RoundedCornerShape(size = 8.dp)) - .background(QuackColor.White.composeColor) + .background(QuackColor.White.value) .quackClickable(onClick = tempSaveButtonClick) - .applyAnimatedQuackBorder( + .quackBorder( QuackBorder(1.dp, QuackColor.Gray3), shape = RoundedCornerShape(size = 8.dp), ) .padding(vertical = 12.dp, horizontal = 19.dp), - color = QuackColor.Black, + typography = QuackTypography.Subtitle.change(QuackColor.Black), text = tempSaveButtonText, ) } // 다음 버튼 - QuackSubtitle( + QuackText( modifier = Modifier .clip(RoundedCornerShape(size = 8.dp)) .background( if (isValidate) { - QuackColor.DuckieOrange.composeColor + QuackColor.DuckieOrange.value } else { - QuackColor.Gray2.composeColor + QuackColor.Gray2.value }, ) .quackClickable { @@ -163,7 +175,7 @@ internal fun CreateProblemBottomLayout( nextButtonClick() } } - .applyAnimatedQuackBorder( + .quackBorder( QuackBorder( 1.dp, if (isValidate) { @@ -178,7 +190,7 @@ internal fun CreateProblemBottomLayout( vertical = 12.dp, horizontal = 19.dp, ), - color = QuackColor.White, + typography = QuackTypography.Subtitle.change(QuackColor.White), text = nextButtonText, ) } diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/FadeAnimatedVisibility.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/FadeAnimatedVisibility.kt similarity index 91% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/FadeAnimatedVisibility.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/FadeAnimatedVisibility.kt index 1c0cf2ddd..7bf284fec 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/FadeAnimatedVisibility.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/FadeAnimatedVisibility.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.common +package team.duckie.app.android.feature.create.exam.common import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/FindTagItem.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/FindTagItem.kt similarity index 59% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/FindTagItem.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/FindTagItem.kt index 0dd633f7e..0edf05886 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/FindTagItem.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/FindTagItem.kt @@ -5,22 +5,25 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.common +package team.duckie.app.android.feature.create.exam.common import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import team.duckie.quackquack.ui.component.QuackBody1 +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.sugar.QuackBody1 @Composable internal fun SearchResultText( text: String, onClick: () -> Unit, ) = QuackBody1( - modifier = Modifier.fillMaxWidth(), - padding = PaddingValues(vertical = 12.dp), + modifier = Modifier + .quackClickable(onClick = onClick) + .fillMaxWidth() + .padding(PaddingValues(vertical = 12.dp)), text = text, - onClick = onClick, ) diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/NoLazyGridItems.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/NoLazyGridItems.kt similarity index 98% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/NoLazyGridItems.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/NoLazyGridItems.kt index 21ba7a498..e3108b3eb 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/NoLazyGridItems.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/NoLazyGridItems.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.common +package team.duckie.app.android.feature.create.exam.common import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TextfieldOptions.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TextfieldOptions.kt similarity index 92% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TextfieldOptions.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TextfieldOptions.kt index 4197ca464..e51ec7afa 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TextfieldOptions.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TextfieldOptions.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.common +package team.duckie.app.android.feature.create.exam.common import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TitleAndComponent.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TitleAndComponent.kt similarity index 92% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TitleAndComponent.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TitleAndComponent.kt index ceb6798f2..ed552774f 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TitleAndComponent.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TitleAndComponent.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.common +package team.duckie.app.android.feature.create.exam.common import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement @@ -15,7 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import team.duckie.quackquack.ui.component.QuackHeadLine2 +import team.duckie.quackquack.ui.sugar.QuackHeadLine2 @Suppress("FunctionName") internal fun LazyListScope.TitleAndComponent( diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TopAppBar.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TopAppBar.kt similarity index 71% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TopAppBar.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TopAppBar.kt index 8f293b57b..14423fede 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/common/TopAppBar.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/TopAppBar.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.common +package team.duckie.app.android.feature.create.exam.common import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -16,11 +16,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import team.duckie.app.android.feature.create.problem.R -import team.duckie.quackquack.ui.component.QuackHeadLine2 -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackTopAppBar -import team.duckie.quackquack.ui.icon.QuackIcon +import team.duckie.app.android.common.compose.ui.quack.todo.QuackTopAppBar +import team.duckie.app.android.feature.create.exam.R +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.ArrowBack +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.sugar.QuackHeadLine2 @Composable internal fun PrevAndNextTopAppBar( @@ -31,8 +34,8 @@ internal fun PrevAndNextTopAppBar( trailingTextEnabled: Boolean = false, ) { // TODO(EvergreenTree97): enabled 속성 필요 QuackTopAppBar( - modifier = modifier, - leadingIcon = QuackIcon.ArrowBack, + modifier = modifier.padding(12.dp), + leadingIcon = OutlinedGroup.ArrowBack, leadingText = stringResource(id = R.string.create_problem), onLeadingIconClick = onLeadingIconClick, trailingText = trailingText, @@ -64,9 +67,9 @@ internal fun ExitAppBar( horizontalArrangement = Arrangement.SpaceBetween, ) { QuackHeadLine2(text = leadingText) - QuackImage( - src = QuackIcon.Close, - onClick = onTrailingIconClick, + QuackIcon( + modifier = Modifier.quackClickable(onClick = onTrailingIconClick), + icon = OutlinedGroup.Close, ) } } diff --git a/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/Constant.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/Constant.kt new file mode 100644 index 000000000..ca0e2fa36 --- /dev/null +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/Constant.kt @@ -0,0 +1,10 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.feature.create.exam.common.type + +internal const val MaximumChoice = 5 diff --git a/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/ImageChoiceLayout.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/ImageChoiceLayout.kt new file mode 100644 index 000000000..7305dc4df --- /dev/null +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/ImageChoiceLayout.kt @@ -0,0 +1,196 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +@file:OptIn(ExperimentalDesignToken::class, ExperimentalQuackQuackApi::class) + +package team.duckie.app.android.feature.create.exam.common.type + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.quack.todo.animation.QuackRoundCheckBox +import team.duckie.app.android.domain.exam.model.Answer +import team.duckie.app.android.domain.exam.model.Question +import team.duckie.app.android.feature.create.exam.R +import team.duckie.app.android.feature.create.exam.common.NoLazyGridItems +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.material.icon.quackicon.outlined.Image +import team.duckie.quackquack.material.quackBorder +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackDefaultTextField +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.QuackTextFieldStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.sugar.QuackSubtitle +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi + +/** + * 객관식/사진 Layout + * // TODO(riflockle7): 정답 체크 연동 필요 + */ +@Composable +internal fun ImageChoiceLayout( + questionIndex: Int, + question: Question?, + titleChanged: (String) -> Unit, + imageClick: () -> Unit, + onDropdownItemClick: (Int) -> Unit, + answers: Answer.ImageChoice, + answerTextChanged: (String, Int) -> Unit, + answerImageClick: (Int) -> Unit, + addAnswerClick: () -> Unit, + correctAnswers: String?, + setCorrectAnswerClick: (String) -> Unit, + deleteLongClick: (Int?) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .quackClickable( + onLongClick = { deleteLongClick(null) }, + ) {}, + ) { + TitleView( + questionIndex, + question, + titleChanged, + imageClick, + answers.type.title, + onDropdownItemClick, + ) + + NoLazyGridItems( + count = answers.imageChoice.size, + nColumns = 2, + paddingValues = PaddingValues(top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + itemContent = { answerIndex -> + val answerNo = answerIndex + 1 + val answerItem = answers.imageChoice[answerIndex] + val isChecked = correctAnswers == "$answerIndex" + + Column( + modifier = Modifier + .fillMaxWidth() + .quackBorder(border = QuackBorder(color = QuackColor.Gray4)) + .quackBorder( + border = QuackBorder( + color = if (isChecked) { + QuackColor.DuckieOrange + } else { + QuackColor.Gray4 + }, + ), + ) + .padding(12.dp), + ) { + Row( + modifier = Modifier.padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + QuackRoundCheckBox( + modifier = Modifier.quackClickable( + onClick = { + setCorrectAnswerClick(if (isChecked) "" else "$answerIndex") + }, + ), + checked = isChecked, + ) + + if (isChecked) { + QuackText( + modifier = Modifier.padding(start = 2.dp), + typography = QuackTypography.Body3.change(QuackColor.DuckieOrange), + text = stringResource(id = R.string.answer), + ) + } + + Spacer(Modifier.weight(1f)) + + QuackIcon( + icon = OutlinedGroup.Close, + modifier = Modifier + .quackClickable( + onClick = { deleteLongClick(answerIndex) }, + ) + .size(DpSize(20.dp, 20.dp)), + ) + } + + if (answerItem.imageUrl.isEmpty()) { + Box( + modifier = Modifier + .quackClickable { answerImageClick(answerIndex) } + .background(color = QuackColor.Gray4.value) + .padding(52.dp), + ) { + QuackIcon( + modifier = Modifier.size(DpSize(32.dp, 32.dp)), + icon = OutlinedGroup.Image, + ) + } + } else { + QuackImage( + modifier = Modifier + .quackClickable( + onClick = { answerImageClick(answerIndex) }, + onLongClick = { deleteLongClick(answerIndex) }, + ) + .size(DpSize(136.dp, 136.dp)), + src = answerItem.imageUrl, + ) + } + + // TODO(riflockle7): 동작 확인 필요 + QuackDefaultTextField( + value = answers.imageChoice[answerIndex].text, + onValueChange = { newAnswer -> + answerTextChanged(newAnswer, answerIndex) + }, + placeholderText = stringResource( + id = R.string.create_problem_answer_placeholder, + "$answerNo", + ), + style = QuackTextFieldStyle.Default, + ) + } + }, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + if (answers.imageChoice.size < MaximumChoice) { + QuackSubtitle( + modifier = Modifier + .quackClickable(onClick = addAnswerClick) + .padding(vertical = 2.dp, horizontal = 4.dp), + text = stringResource(id = R.string.create_problem_add_button), + ) + } + } +} diff --git a/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/ShortAnswerLayout.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/ShortAnswerLayout.kt new file mode 100644 index 000000000..6cbd28087 --- /dev/null +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/ShortAnswerLayout.kt @@ -0,0 +1,65 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +@file:OptIn(ExperimentalDesignToken::class, ExperimentalQuackQuackApi::class) + +package team.duckie.app.android.feature.create.exam.common.type + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import team.duckie.app.android.domain.exam.model.Answer +import team.duckie.app.android.domain.exam.model.Question +import team.duckie.app.android.feature.create.exam.R +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackDefaultTextField +import team.duckie.quackquack.ui.QuackTextFieldStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi + +/** 주관식 Layout */ +@Composable +internal fun ShortAnswerLayout( + questionIndex: Int, + question: Question?, + titleChanged: (String) -> Unit, + imageClick: () -> Unit, + onDropdownItemClick: (Int) -> Unit, + answer: String, + answerTextChanged: (String, Int) -> Unit, + deleteLongClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .quackClickable( + onLongClick = { deleteLongClick() }, + ) {}, + ) { + TitleView( + questionIndex, + question, + titleChanged, + imageClick, + Answer.Type.ShortAnswer.title, + onDropdownItemClick, + ) + + // TODO(riflockle7): 동작 확인 필요 + QuackDefaultTextField( + value = answer, + onValueChange = { newAnswer -> answerTextChanged(newAnswer, 0) }, + placeholderText = stringResource(id = R.string.create_problem_short_answer_placeholder), + style = QuackTextFieldStyle.Default, + ) + } +} diff --git a/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/TextChoiceLayout.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/TextChoiceLayout.kt new file mode 100644 index 000000000..348b80da0 --- /dev/null +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/TextChoiceLayout.kt @@ -0,0 +1,129 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +@file:OptIn( + ExperimentalDesignToken::class, + ExperimentalDesignToken::class, + ExperimentalQuackQuackApi::class, +) + +package team.duckie.app.android.feature.create.exam.common.type + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.kotlin.fastForEachIndexed +import team.duckie.app.android.domain.exam.model.Answer +import team.duckie.app.android.domain.exam.model.Question +import team.duckie.app.android.feature.create.exam.R +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.quackBorder +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackDefaultTextField +import team.duckie.quackquack.ui.QuackTextFieldStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.sugar.QuackSubtitle +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi + +/** 객관식/글 Layout */ +@Composable +@Suppress("unused") +internal fun TextChoiceLayout( + questionIndex: Int, + question: Question?, + titleChanged: (String) -> Unit, + imageClick: () -> Unit, + onDropdownItemClick: (Int) -> Unit, + answers: Answer.Choice, + answerTextChanged: (String, Int) -> Unit, + addAnswerClick: () -> Unit, + correctAnswers: String?, + setCorrectAnswerClick: (String) -> Unit, + deleteLongClick: (Int?) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .quackClickable( + onLongClick = { deleteLongClick(null) }, + ) {}, + ) { + TitleView( + questionIndex, + question, + titleChanged, + imageClick, + answers.type.title, + onDropdownItemClick, + ) + + answers.choices.fastForEachIndexed { answerIndex, choiceModel -> + val answerNo = answerIndex + 1 + val isChecked = correctAnswers == "$answerIndex" + QuackDefaultTextField( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .quackBorder( + border = QuackBorder( + color = if (isChecked) QuackColor.DuckieOrange else QuackColor.Gray4, + ), + ) + .quackClickable( + onLongClick = { deleteLongClick(answerIndex) }, + ) {}, + value = choiceModel.text, + onValueChange = { newAnswer -> answerTextChanged(newAnswer, answerIndex) }, + placeholderText = stringResource( + id = R.string.create_problem_answer_placeholder, + "$answerNo", + ), + style = QuackTextFieldStyle.Default, + // TODO(riflockle7): 꽥꽥 기능 제공 안함 + // trailingContent = { + // Column( + // modifier = Modifier.quackClickable( + // onClick = { + // setCorrectAnswerClick(if (isChecked) "" else "$answerIndex") + // }, + // ), + // horizontalAlignment = Alignment.CenterHorizontally, + // ) { + // QuackRoundCheckBox(checked = isChecked) + // + // if (isChecked) { + // QuackText( + // modifier = Modifier.padding(top = 2.dp), + // typography = QuackTypography.Body3.change(QuackColor.DuckieOrange), + // text = stringResource(id = R.string.answer), + // ) + // } + // } + // }, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + if (answers.choices.size < MaximumChoice) { + QuackSubtitle( + modifier = Modifier + .quackClickable(onClick = addAnswerClick) + .padding(vertical = 2.dp, horizontal = 4.dp), + text = stringResource(id = R.string.create_problem_add_button), + ) + } + } +} diff --git a/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/TitleView.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/TitleView.kt new file mode 100644 index 000000000..49c2761e1 --- /dev/null +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/common/type/TitleView.kt @@ -0,0 +1,74 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +@file:OptIn(ExperimentalDesignToken::class, ExperimentalQuackQuackApi::class) + +package team.duckie.app.android.feature.create.exam.common.type + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.quack.todo.QuackDropDownCard +import team.duckie.app.android.domain.exam.model.Question +import team.duckie.app.android.feature.create.exam.R +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.Image +import team.duckie.quackquack.ui.QuackDefaultTextField +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackTextFieldStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.trailingIcon +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi + +/** 문제 항목 Layout 내 공통 제목 Layout */ +@Composable +internal fun TitleView( + questionIndex: Int, + question: Question?, + titleChanged: (String) -> Unit, + imageClick: () -> Unit, + dropDownTitle: String, + onDropdownItemClick: (Int) -> Unit, +) { + // TODO(riflockle7): 동작 확인 필요 + // TODO(riflockle7): 최상단 Line 없는 TextField 필요 + QuackDefaultTextField( + modifier = Modifier.trailingIcon( + icon = OutlinedGroup.Image, + onClick = imageClick, + ), + value = question?.text ?: "", + onValueChange = titleChanged, + style = QuackTextFieldStyle.Default, + placeholderText = stringResource( + id = R.string.create_problem_question_placeholder, + "${questionIndex + 1}", + ), + ) + + (question as? Question.Image)?.imageUrl?.let { + QuackImage( + modifier = Modifier + .padding(top = 24.dp) + .size(DpSize(200.dp, 200.dp)), + src = it, + ) + } + + // TODO(riflockle7): border 없는 DropDownCard 필요 + QuackDropDownCard( + modifier = Modifier.padding(top = 24.dp), + text = dropDownTitle, + onClick = { + onDropdownItemClick(questionIndex) + }, + ) +} diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/navigator/impl/CreateProblemNavigatorImpl.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/navigator/impl/CreateProblemNavigatorImpl.kt similarity index 77% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/navigator/impl/CreateProblemNavigatorImpl.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/navigator/impl/CreateProblemNavigatorImpl.kt index 88ba06a47..ee75b324b 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/navigator/impl/CreateProblemNavigatorImpl.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/navigator/impl/CreateProblemNavigatorImpl.kt @@ -5,11 +5,11 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.navigator.impl +package team.duckie.app.android.feature.create.exam.navigator.impl import android.app.Activity import android.content.Intent -import team.duckie.app.android.feature.create.problem.CreateProblemActivity +import team.duckie.app.android.feature.create.exam.CreateExamActivity import team.duckie.app.android.navigator.feature.createproblem.CreateProblemNavigator import team.duckie.app.android.common.android.ui.startActivityWithAnimation import javax.inject.Inject @@ -20,7 +20,7 @@ internal class CreateProblemNavigatorImpl @Inject constructor() : CreateProblemN intentBuilder: Intent.() -> Intent, withFinish: Boolean, ) { - activity.startActivityWithAnimation( + activity.startActivityWithAnimation( intentBuilder = intentBuilder, withFinish = withFinish, ) diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/navigator/module/CreateProblemNavigatorModule.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/navigator/module/CreateProblemNavigatorModule.kt similarity index 79% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/navigator/module/CreateProblemNavigatorModule.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/navigator/module/CreateProblemNavigatorModule.kt index 474e2bb9d..a2d57397d 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/navigator/module/CreateProblemNavigatorModule.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/navigator/module/CreateProblemNavigatorModule.kt @@ -5,13 +5,13 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.navigator.module +package team.duckie.app.android.feature.create.exam.navigator.module import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import team.duckie.app.android.feature.create.problem.navigator.impl.CreateProblemNavigatorImpl +import team.duckie.app.android.feature.create.exam.navigator.impl.CreateProblemNavigatorImpl import team.duckie.app.android.navigator.feature.createproblem.CreateProblemNavigator import javax.inject.Singleton diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/AdditionalInfoScreen.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/AdditionalInfoScreen.kt similarity index 82% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/AdditionalInfoScreen.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/AdditionalInfoScreen.kt index 2f8baf7bc..9ccd46b1c 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/AdditionalInfoScreen.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/AdditionalInfoScreen.kt @@ -6,10 +6,11 @@ */ @file:OptIn( ExperimentalMaterialApi::class, - ExperimentalComposeUiApi::class, + ExperimentalQuackQuackApi::class, + ExperimentalDesignToken::class, ) -package team.duckie.app.android.feature.create.problem.screen +package team.duckie.app.android.feature.create.exam.screen import android.Manifest import android.content.Context @@ -28,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -37,16 +39,13 @@ import androidx.compose.material.ModalBottomSheetLayout import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale @@ -62,35 +61,41 @@ import androidx.core.content.ContextCompat import androidx.core.net.toUri import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState -import team.duckie.app.android.domain.exam.model.ThumbnailType -import team.duckie.app.android.domain.tag.model.Tag -import team.duckie.app.android.common.compose.ui.PhotoPicker -import team.duckie.app.android.feature.create.problem.R -import team.duckie.app.android.feature.create.problem.common.CreateProblemBottomLayout -import team.duckie.app.android.feature.create.problem.common.FadeAnimatedVisibility -import team.duckie.app.android.feature.create.problem.common.ImeActionNext -import team.duckie.app.android.feature.create.problem.common.PrevAndNextTopAppBar -import team.duckie.app.android.feature.create.problem.common.TitleAndComponent -import team.duckie.app.android.feature.create.problem.common.getCreateProblemMeasurePolicy -import team.duckie.app.android.feature.create.problem.viewmodel.CreateProblemViewModel -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemPhotoState -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemStep import team.duckie.app.android.common.compose.GetHeightRatioW328H240 +import team.duckie.app.android.common.compose.HideKeyboardWhenBottomSheetHidden import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.rememberToast import team.duckie.app.android.common.compose.systemBarPaddings +import team.duckie.app.android.common.compose.ui.PhotoPicker +import team.duckie.app.android.common.compose.ui.icon.v1.AreaId +import team.duckie.app.android.common.compose.ui.quack.todo.QuackLazyVerticalGridTag import team.duckie.app.android.common.kotlin.fastMap import team.duckie.app.android.common.kotlin.takeBy -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBasicTextField -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType -import team.duckie.quackquack.ui.component.QuackLazyVerticalGridTag -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.component.QuackTagType -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable +import team.duckie.app.android.domain.exam.model.ThumbnailType +import team.duckie.app.android.domain.tag.model.Tag +import team.duckie.app.android.feature.create.exam.R +import team.duckie.app.android.feature.create.exam.common.CreateProblemBottomLayout +import team.duckie.app.android.feature.create.exam.common.FadeAnimatedVisibility +import team.duckie.app.android.feature.create.exam.common.ImeActionNext +import team.duckie.app.android.feature.create.exam.common.PrevAndNextTopAppBar +import team.duckie.app.android.feature.create.exam.common.TitleAndComponent +import team.duckie.app.android.feature.create.exam.common.getCreateProblemMeasurePolicy +import team.duckie.app.android.feature.create.exam.viewmodel.CreateProblemViewModel +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemPhotoState +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemStep +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackButton +import team.duckie.quackquack.ui.QuackButtonStyle +import team.duckie.quackquack.ui.QuackDefaultTextField +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackTextFieldStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.sugar.QuackSubtitle +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi private const val TopAppBarLayoutId = "AdditionalInfoScreenTopAppBarLayoutId" private const val ContentLayoutId = "AdditionalInfoScreenContentLayoutId" @@ -153,24 +158,17 @@ internal fun AdditionalInformationScreen( sheetState.hide() } } else { - vm.navigateStep(CreateProblemStep.CreateProblem) + vm.navigateStep(CreateProblemStep.CreateExam) } } - LaunchedEffect(Unit) { - val sheetStateFlow = snapshotFlow { sheetState.currentValue } - sheetStateFlow.collect { state -> - if (state == ModalBottomSheetValue.Hidden) { - keyboard?.hide() - } - } - } + HideKeyboardWhenBottomSheetHidden(sheetState) ModalBottomSheetLayout( modifier = modifier, sheetState = sheetState, - sheetBackgroundColor = QuackColor.White.composeColor, - scrimColor = QuackColor.Dimmed.composeColor, + sheetBackgroundColor = QuackColor.White.value, + scrimColor = QuackColor.Dimmed.value, sheetShape = RoundedCornerShape( topStart = 16.dp, topEnd = 16.dp, @@ -189,7 +187,7 @@ internal fun AdditionalInformationScreen( .width(40.dp) .height(4.dp) .clip(RoundedCornerShape(2.dp)) - .background(QuackColor.Gray2.composeColor), + .background(QuackColor.Gray2.value), ) // 선택 목록 @@ -219,7 +217,7 @@ internal fun AdditionalInformationScreen( // 갤러리에서 선택 AdditionalBottomSheetThumbnailLayout( title = "", - src = team.duckie.quackquack.ui.R.drawable.quack_ic_area_24, + src = QuackIcon.AreaId, onClick = { val result = imagePermission.check(context) if (result) { @@ -247,7 +245,7 @@ internal fun AdditionalInformationScreen( // 상단 탭바 PrevAndNextTopAppBar( modifier = Modifier.layoutId(TopAppBarLayoutId), - onLeadingIconClick = { vm.navigateStep(CreateProblemStep.CreateProblem) }, + onLeadingIconClick = { vm.navigateStep(CreateProblemStep.CreateExam) }, ) // 컨텐츠 Layout @@ -297,7 +295,7 @@ internal fun AdditionalInformationScreen( modifier = Modifier .padding(top = systemBarPaddings.calculateTopPadding()) .fillMaxSize() - .background(color = QuackColor.White.composeColor), + .background(color = QuackColor.White.value), imageUris = galleryImages, imageSelections = galleryImagesSelections, onCameraClick = {}, @@ -330,6 +328,7 @@ internal fun AdditionalInformationScreen( } /** 썸네일 선택 (어떤 카테고리를 좋아하나요?) Layout */ +@OptIn(ExperimentalQuackQuackApi::class) @Composable private fun AdditionalThumbnailLayout( thumbnail: Any?, @@ -346,27 +345,29 @@ private fun AdditionalThumbnailLayout( stringResource = R.string.category_title, ) { QuackImage( - size = DpSize( - thumbnailWidthDp, - thumbnailWidthDp * GetHeightRatioW328H240, + modifier = Modifier.size( + DpSize( + thumbnailWidthDp, + thumbnailWidthDp * GetHeightRatioW328H240, + ), ), contentScale = ContentScale.FillWidth, src = thumbnail, ) // 썸네일 종류 선택 버튼 - // TODO(riflockle7): trailingIcon 추가 필요 - QuackLargeButton( - modifier = Modifier.padding(top = 4.dp), - type = QuackLargeButtonType.Border, + // TODO(riflockle7): 동작 확인 필요 + QuackButton( text = stringResource(id = R.string.additional_information_thumbnail_select), - leadingIcon = QuackIcon.ArrowRight, + style = QuackButtonStyle.PrimaryLarge, + modifier = Modifier.padding(top = 4.dp), onClick = onClick, ) } } /** 시험 응시 텍스트 선택 (시험 응시하기 버튼) Layout */ +@OptIn(ExperimentalDesignToken::class) @Composable private fun AdditionalTakeLayout(vm: CreateProblemViewModel = activityViewModel()) { val state = vm.collectAsState().value.additionalInfo @@ -375,9 +376,10 @@ private fun AdditionalTakeLayout(vm: CreateProblemViewModel = activityViewModel( modifier = Modifier.padding(top = 48.dp), stringResource = R.string.additional_information_take_title, ) { - QuackBasicTextField( - text = state.takeTitle, - onTextChanged = { + // TODO(riflockle7): 동작 확인 필요 + QuackDefaultTextField( + value = state.takeTitle, + onValueChange = { vm.setButtonTitle( it.takeBy( TakeTitleMaxLength, @@ -385,6 +387,7 @@ private fun AdditionalTakeLayout(vm: CreateProblemViewModel = activityViewModel( ), ) }, + style = QuackTextFieldStyle.Default, placeholderText = stringResource( id = R.string.additional_information_take_input_hint, TakeTitleMaxLength, @@ -403,12 +406,14 @@ private fun AdditionalSubTagsLayout(vm: CreateProblemViewModel = activityViewMod modifier = Modifier.padding(top = 48.dp), stringResource = R.string.additional_information_sub_tags_title, ) { - QuackBasicTextField( + // TODO(riflockle7): 동작 확인 필요 + QuackDefaultTextField( modifier = Modifier.quackClickable { vm.goToSearchSubTags() }, - text = "", - onTextChanged = {}, + value = "", + onValueChange = { }, + style = QuackTextFieldStyle.Default, placeholderText = stringResource(id = R.string.additional_information_sub_tags_placeholder), enabled = false, ) @@ -419,7 +424,7 @@ private fun AdditionalSubTagsLayout(vm: CreateProblemViewModel = activityViewMod QuackLazyVerticalGridTag( horizontalSpace = 4.dp, items = state.subTags.fastMap(Tag::name), - tagType = QuackTagType.Circle(QuackIcon.Close), + trailingIcon = OutlinedGroup.Close, onClick = { vm.onClickCloseTag(it) }, itemChunkedSize = 3, ) @@ -445,9 +450,11 @@ private fun AdditionalBottomSheetThumbnailLayout( ), ) { QuackImage( - size = DpSize( - thumbnailWidthDp, - thumbnailWidthDp * GetHeightRatioW328H240, + modifier = Modifier.size( + DpSize( + thumbnailWidthDp, + thumbnailWidthDp * GetHeightRatioW328H240, + ), ), contentScale = ContentScale.FillWidth, src = src, diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/CreateProblemScreen.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/CreateExamScreen.kt similarity index 64% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/CreateProblemScreen.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/CreateExamScreen.kt index d2153d38b..8d3eff819 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/CreateProblemScreen.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/CreateExamScreen.kt @@ -7,10 +7,11 @@ @file:OptIn( ExperimentalMaterialApi::class, - ExperimentalComposeUiApi::class, + ExperimentalDesignToken::class, + ExperimentalQuackQuackApi::class, ) -package team.duckie.app.android.feature.create.problem.screen +package team.duckie.app.android.feature.create.exam.screen import android.Manifest import android.content.Context @@ -21,12 +22,9 @@ import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -41,16 +39,13 @@ import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.Layout @@ -59,57 +54,52 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.net.toUri import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState +import team.duckie.app.android.common.compose.HideKeyboardWhenBottomSheetHidden import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.rememberToast import team.duckie.app.android.common.compose.systemBarPaddings import team.duckie.app.android.common.compose.ui.PhotoPicker import team.duckie.app.android.common.compose.ui.dialog.DuckieDialog import team.duckie.app.android.common.kotlin.fastForEach -import team.duckie.app.android.common.kotlin.fastForEachIndexed import team.duckie.app.android.common.kotlin.takeBy import team.duckie.app.android.domain.exam.model.Answer import team.duckie.app.android.domain.exam.model.Question -import team.duckie.app.android.feature.create.problem.R -import team.duckie.app.android.feature.create.problem.common.CreateProblemBottomLayout -import team.duckie.app.android.feature.create.problem.common.NoLazyGridItems -import team.duckie.app.android.feature.create.problem.common.PrevAndNextTopAppBar -import team.duckie.app.android.feature.create.problem.common.getCreateProblemMeasurePolicy -import team.duckie.app.android.feature.create.problem.viewmodel.CreateProblemViewModel -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemPhotoState -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemStep -import team.duckie.quackquack.ui.border.QuackBorder -import team.duckie.quackquack.ui.border.applyAnimatedQuackBorder -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBasic2TextField -import team.duckie.quackquack.ui.component.QuackBasicTextField -import team.duckie.quackquack.ui.component.QuackBody3 -import team.duckie.quackquack.ui.component.QuackBorderTextField -import team.duckie.quackquack.ui.component.QuackDropDownCard -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackRoundCheckBox -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable +import team.duckie.app.android.feature.create.exam.R +import team.duckie.app.android.feature.create.exam.common.CreateProblemBottomLayout +import team.duckie.app.android.feature.create.exam.common.PrevAndNextTopAppBar +import team.duckie.app.android.feature.create.exam.common.getCreateProblemMeasurePolicy +import team.duckie.app.android.feature.create.exam.common.type.ImageChoiceLayout +import team.duckie.app.android.feature.create.exam.common.type.ShortAnswerLayout +import team.duckie.app.android.feature.create.exam.common.type.TextChoiceLayout +import team.duckie.app.android.feature.create.exam.viewmodel.CreateProblemViewModel +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemPhotoState +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemStep +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.Plus +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi private const val TopAppBarLayoutId = "CreateProblemScreenTopAppBarLayoutId" private const val ContentLayoutId = "CreateProblemScreenContentLayoutId" private const val BottomLayoutId = "CreateProblemScreenBottomLayoutId" private const val GalleryListLayoutId = "CreateProblemScreenGalleryListLayoutId" -private const val MaximumChoice = 5 private const val MaximumProblem = 10 private const val TextFieldMaxLength = 20 /** 문제 만들기 2단계 (문제 만들기) Screen */ @Composable -internal fun CreateProblemScreen( +internal fun CreateExamScreen( modifier: Modifier, vm: CreateProblemViewModel = activityViewModel(), ) { @@ -117,7 +107,7 @@ internal fun CreateProblemScreen( val coroutineShape = rememberCoroutineScope() val rootState = vm.collectAsState().value - val state = rootState.createProblem + val state = rootState.createExam val keyboard = LocalSoftwareKeyboardController.current val sheetState = rememberModalBottomSheetState( ModalBottomSheetValue.Hidden, @@ -181,20 +171,13 @@ internal fun CreateProblemScreen( } } - LaunchedEffect(sheetState) { - val sheetStateFlow = snapshotFlow { sheetState.currentValue } - sheetStateFlow.collect { state -> - if (state == ModalBottomSheetValue.Hidden) { - keyboard?.hide() - } - } - } + HideKeyboardWhenBottomSheetHidden(sheetState) ModalBottomSheetLayout( modifier = modifier, sheetState = sheetState, - sheetBackgroundColor = QuackColor.White.composeColor, - scrimColor = QuackColor.Dimmed.composeColor, + sheetBackgroundColor = QuackColor.White.value, + scrimColor = QuackColor.Dimmed.value, sheetShape = RoundedCornerShape( topStart = 16.dp, topEnd = 16.dp, @@ -213,7 +196,7 @@ internal fun CreateProblemScreen( .width(40.dp) .height(4.dp) .clip(RoundedCornerShape(2.dp)) - .background(QuackColor.Gray2.composeColor), + .background(QuackColor.Gray2.value), ) // 선택 목록 @@ -223,26 +206,30 @@ internal fun CreateProblemScreen( .padding(top = 16.dp), ) { buttonNames.fastForEach { - QuackSubtitle( - modifier = Modifier.fillMaxWidth(), - padding = PaddingValues( - vertical = 12.dp, - horizontal = 16.dp, - ), - text = it.first, - onClick = { - coroutineShape.launch { - selectedQuestionIndex?.let { questionIndex -> - // 특정 문제의 답안 유형 수정 - vm.editAnswersType(questionIndex, it.second) - selectedQuestionIndex = null - } ?: run { - // 문제 추가 - vm.addProblem(it.second) + QuackText( + modifier = Modifier + .quackClickable { + coroutineShape.launch { + selectedQuestionIndex?.let { questionIndex -> + // 특정 문제의 답안 유형 수정 + vm.editAnswersType(questionIndex, it.second) + selectedQuestionIndex = null + } ?: run { + // 문제 추가 + vm.addProblem(it.second) + } + hideBottomSheet(sheetState) { selectedQuestionIndex = null } } - hideBottomSheet(sheetState) { selectedQuestionIndex = null } } - }, + .fillMaxWidth() + .padding( + PaddingValues( + vertical = 12.dp, + horizontal = 16.dp, + ), + ), + text = it.first, + typography = QuackTypography.Subtitle, ) } } @@ -285,7 +272,7 @@ internal fun CreateProblemScreen( val correctAnswer = state.correctAnswers[questionIndex] when (answers) { - is Answer.Short -> ShortAnswerProblemLayout( + is Answer.Short -> ShortAnswerLayout( questionIndex = questionIndex, question = question, titleChanged = { newTitle -> @@ -334,7 +321,7 @@ internal fun CreateProblemScreen( }, ) - is Answer.Choice -> ChoiceProblemLayout( + is Answer.Choice -> TextChoiceLayout( questionIndex = questionIndex, question = question, titleChanged = { newTitle -> @@ -396,7 +383,7 @@ internal fun CreateProblemScreen( }, ) - is Answer.ImageChoice -> ImageChoiceProblemLayout( + is Answer.ImageChoice -> ImageChoiceLayout( questionIndex = questionIndex, question = question, titleChanged = { newTitle -> @@ -487,7 +474,7 @@ internal fun CreateProblemScreen( modifier = Modifier .fillMaxWidth() .layoutId(BottomLayoutId), - leftButtonLeadingIcon = QuackIcon.Plus, + leftButtonLeadingIcon = OutlinedGroup.Plus, leftButtonText = stringResource(id = R.string.create_problem_add_problem_button), leftButtonClick = { coroutineShape.launch { @@ -504,7 +491,7 @@ internal fun CreateProblemScreen( } }, isCreateProblemValidate = problemCount < MaximumProblem, - isValidateCheck = vm::createProblemIsValidate, + isValidateCheck = vm::createExamIsValidate, ) }, ) @@ -516,7 +503,7 @@ internal fun CreateProblemScreen( modifier = Modifier .padding(top = systemBarPaddings.calculateTopPadding()) .fillMaxSize() - .background(color = QuackColor.White.composeColor) + .background(color = QuackColor.White.value) .layoutId(GalleryListLayoutId), imageUris = galleryImages, imageSelections = galleryImagesSelections, @@ -605,327 +592,20 @@ private fun CoroutineScope.hideBottomSheet( private fun CoroutineScope.openPhotoPicker( context: Context, vm: CreateProblemViewModel, - createProblemPhotoState: CreateProblemPhotoState, + createExamPhotoState: CreateProblemPhotoState, keyboard: SoftwareKeyboardController?, launcher: ManagedActivityResultLauncher, ) = launch { val result = imagePermission.check(context) if (result) { vm.loadGalleryImages() - vm.updatePhotoState(createProblemPhotoState) + vm.updatePhotoState(createExamPhotoState) keyboard?.hide() } else { launcher.launch(imagePermission) } } -/** 문제 항목 Layout 내 공통 제목 Layout */ -@Composable -private fun CreateProblemTitleLayout( - questionIndex: Int, - question: Question?, - titleChanged: (String) -> Unit, - imageClick: () -> Unit, - dropDownTitle: String, - onDropdownItemClick: (Int) -> Unit, -) { - // TODO(riflockle7): 최상단 Line 없는 TextField 필요 - QuackBasic2TextField( - text = question?.text ?: "", - onTextChanged = titleChanged, - placeholderText = stringResource( - id = R.string.create_problem_question_placeholder, - "${questionIndex + 1}", - ), - trailingIcon = QuackIcon.Image, - trailingIconOnClick = imageClick, - ) - - (question as? Question.Image)?.imageUrl?.let { - QuackImage( - modifier = Modifier.padding(top = 24.dp), - src = it, - size = DpSize(200.dp, 200.dp), - ) - } - - // TODO(riflockle7): border 없는 DropDownCard 필요 - QuackDropDownCard( - modifier = Modifier.padding(top = 24.dp), - text = dropDownTitle, - onClick = { - onDropdownItemClick(questionIndex) - }, - ) -} - -/** 객관식/글 Layout */ -@Composable -private fun ChoiceProblemLayout( - questionIndex: Int, - question: Question?, - titleChanged: (String) -> Unit, - imageClick: () -> Unit, - onDropdownItemClick: (Int) -> Unit, - answers: Answer.Choice, - answerTextChanged: (String, Int) -> Unit, - addAnswerClick: () -> Unit, - correctAnswers: String?, - setCorrectAnswerClick: (String) -> Unit, - deleteLongClick: (Int?) -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .quackClickable( - onLongClick = { deleteLongClick(null) }, - ) {}, - ) { - CreateProblemTitleLayout( - questionIndex, - question, - titleChanged, - imageClick, - answers.type.title, - onDropdownItemClick, - ) - - answers.choices.fastForEachIndexed { answerIndex, choiceModel -> - val answerNo = answerIndex + 1 - val isChecked = correctAnswers == "$answerIndex" - QuackBorderTextField( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp) - .applyAnimatedQuackBorder( - border = QuackBorder( - width = 1.dp, - color = if (isChecked) QuackColor.DuckieOrange else QuackColor.Gray4, - ), - ) - .quackClickable( - onLongClick = { deleteLongClick(answerIndex) }, - ) {}, - text = choiceModel.text, - onTextChanged = { newAnswer -> answerTextChanged(newAnswer, answerIndex) }, - placeholderText = stringResource( - id = R.string.create_problem_answer_placeholder, - "$answerNo", - ), - trailingContent = { - Column( - modifier = Modifier.quackClickable( - onClick = { - setCorrectAnswerClick(if (isChecked) "" else "$answerIndex") - }, - ), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - QuackRoundCheckBox(checked = isChecked) - - if (isChecked) { - QuackBody3( - modifier = Modifier.padding(top = 2.dp), - color = QuackColor.DuckieOrange, - text = stringResource(id = R.string.answer), - ) - } - } - }, - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - if (answers.choices.size < MaximumChoice) { - QuackSubtitle( - modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp), - text = stringResource(id = R.string.create_problem_add_button), - onClick = { addAnswerClick() }, - ) - } - } -} - -/** - * 객관식/사진 Layout - * // TODO(riflockle7): 정답 체크 연동 필요 - */ -@Composable -private fun ImageChoiceProblemLayout( - questionIndex: Int, - question: Question?, - titleChanged: (String) -> Unit, - imageClick: () -> Unit, - onDropdownItemClick: (Int) -> Unit, - answers: Answer.ImageChoice, - answerTextChanged: (String, Int) -> Unit, - answerImageClick: (Int) -> Unit, - addAnswerClick: () -> Unit, - correctAnswers: String?, - setCorrectAnswerClick: (String) -> Unit, - deleteLongClick: (Int?) -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .quackClickable( - onLongClick = { deleteLongClick(null) }, - ) {}, - ) { - CreateProblemTitleLayout( - questionIndex, - question, - titleChanged, - imageClick, - answers.type.title, - onDropdownItemClick, - ) - - NoLazyGridItems( - count = answers.imageChoice.size, - nColumns = 2, - paddingValues = PaddingValues(top = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - itemContent = { answerIndex -> - val answerNo = answerIndex + 1 - val answerItem = answers.imageChoice[answerIndex] - val isChecked = correctAnswers == "$answerIndex" - - Column( - modifier = Modifier - .fillMaxWidth() - .applyAnimatedQuackBorder(border = QuackBorder(color = QuackColor.Gray4)) - .applyAnimatedQuackBorder( - border = QuackBorder( - width = 1.dp, - color = if (isChecked) { - QuackColor.DuckieOrange - } else { - QuackColor.Gray4 - }, - ), - ) - .padding(12.dp), - ) { - Row( - modifier = Modifier.padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - QuackRoundCheckBox( - modifier = Modifier.quackClickable( - onClick = { - setCorrectAnswerClick(if (isChecked) "" else "$answerIndex") - }, - ), - checked = isChecked, - ) - - if (isChecked) { - QuackBody3( - modifier = Modifier.padding(start = 2.dp), - color = QuackColor.DuckieOrange, - text = stringResource(id = R.string.answer), - ) - } - - Spacer(Modifier.weight(1f)) - - QuackImage( - modifier = Modifier.quackClickable( - onClick = { deleteLongClick(answerIndex) }, - ), - src = QuackIcon.Close, - size = DpSize(20.dp, 20.dp), - ) - } - - if (answerItem.imageUrl.isEmpty()) { - Box( - modifier = Modifier - .quackClickable { answerImageClick(answerIndex) } - .background(color = QuackColor.Gray4.composeColor) - .padding(52.dp), - ) { - QuackImage( - src = QuackIcon.Image, - size = DpSize(32.dp, 32.dp), - ) - } - } else { - QuackImage( - src = answerItem.imageUrl, - size = DpSize(136.dp, 136.dp), - onClick = { answerImageClick(answerIndex) }, - onLongClick = { deleteLongClick(answerIndex) }, - ) - } - - QuackBasicTextField( - text = answers.imageChoice[answerIndex].text, - onTextChanged = { newAnswer -> - answerTextChanged(newAnswer, answerIndex) - }, - placeholderText = stringResource( - id = R.string.create_problem_answer_placeholder, - "$answerNo", - ), - ) - } - }, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - if (answers.imageChoice.size < MaximumChoice) { - QuackSubtitle( - modifier = Modifier.padding(vertical = 2.dp, horizontal = 4.dp), - text = stringResource(id = R.string.create_problem_add_button), - onClick = { addAnswerClick() }, - ) - } - } -} - -/** 주관식 Layout */ -@Composable -private fun ShortAnswerProblemLayout( - questionIndex: Int, - question: Question?, - titleChanged: (String) -> Unit, - imageClick: () -> Unit, - onDropdownItemClick: (Int) -> Unit, - answer: String, - answerTextChanged: (String, Int) -> Unit, - deleteLongClick: () -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .quackClickable( - onLongClick = { deleteLongClick() }, - ) {}, - ) { - CreateProblemTitleLayout( - questionIndex, - question, - titleChanged, - imageClick, - Answer.Type.ShortAnswer.title, - onDropdownItemClick, - ) - - QuackBasicTextField( - text = answer, - onTextChanged = { newAnswer -> answerTextChanged(newAnswer, 0) }, - placeholderText = stringResource(id = R.string.create_problem_short_answer_placeholder), - ) - } -} - /** * 이미지 권한 체크시 사용해야하는 permission * TODO(riflockle7): 권한 로직은 추후 PermissionViewModel 과 같이 쓰면서 지워질 예정 diff --git a/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/CreateProblemScreen.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/CreateProblemScreen.kt new file mode 100644 index 000000000..894183ecf --- /dev/null +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/CreateProblemScreen.kt @@ -0,0 +1,42 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.feature.create.exam.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.launch +import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.feature.create.exam.R +import team.duckie.app.android.feature.create.exam.common.PrevAndNextTopAppBar +import team.duckie.app.android.feature.create.exam.viewmodel.CreateProblemViewModel +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemStep + +/** 단일 문제 만들기 Screen */ +@Composable +internal fun CreateProblemScreen( + modifier: Modifier, + vm: CreateProblemViewModel = activityViewModel(), +) { + val coroutineScope = rememberCoroutineScope() + + Column(modifier = modifier) { + PrevAndNextTopAppBar( + modifier = Modifier.fillMaxWidth(), + onLeadingIconClick = { + coroutineScope.launch { vm.navigateStep(CreateProblemStep.ExamInformation) } + }, + trailingText = stringResource(id = R.string.next), + onTrailingTextClick = {}, + trailingTextEnabled = true, + ) + } +} diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/ExamInformationScreen.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/ExamInformationScreen.kt similarity index 68% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/ExamInformationScreen.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/ExamInformationScreen.kt index 08c9cffa6..5ba6dc160 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/ExamInformationScreen.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/ExamInformationScreen.kt @@ -5,11 +5,14 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.screen +@file:OptIn(ExperimentalQuackQuackApi::class, ExperimentalDesignToken::class) + +package team.duckie.app.android.feature.create.exam.screen import android.app.Activity import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn @@ -36,7 +39,6 @@ import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch @@ -44,27 +46,31 @@ import org.orbitmvi.orbit.compose.collectAsState import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.ui.DuckieGridLayout import team.duckie.app.android.common.compose.ui.dialog.DuckieDialog +import team.duckie.app.android.common.compose.ui.quack.todo.QuackSurface import team.duckie.app.android.common.kotlin.takeBy -import team.duckie.app.android.feature.create.problem.R -import team.duckie.app.android.feature.create.problem.common.CreateProblemBottomLayout -import team.duckie.app.android.feature.create.problem.common.ImeActionNext -import team.duckie.app.android.feature.create.problem.common.PrevAndNextTopAppBar -import team.duckie.app.android.feature.create.problem.common.TitleAndComponent -import team.duckie.app.android.feature.create.problem.common.getCreateProblemMeasurePolicy -import team.duckie.app.android.feature.create.problem.common.moveDownFocus -import team.duckie.app.android.feature.create.problem.viewmodel.CreateProblemViewModel -import team.duckie.quackquack.ui.animation.QuackAnimatedVisibility -import team.duckie.quackquack.ui.border.QuackBorder -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBasicTextField -import team.duckie.quackquack.ui.component.QuackCircleTag -import team.duckie.quackquack.ui.component.QuackGrayscaleTextField -import team.duckie.quackquack.ui.component.QuackReviewTextArea -import team.duckie.quackquack.ui.component.QuackSurface -import team.duckie.quackquack.ui.component.internal.QuackText -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.textstyle.QuackTextStyle +import team.duckie.app.android.feature.create.exam.R +import team.duckie.app.android.feature.create.exam.common.CreateProblemBottomLayout +import team.duckie.app.android.feature.create.exam.common.ImeActionNext +import team.duckie.app.android.feature.create.exam.common.PrevAndNextTopAppBar +import team.duckie.app.android.feature.create.exam.common.TitleAndComponent +import team.duckie.app.android.feature.create.exam.common.getCreateProblemMeasurePolicy +import team.duckie.app.android.feature.create.exam.common.moveDownFocus +import team.duckie.app.android.feature.create.exam.viewmodel.CreateProblemViewModel +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackDefaultTextField +import team.duckie.quackquack.ui.QuackTag +import team.duckie.quackquack.ui.QuackTagStyle +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.QuackTextArea +import team.duckie.quackquack.ui.QuackTextAreaStyle +import team.duckie.quackquack.ui.QuackTextFieldStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.trailingIcon +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi private const val TopAppBarLayoutId = "ExamInformationScreenTopAppBarLayoutId" private const val ContentLayoutId = "ExamInformationScreenContentLayoutId" @@ -84,12 +90,12 @@ internal fun ExamInformationScreen( val coroutineScope = rememberCoroutineScope() val focusManager = LocalFocusManager.current val lazyListState = rememberLazyListState() - var createProblemExitDialogVisible by remember { mutableStateOf(false) } + var createExamExitDialogVisible by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } BackHandler { - if (!createProblemExitDialogVisible) { - createProblemExitDialogVisible = true + if (!createExamExitDialogVisible) { + createExamExitDialogVisible = true } } @@ -115,8 +121,8 @@ internal fun ExamInformationScreen( modifier = Modifier.layoutId(TopAppBarLayoutId), trailingText = stringResource(id = R.string.next), onLeadingIconClick = { - if (!createProblemExitDialogVisible) { - createProblemExitDialogVisible = true + if (!createExamExitDialogVisible) { + createExamExitDialogVisible = true } }, ) @@ -147,30 +153,34 @@ internal fun ExamInformationScreen( } TitleAndComponent(stringResource = R.string.main_tag) { if (state.isMainTagSelected) { - QuackCircleTag( + QuackTag( text = state.mainTag, - trailingIcon = QuackIcon.Close, - isSelected = false, - onClick = { viewModel.onClickCloseTag() }, - ) + style = QuackTagStyle.Outlined, + modifier = Modifier.trailingIcon(OutlinedGroup.Close) { viewModel.onClickCloseTag() }, + selected = false, + ) {} } - QuackAnimatedVisibility(visible = !state.isMainTagSelected) { - QuackBasicTextField( - modifier = Modifier.quackClickable { - viewModel.goToSearchMainTag(lazyListState.firstVisibleItemIndex) - }, - leadingIcon = QuackIcon.Search, - text = state.mainTag, - onTextChanged = {}, + AnimatedVisibility(visible = !state.isMainTagSelected) { + // TODO(riflockle7): 동작 확인 필요 + QuackDefaultTextField( + modifier = Modifier.quackClickable( + onClick = { + viewModel.goToSearchMainTag(lazyListState.firstVisibleItemIndex) + }, + ), + // leadingIcon = QuackIcon.Search, + value = state.mainTag, + onValueChange = {}, placeholderText = stringResource(id = R.string.search_main_tag_placeholder), + style = QuackTextFieldStyle.Default, enabled = false, ) } } TitleAndComponent(stringResource = R.string.exam_title) { - QuackBasicTextField( - text = state.examTitle, - onTextChanged = { + QuackDefaultTextField( + value = state.examTitle, + onValueChange = { viewModel.setExamTitle( it.takeBy( ExamTitleMaxLength, @@ -178,6 +188,7 @@ internal fun ExamInformationScreen( ), ) }, + style = QuackTextFieldStyle.Default, placeholderText = stringResource( id = R.string.input_exam_title, ExamTitleMaxLength, @@ -187,15 +198,17 @@ internal fun ExamInformationScreen( ) } TitleAndComponent(stringResource = R.string.exam_description) { - QuackReviewTextArea( + // TODO(riflockle7): 동작 확인 필요 + QuackTextArea( modifier = Modifier .heightIn(140.dp) .focusRequester(focusRequester = focusRequester) .onFocusChanged { state -> viewModel.onSearchTextFocusChanged(state.isFocused) }, - text = state.examDescription, - onTextChanged = { + value = state.examDescription, + style = QuackTextAreaStyle.Default, + onValueChange = { viewModel.setExamDescription( it.takeBy( ExamDescriptionMaxLength, @@ -207,16 +220,17 @@ internal fun ExamInformationScreen( id = R.string.input_exam_description, ExamDescriptionMaxLength, ), - imeAction = ImeAction.Next, - keyboardActions = moveDownFocus(focusManager), - focused = state.examDescriptionFocused, + // TODO(riflockle7): 꽥꽥에서 기능 제공 안함 + // imeAction = ImeAction.Next, + // keyboardActions = moveDownFocus(focusManager), + // focused = state.examDescriptionFocused, ) } TitleAndComponent(stringResource = R.string.certifying_statement) { - QuackGrayscaleTextField( + QuackDefaultTextField( modifier = Modifier.padding(bottom = 16.dp), - text = state.certifyingStatement, - onTextChanged = { + value = state.certifyingStatement, + onValueChange = { viewModel.setCertifyingStatement( it.takeBy( CertifyingStatementMaxLength, @@ -224,6 +238,7 @@ internal fun ExamInformationScreen( ), ) }, + style = QuackTextFieldStyle.Default, placeholderText = stringResource( id = R.string.input_certifying_statement, CertifyingStatementMaxLength, @@ -236,8 +251,9 @@ internal fun ExamInformationScreen( } }, ), - maxLength = CertifyingStatementMaxLength, - showCounter = true, + // TODO(riflockle7): 꽥꽥에서 기능 제공 안함 + // maxLength = CertifyingStatementMaxLength, + // showCounter = true, ) } } @@ -264,15 +280,15 @@ internal fun ExamInformationScreen( DuckieDialog( title = stringResource(id = R.string.create_problem_exit_dialog_title), message = stringResource(id = R.string.create_problem_exit_dialog_message), - visible = createProblemExitDialogVisible, + visible = createExamExitDialogVisible, leftButtonText = stringResource(id = R.string.cancel), - leftButtonOnClick = { createProblemExitDialogVisible = false }, + leftButtonOnClick = { createExamExitDialogVisible = false }, rightButtonText = stringResource(id = R.string.ok), rightButtonOnClick = { - createProblemExitDialogVisible = false + createExamExitDialogVisible = false activity.finish() }, - onDismissRequest = { createProblemExitDialogVisible = false }, + onDismissRequest = { createExamExitDialogVisible = false }, ) } @@ -289,11 +305,12 @@ private fun MediumButton( height = 40.dp, ), backgroundColor = QuackColor.White, - border = QuackBorder( - color = when (selected) { + border = BorderStroke( + width = 1.dp, + brush = when (selected) { true -> QuackColor.DuckieOrange else -> QuackColor.Gray3 - }, + }.toBrush(), ), shape = RoundedCornerShape(size = 8.dp), onClick = onClick, @@ -301,13 +318,13 @@ private fun MediumButton( QuackText( modifier = Modifier.padding(all = 10.dp), text = text, - style = when (selected) { - true -> QuackTextStyle.Title2.change( + typography = when (selected) { + true -> QuackTypography.Title2.change( color = QuackColor.DuckieOrange, textAlign = TextAlign.Center, ) - else -> QuackTextStyle.Body1.change( + else -> QuackTypography.Body1.change( color = QuackColor.Black, textAlign = TextAlign.Center, ) diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/SearchTagScreen.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/SearchTagScreen.kt similarity index 81% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/SearchTagScreen.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/SearchTagScreen.kt index 4898ca53a..ea771418f 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/screen/SearchTagScreen.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/screen/SearchTagScreen.kt @@ -5,9 +5,12 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.screen +@file:OptIn(ExperimentalDesignToken::class, ExperimentalQuackQuackApi::class) + +package team.duckie.app.android.feature.create.exam.screen import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -30,21 +33,23 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState -import team.duckie.app.android.feature.create.problem.R -import team.duckie.app.android.feature.create.problem.common.CreateProblemBottomLayout -import team.duckie.app.android.feature.create.problem.common.ExitAppBar -import team.duckie.app.android.feature.create.problem.common.SearchResultText -import team.duckie.app.android.feature.create.problem.viewmodel.CreateProblemViewModel -import team.duckie.app.android.feature.create.problem.viewmodel.state.FindResultType -import team.duckie.app.android.feature.create.problem.viewmodel.state.SearchScreenData -import team.duckie.app.android.common.compose.ui.ImeSpacer import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.common.compose.ui.ImeSpacer +import team.duckie.app.android.common.compose.ui.quack.todo.QuackLazyVerticalGridTag import team.duckie.app.android.common.kotlin.fastMap -import team.duckie.quackquack.ui.animation.QuackAnimatedVisibility -import team.duckie.quackquack.ui.component.QuackBasicTextField -import team.duckie.quackquack.ui.component.QuackLazyVerticalGridTag -import team.duckie.quackquack.ui.component.QuackTagType -import team.duckie.quackquack.ui.icon.QuackIcon +import team.duckie.app.android.feature.create.exam.R +import team.duckie.app.android.feature.create.exam.common.CreateProblemBottomLayout +import team.duckie.app.android.feature.create.exam.common.ExitAppBar +import team.duckie.app.android.feature.create.exam.common.SearchResultText +import team.duckie.app.android.feature.create.exam.viewmodel.CreateProblemViewModel +import team.duckie.app.android.feature.create.exam.viewmodel.state.FindResultType +import team.duckie.app.android.feature.create.exam.viewmodel.state.SearchScreenData +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.ui.QuackDefaultTextField +import team.duckie.quackquack.ui.QuackTextFieldStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi private const val MaximumSubTagCount = 5 @@ -111,13 +116,16 @@ internal fun SearchTagScreen( ), horizontalSpace = 4.dp, items = state.results.fastMap { it.name }, - tagType = QuackTagType.Circle(QuackIcon.Close), + trailingIcon = OutlinedGroup.Close, onClick = { viewModel.onClickCloseTag(it) }, itemChunkedSize = 3, ) } - QuackBasicTextField( + // TODO(riflockle7): 동작 확인 필요 + QuackDefaultTextField( + // TODO(riflockle7): 꽥꽥 기능 제공 안함 + // leadingIcon = QuackIcon.Search, modifier = Modifier .padding( top = 16.dp, @@ -125,11 +133,11 @@ internal fun SearchTagScreen( end = 16.dp, ) .focusRequester(focusRequester), - leadingIcon = QuackIcon.Search, - text = searchTextFieldValue, - onTextChanged = { textFieldValue -> + value = searchTextFieldValue, + onValueChange = { textFieldValue -> viewModel.setTextFieldValue(textFieldValue = textFieldValue) }, + style = QuackTextFieldStyle.Default, placeholderText = placeholderText, keyboardActions = KeyboardActions( onDone = { @@ -140,7 +148,7 @@ internal fun SearchTagScreen( ), ) - QuackAnimatedVisibility( + AnimatedVisibility( modifier = Modifier.padding( top = 8.dp, start = 16.dp, diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/CreateProblemViewModel.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/CreateProblemViewModel.kt similarity index 92% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/CreateProblemViewModel.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/CreateProblemViewModel.kt index 38ad139ad..b530ee80f 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/CreateProblemViewModel.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/CreateProblemViewModel.kt @@ -7,7 +7,7 @@ @file:Suppress("ConstPropertyName", "PrivatePropertyName") -package team.duckie.app.android.feature.create.problem.viewmodel +package team.duckie.app.android.feature.create.exam.viewmodel import android.app.Application import android.content.Context @@ -31,6 +31,16 @@ import org.orbitmvi.orbit.syntax.simple.intent import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container +import team.duckie.app.android.common.android.image.MediaUtil +import team.duckie.app.android.common.android.network.NetworkUtil +import team.duckie.app.android.common.android.ui.const.Debounce +import team.duckie.app.android.common.android.ui.const.Extras +import team.duckie.app.android.common.android.viewmodel.context +import team.duckie.app.android.common.kotlin.copy +import team.duckie.app.android.common.kotlin.exception.duckieClientLogicProblemException +import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe +import team.duckie.app.android.common.kotlin.exception.isTagAlreadyExist +import team.duckie.app.android.common.kotlin.fastMapIndexed import team.duckie.app.android.domain.category.usecase.GetCategoriesUseCase import team.duckie.app.android.domain.exam.model.Answer import team.duckie.app.android.domain.exam.model.ChoiceModel @@ -55,21 +65,11 @@ import team.duckie.app.android.domain.search.usecase.GetSearchUseCase import team.duckie.app.android.domain.tag.model.Tag import team.duckie.app.android.domain.tag.repository.TagRepository import team.duckie.app.android.domain.user.usecase.GetMeUseCase -import team.duckie.app.android.feature.create.problem.viewmodel.sideeffect.CreateProblemSideEffect -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemPhotoState -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemState -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemStep -import team.duckie.app.android.feature.create.problem.viewmodel.state.FindResultType -import team.duckie.app.android.common.android.image.MediaUtil -import team.duckie.app.android.common.android.network.NetworkUtil -import team.duckie.app.android.common.android.viewmodel.context -import team.duckie.app.android.common.kotlin.copy -import team.duckie.app.android.common.kotlin.exception.duckieClientLogicProblemException -import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe -import team.duckie.app.android.common.kotlin.exception.isTagAlreadyExist -import team.duckie.app.android.common.kotlin.fastMapIndexed -import team.duckie.app.android.common.android.ui.const.Debounce -import team.duckie.app.android.common.android.ui.const.Extras +import team.duckie.app.android.feature.create.exam.viewmodel.sideeffect.CreateProblemSideEffect +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemPhotoState +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemState +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemStep +import team.duckie.app.android.feature.create.exam.viewmodel.state.FindResultType import javax.inject.Inject private const val TagsMaximumCount = 10 @@ -111,7 +111,7 @@ internal class CreateProblemViewModel @Inject constructor( state.copy( me = me, isEditMode = false, - createProblemStep = CreateProblemStep.ExamInformation, + createExamStep = CreateProblemStep.ExamInformation, ) } }.onFailure { @@ -130,7 +130,7 @@ internal class CreateProblemViewModel @Inject constructor( private suspend fun loadErrorPage(isNetworkError: Boolean = false) = intent { reduce { state.copy( - createProblemStep = CreateProblemStep.Error, + createExamStep = CreateProblemStep.Error, isNetworkError = isNetworkError, ) } @@ -141,7 +141,7 @@ internal class CreateProblemViewModel @Inject constructor( reduce { state.copy( isNetworkError = false, - createProblemStep = CreateProblemStep.Loading, + createExamStep = CreateProblemStep.Loading, ) } } @@ -236,21 +236,21 @@ internal class CreateProblemViewModel @Inject constructor( /** request 를 위해 필요한 [ExamBody] 를 생성한다. */ private fun generateExamBody(): ExamBody { val examInformationState = container.stateFlow.value.examInformation - val createProblemState = container.stateFlow.value.createProblem + val createExamState = container.stateFlow.value.createExam val additionalInfoState = container.stateFlow.value.additionalInfo val serverCorrectAnswers = - createProblemState.correctAnswers.fastMapIndexed { index, correctAnswer -> - correctAnswer.toCorrectAnswerData(createProblemState.answers[index]) + createExamState.correctAnswers.fastMapIndexed { index, correctAnswer -> + correctAnswer.toCorrectAnswerData(createExamState.answers[index]) } - val problems = createProblemState.questions.fastMapIndexed { index, question -> + val problems = createExamState.questions.fastMapIndexed { index, question -> Problem( index, question, - createProblemState.answers[index], + createExamState.answers[index], serverCorrectAnswers[index], - createProblemState.hints[index], - createProblemState.memos[index], + createExamState.hints[index], + createExamState.memos[index], null, ) }.toPersistentList() @@ -284,7 +284,7 @@ internal class CreateProblemViewModel @Inject constructor( /** 특정 태그의 닫기 버튼을 클릭한다. 대체로 삭제 로직이 실행된다. */ internal fun onClickCloseTag(index: Int = 0) = intent { reduce { - when (state.createProblemStep) { + when (state.createExamStep) { CreateProblemStep.ExamInformation -> { state.copy( examInformation = state.examInformation.run { @@ -358,7 +358,7 @@ internal class CreateProblemViewModel @Inject constructor( /** 특정 화면으로 이동한다. */ internal fun navigateStep(step: CreateProblemStep) = intent { reduce { - state.copy(createProblemStep = step) + state.copy(createExamStep = step) } } @@ -394,7 +394,7 @@ internal class CreateProblemViewModel @Inject constructor( internal fun goToSearchMainTag(scrollPosition: Int) = intent { reduce { state.copy( - createProblemStep = CreateProblemStep.Search, + createExamStep = CreateProblemStep.Search, findResultType = FindResultType.MainTag, examInformation = state.examInformation.copy( scrollPosition = scrollPosition, @@ -453,7 +453,7 @@ internal class CreateProblemViewModel @Inject constructor( // 이전에 등록된 제목과 동일한 경우 별다른 처리를 하지 않는다. if (state.examInformation.prevExamTitle == state.examInformation.examTitle) { reduce { - state.copy(createProblemStep = CreateProblemStep.CreateProblem) + state.copy(createExamStep = CreateProblemStep.CreateExam) } return@intent } @@ -463,7 +463,7 @@ internal class CreateProblemViewModel @Inject constructor( ).onSuccess { thumbnail -> reduce { state.copy( - createProblemStep = CreateProblemStep.CreateProblem, + createExamStep = CreateProblemStep.CreateExam, examInformation = state.examInformation.copy( prevExamTitle = state.examInformation.examTitle, ), @@ -490,7 +490,7 @@ internal class CreateProblemViewModel @Inject constructor( val newQuestion = Question.Text(text = "") val newAnswer = answerType.getDefaultAnswer() - with(state.createProblem) { + with(state.createExam) { val newQuestions = questions.copy { add(newQuestion) } val newAnswers = answers.copy { add(newAnswer) } val newCorrectAnswers = correctAnswers.copy { add("") } @@ -499,7 +499,7 @@ internal class CreateProblemViewModel @Inject constructor( reduce { state.copy( - createProblem = state.createProblem.copy( + createExam = state.createExam.copy( questions = newQuestions.toPersistentList(), answers = newAnswers.toPersistentList(), correctAnswers = newCorrectAnswers.toPersistentList(), @@ -513,7 +513,7 @@ internal class CreateProblemViewModel @Inject constructor( /** [questionIndex + 1] 번 문제를 삭제한다. */ internal fun removeProblem(questionIndex: Int) = intent { - with(state.createProblem) { + with(state.createExam) { val newQuestions = questions.copy { removeAt(questionIndex) } val newAnswers = answers.copy { removeAt(questionIndex) } val newCorrectAnswers = correctAnswers.copy { removeAt(questionIndex) } @@ -522,7 +522,7 @@ internal class CreateProblemViewModel @Inject constructor( reduce { state.copy( - createProblem = state.createProblem.copy( + createExam = state.createExam.copy( questions = newQuestions.toPersistentList(), answers = newAnswers.toPersistentList(), correctAnswers = newCorrectAnswers.toPersistentList(), @@ -546,7 +546,7 @@ internal class CreateProblemViewModel @Inject constructor( title: String? = null, urlSource: String? = null, ) = intent { - val newQuestions = state.createProblem.questions.toMutableList() + val newQuestions = state.createExam.questions.toMutableList() val prevQuestion = newQuestions[questionIndex] val newQuestion = when (questionType) { Question.Type.Text -> Question.Text( @@ -574,7 +574,7 @@ internal class CreateProblemViewModel @Inject constructor( reduce { state.copy( - createProblem = state.createProblem.copy( + createExam = state.createExam.copy( questions = newQuestions.toPersistentList(), ), ) @@ -617,7 +617,7 @@ internal class CreateProblemViewModel @Inject constructor( questionIndex: Int, answerType: Answer.Type, ) = intent { - val newAnswers = state.createProblem.answers.toMutableList() + val newAnswers = state.createExam.answers.toMutableList() newAnswers[questionIndex] = when (answerType) { Answer.Type.ShortAnswer -> newAnswers[questionIndex].toShort() Answer.Type.Choice -> newAnswers[questionIndex].toChoice() @@ -626,7 +626,7 @@ internal class CreateProblemViewModel @Inject constructor( reduce { state.copy( - createProblem = state.createProblem.copy( + createExam = state.createExam.copy( answers = newAnswers.toPersistentList(), ), ) @@ -647,7 +647,7 @@ internal class CreateProblemViewModel @Inject constructor( answer: String? = null, urlSource: String? = null, ) = intent { - val newAnswers = state.createProblem.answers.toMutableList() + val newAnswers = state.createExam.answers.toMutableList() newAnswers[questionIndex].getEditedAnswers( answerIndex, @@ -656,14 +656,14 @@ internal class CreateProblemViewModel @Inject constructor( urlSource, ).let { newAnswers[questionIndex] = it } - val newCorrectAnswers = state.createProblem.correctAnswers.toMutableList() + val newCorrectAnswers = state.createExam.correctAnswers.toMutableList() if (answerType == Answer.Type.ShortAnswer) { newCorrectAnswers[questionIndex] = answer ?: "" } reduce { state.copy( - createProblem = state.createProblem.copy( + createExam = state.createExam.copy( answers = newAnswers.toPersistentList(), correctAnswers = newCorrectAnswers.toPersistentList(), ), @@ -699,7 +699,7 @@ internal class CreateProblemViewModel @Inject constructor( questionIndex: Int, answerIndex: Int, ) = intent { - val newAnswers = state.createProblem.answers.toMutableList() + val newAnswers = state.createExam.answers.toMutableList() val newAnswer = newAnswers[questionIndex] newAnswers[questionIndex] = when (newAnswer) { is Answer.Short -> duckieClientLogicProblemException(message = "주관식 답변은 삭제할 수 없습니다.") @@ -714,14 +714,14 @@ internal class CreateProblemViewModel @Inject constructor( } // 만약 정답 처리 된 내용을 삭제하는 경우 정답내용(여기에서는 정답 index)를 초기화 시킨다. - val newCorrectAnswers = state.createProblem.correctAnswers.toMutableList() + val newCorrectAnswers = state.createExam.correctAnswers.toMutableList() if ("$answerIndex" == newCorrectAnswers[questionIndex]) { newCorrectAnswers[questionIndex] = "" } reduce { state.copy( - createProblem = state.createProblem.copy( + createExam = state.createExam.copy( answers = newAnswers.toPersistentList(), correctAnswers = newCorrectAnswers.toPersistentList(), ), @@ -752,12 +752,12 @@ internal class CreateProblemViewModel @Inject constructor( questionIndex: Int, correctAnswer: String, ) = intent { - val newCorrectAnswers = state.createProblem.correctAnswers.toMutableList() + val newCorrectAnswers = state.createExam.correctAnswers.toMutableList() newCorrectAnswers[questionIndex] = correctAnswer reduce { state.copy( - createProblem = state.createProblem.copy( + createExam = state.createExam.copy( correctAnswers = newCorrectAnswers.toPersistentList(), ), ) @@ -772,7 +772,7 @@ internal class CreateProblemViewModel @Inject constructor( questionIndex: Int, answerType: Answer.Type, ) = intent { - val newAnswers = state.createProblem.answers.toMutableList() + val newAnswers = state.createExam.answers.toMutableList() val newAnswer = newAnswers[questionIndex] when (answerType) { @@ -796,8 +796,8 @@ internal class CreateProblemViewModel @Inject constructor( reduce { state.copy( - createProblem = state.createProblem.copy( - questions = state.createProblem.questions, + createExam = state.createExam.copy( + questions = state.createExam.questions, answers = newAnswers.toPersistentList(), ), ) @@ -805,8 +805,8 @@ internal class CreateProblemViewModel @Inject constructor( } /** 문제 만들기 2단계 화면의 유효성을 체크한다. */ - internal fun createProblemIsValidate(): Boolean { - return with(container.stateFlow.value.createProblem) { + internal fun createExamIsValidate(): Boolean { + return with(container.stateFlow.value.createExam) { val examCountValidate = this.questions.size in MinimumProblem..MaximumProblem val questionsValidate = this.questions.asSequence() .map { it.validate() } @@ -878,14 +878,14 @@ internal class CreateProblemViewModel @Inject constructor( /** 문제 만들기 전체 화면의 유효성을 체크한다. */ internal fun isAllFieldsNotEmpty(): Boolean { - return examInformationIsValidate() && createProblemIsValidate() && additionInfoIsValidate() + return examInformationIsValidate() && createExamIsValidate() && additionInfoIsValidate() } /** 태그 항목들을 등록하기 위한 검색화면으로 진입한다. */ internal fun goToSearchSubTags() = intent { reduce { state.copy( - createProblemStep = CreateProblemStep.Search, + createExamStep = CreateProblemStep.Search, findResultType = FindResultType.SubTags, ) } @@ -970,7 +970,7 @@ internal class CreateProblemViewModel @Inject constructor( when (state.findResultType) { FindResultType.MainTag -> { state.copy( - createProblemStep = CreateProblemStep.ExamInformation, + createExamStep = CreateProblemStep.ExamInformation, examInformation = state.examInformation.run { copy( isMainTagSelected = true, @@ -1019,7 +1019,7 @@ internal class CreateProblemViewModel @Inject constructor( reduce { when (state.findResultType) { FindResultType.MainTag -> state.copy( - createProblemStep = CreateProblemStep.ExamInformation, + createExamStep = CreateProblemStep.ExamInformation, examInformation = state.examInformation.run { copy( isMainTagSelected = false, @@ -1032,7 +1032,7 @@ internal class CreateProblemViewModel @Inject constructor( ) FindResultType.SubTags -> state.copy( - createProblemStep = CreateProblemStep.AdditionalInformation, + createExamStep = CreateProblemStep.AdditionalInformation, additionalInfo = state.additionalInfo.run { copy( isSubTagsAdded = isComplete, diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/sideeffect/CreateProblemSideEffect.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/sideeffect/CreateProblemSideEffect.kt similarity index 84% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/sideeffect/CreateProblemSideEffect.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/sideeffect/CreateProblemSideEffect.kt index 5486b0ac4..811d4dce0 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/sideeffect/CreateProblemSideEffect.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/sideeffect/CreateProblemSideEffect.kt @@ -5,12 +5,12 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.viewmodel.sideeffect +package team.duckie.app.android.feature.create.exam.viewmodel.sideeffect import com.google.firebase.crashlytics.FirebaseCrashlytics import team.duckie.app.android.domain.gallery.usecase.LoadGalleryImagesUseCase -import team.duckie.app.android.feature.create.problem.viewmodel.CreateProblemViewModel -import team.duckie.app.android.feature.create.problem.viewmodel.state.CreateProblemState +import team.duckie.app.android.feature.create.exam.viewmodel.CreateProblemViewModel +import team.duckie.app.android.feature.create.exam.viewmodel.state.CreateProblemState internal sealed class CreateProblemSideEffect { object FinishActivity : CreateProblemSideEffect() diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/state/CreateProblemState.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/state/CreateProblemState.kt similarity index 95% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/state/CreateProblemState.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/state/CreateProblemState.kt index 21599268e..56768593c 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/state/CreateProblemState.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/state/CreateProblemState.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.viewmodel.state +package team.duckie.app.android.feature.create.exam.viewmodel.state import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -23,9 +23,9 @@ internal data class CreateProblemState( val me: User? = null, val isNetworkError: Boolean = false, val isEditMode: Boolean = false, - val createProblemStep: CreateProblemStep = CreateProblemStep.Loading, + val createExamStep: CreateProblemStep = CreateProblemStep.Loading, val examInformation: ExamInformation = ExamInformation(), - val createProblem: CreateProblem = CreateProblem(), + val createExam: CreateProblem = CreateProblem(), val additionalInfo: AdditionInfo = AdditionInfo(), val findResultType: FindResultType = FindResultType.MainTag, val photoState: CreateProblemPhotoState? = null, diff --git a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/state/CreateProblemStep.kt b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/state/CreateProblemStep.kt similarity index 87% rename from feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/state/CreateProblemStep.kt rename to feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/state/CreateProblemStep.kt index bb568dda2..3ff0d672d 100644 --- a/feature/create-problem/src/main/kotlin/team/duckie/app/android/feature/create/problem/viewmodel/state/CreateProblemStep.kt +++ b/feature/create-exam/src/main/kotlin/team/duckie/app/android/feature/create/exam/viewmodel/state/CreateProblemStep.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -package team.duckie.app.android.feature.create.problem.viewmodel.state +package team.duckie.app.android.feature.create.exam.viewmodel.state import team.duckie.app.android.common.kotlin.AllowMagicNumber @@ -13,7 +13,7 @@ import team.duckie.app.android.common.kotlin.AllowMagicNumber enum class CreateProblemStep(private val index: Int) { Loading(0), ExamInformation(1), - CreateProblem(2), + CreateExam(2), AdditionalInformation(3), Search(4), Error(5), diff --git a/feature/create-problem/src/main/res/values/strings.xml b/feature/create-exam/src/main/res/values/strings.xml similarity index 100% rename from feature/create-problem/src/main/res/values/strings.xml rename to feature/create-exam/src/main/res/values/strings.xml diff --git a/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/BottomContent.kt b/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/BottomContent.kt index 05748e915..9c6f813a9 100644 --- a/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/BottomContent.kt +++ b/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/BottomContent.kt @@ -29,7 +29,7 @@ import team.duckie.quackquack.material.icon.quackicon.outlined.Heart import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.ui.QuackButton import team.duckie.quackquack.ui.QuackButtonStyle -import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackIcon import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi /** 상세 화면 최하단 Layout */ @@ -54,11 +54,11 @@ internal fun DetailBottomLayout( horizontalArrangement = Arrangement.SpaceBetween, ) { // 좋아요 버튼 - QuackImage( + QuackIcon( modifier = Modifier .size(DpSize(24.dp, 24.dp)) .quackClickable(onClick = onHeartClick), - src = if (state.isHeart) { + icon = if (state.isHeart) { QuackIcon.FilledHeart } else { QuackIcon.Outlined.Heart diff --git a/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/DetailContent.kt b/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/DetailContent.kt index a67c8147b..dbb6eac53 100644 --- a/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/DetailContent.kt +++ b/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/DetailContent.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -46,7 +45,7 @@ import team.duckie.quackquack.material.icon.QuackIcon import team.duckie.quackquack.material.icon.quackicon.Outlined import team.duckie.quackquack.material.icon.quackicon.outlined.More import team.duckie.quackquack.material.quackClickable -import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackIcon import team.duckie.quackquack.ui.QuackTag import team.duckie.quackquack.ui.QuackTagStyle import team.duckie.quackquack.ui.QuackText @@ -101,14 +100,14 @@ internal fun DetailContentLayout( ) // 더보기 아이콘 - QuackImage( + QuackIcon( modifier = Modifier .width(24.dp) .height(24.dp) .quackClickable( onClick = moreButtonClick, ), - src = QuackIcon.Outlined.More, + icon = QuackIcon.Outlined.More, ) } @@ -173,11 +172,12 @@ private fun DetailProfileLayout( profileClick: (Int) -> Unit, ) { val isFollowed = remember(state.isFollowing) { state.isFollowing } + val onProfileClick = { profileClick(state.exam.user?.id ?: 0) } Row( modifier = Modifier .quackClickable( - onClick = { profileClick(state.exam.user?.id ?: 0) }, + onClick = onProfileClick, rippleEnabled = false, ) .padding( @@ -190,6 +190,7 @@ private fun DetailProfileLayout( QuackProfileImage( profileUrl = state.profileImageUrl, size = DpSize(36.dp, 36.dp), + onClick = onProfileClick, ) // 공백 diff --git a/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/TopCustomBar.kt b/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/TopCustomBar.kt index 2b622901f..494ec85cd 100644 --- a/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/TopCustomBar.kt +++ b/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/common/TopCustomBar.kt @@ -29,7 +29,7 @@ import team.duckie.quackquack.material.icon.quackicon.Outlined import team.duckie.quackquack.material.icon.quackicon.outlined.ArrowBack import team.duckie.quackquack.material.icon.quackicon.outlined.ArrowRight import team.duckie.quackquack.material.quackClickable -import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackIcon import team.duckie.quackquack.ui.QuackTag import team.duckie.quackquack.ui.QuackTagStyle import team.duckie.quackquack.ui.trailingIcon @@ -54,14 +54,14 @@ internal fun TopAppCustomBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - QuackImage( + QuackIcon( modifier = Modifier .padding(6.dp) .size(DpSize(24.dp, 24.dp)) .quackClickable( onClick = { activity.finish() }, ), - src = QuackIcon.Outlined.ArrowBack, + icon = QuackIcon.Outlined.ArrowBack, ) QuackTag( diff --git a/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/screen/quiz/QuizDetailScreen.kt b/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/screen/quiz/QuizDetailScreen.kt index 519e7e6d2..f5736dab4 100644 --- a/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/screen/quiz/QuizDetailScreen.kt +++ b/feature/detail/src/main/kotlin/team/duckie/app/android/feature/detail/screen/quiz/QuizDetailScreen.kt @@ -262,6 +262,7 @@ private fun RankingContent( } } } + Divider(color = QuackColor.Gray4.value) } } diff --git a/feature/exam-result/build.gradle.kts b/feature/exam-result/build.gradle.kts index 24520d71e..59de6901e 100644 --- a/feature/exam-result/build.gradle.kts +++ b/feature/exam-result/build.gradle.kts @@ -32,8 +32,8 @@ dependencies { libs.compose.ui.coil, libs.compose.lifecycle.runtime, libs.compose.ui.material, // needs for Scaffold - libs.quack.ui.components, libs.quack.v2.ui, + libs.kotlin.collections.immutable, libs.firebase.crashlytics, ) } diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/ExamResultActivity.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/ExamResultActivity.kt index a8bdffcdc..603663392 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/ExamResultActivity.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/ExamResultActivity.kt @@ -23,7 +23,7 @@ import team.duckie.app.android.feature.exam.result.screen.ExamResultScreen import team.duckie.app.android.feature.exam.result.viewmodel.ExamResultSideEffect import team.duckie.app.android.feature.exam.result.viewmodel.ExamResultViewModel import team.duckie.app.android.navigator.feature.startexam.StartExamNavigator -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.quackquack.material.theme.QuackTheme import javax.inject.Inject @AndroidEntryPoint @@ -78,6 +78,7 @@ class ExamResultActivity : BaseActivity() { withFinish = true, ) } + is ExamResultSideEffect.SendReactionSuccessToast -> { ToastWrapper(this).invoke(getString(R.string.exam_result_post_reaction_success)) } diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/common/LoadingIndicator.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/common/LoadingIndicator.kt deleted file mode 100644 index 5e6bb06a3..000000000 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/common/LoadingIndicator.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Designed and developed by Duckie Team, 2022 - * - * Licensed under the MIT. - * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE - */ - -package team.duckie.app.android.feature.exam.result.common - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import team.duckie.quackquack.ui.color.QuackColor - -// TODO(EvergreenTree97): QuackLoadingIndicator로 통합 필요 -@Composable -internal fun LoadingIndicator() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(color = QuackColor.DuckieOrange.composeColor) - } -} diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/common/ResultBottomBar.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/common/ResultBottomBar.kt index 9e10a4535..de40e41fe 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/common/ResultBottomBar.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/common/ResultBottomBar.kt @@ -5,8 +5,11 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalQuackQuackApi::class) + package team.duckie.app.android.feature.exam.result.common +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -18,13 +21,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.quack.todo.QuackSurface +import team.duckie.app.android.common.compose.ui.temp.TempFlexiblePrimaryLargeButton import team.duckie.app.android.feature.exam.result.R -import team.duckie.quackquack.ui.border.QuackBorder -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackSmallButton -import team.duckie.quackquack.ui.component.QuackSmallButtonType -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.component.QuackSurface +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.ui.sugar.QuackSubtitle +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi @Composable internal fun ResultBottomBar( @@ -51,13 +53,9 @@ internal fun ResultBottomBar( onClick = onClickRetryButton, ) } - QuackSmallButton( - modifier = Modifier - .heightIn(44.dp) - .weight(1f), - type = QuackSmallButtonType.Fill, + TempFlexiblePrimaryLargeButton( + modifier = Modifier.weight(1f), text = stringResource(id = R.string.exam_result_exit_exam), - enabled = true, onClick = onClickExitButton, ) } @@ -72,7 +70,7 @@ private fun GrayBorderSmallButton( QuackSurface( modifier = modifier, backgroundColor = QuackColor.White, - border = QuackBorder(color = QuackColor.Gray3), + border = BorderStroke(width = 1.dp, color = QuackColor.Gray3.value), shape = RoundedCornerShape(size = 8.dp), onClick = onClick, ) { diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/ExamResultScreen.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/ExamResultScreen.kt index b794d44c0..2c324395a 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/ExamResultScreen.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/ExamResultScreen.kt @@ -29,7 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -47,6 +47,7 @@ import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.common.compose.ui.dialog.DuckieBottomSheetDialog import team.duckie.app.android.common.compose.ui.dialog.DuckieDialog import team.duckie.app.android.common.compose.ui.quack.todo.QuackReactionTextArea +import team.duckie.app.android.common.compose.ui.quack.todo.QuackTopAppBar import team.duckie.app.android.common.compose.util.HandleKeyboardVisibilityWithSheet import team.duckie.app.android.feature.exam.result.R import team.duckie.app.android.feature.exam.result.common.ResultBottomBar @@ -57,8 +58,9 @@ import team.duckie.app.android.feature.exam.result.viewmodel.ExamResultScreen import team.duckie.app.android.feature.exam.result.viewmodel.ExamResultState import team.duckie.app.android.feature.exam.result.viewmodel.ExamResultViewModel import team.duckie.quackquack.material.QuackColor -import team.duckie.quackquack.ui.component.QuackTopAppBar -import team.duckie.quackquack.ui.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.Outlined +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.material.icon.quackicon.outlined.Share import team.duckie.quackquack.ui.span import team.duckie.quackquack.ui.sugar.QuackHeadLine1 @@ -231,9 +233,9 @@ private fun ExamResultSuccessScreen( modifier = Modifier .padding(vertical = 8.dp) .padding(horizontal = 16.dp), - leadingIcon = QuackIcon.Close, + leadingIcon = team.duckie.quackquack.material.icon.QuackIcon.Outlined.Close, onLeadingIconClick = viewModel::exitExam, - trailingIcon = QuackIcon.Share, + trailingIcon = team.duckie.quackquack.material.icon.QuackIcon.Outlined.Share, onTrailingIconClick = { viewModel.updateExamResultScreen(ExamResultScreen.SHARE_EXAM_RESULT) }, @@ -252,11 +254,12 @@ private fun ExamResultSuccessScreen( PullRefreshIndicator( modifier = Modifier .fillMaxWidth() - .wrapContentWidth(CenterHorizontally) + .wrapContentWidth(Alignment.CenterHorizontally) .zIndex(1f), refreshing = state.isRefreshing, state = pullRefreshState, ) + Column( modifier = Modifier .fillMaxSize() @@ -282,9 +285,6 @@ private fun ExamResultSuccessScreen( nickname = nickname, myAnswer = myAnswer, profileImg = profileImg, - onHeartComment = { isLike -> - viewModel.heartWrongComment(isLike) - }, initialState = { viewModel.initialQuizState() }, diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/ExamResultShareScreen.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/ExamResultShareScreen.kt index 9190766cc..4625f27f0 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/ExamResultShareScreen.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/ExamResultShareScreen.kt @@ -43,22 +43,22 @@ import coil.compose.AsyncImagePainter import coil.compose.rememberAsyncImagePainter import kotlinx.coroutines.launch import team.duckie.app.android.common.android.image.saveImageInGallery -import team.duckie.app.android.common.compose.util.ComposeToBitmap import team.duckie.app.android.common.compose.GetHeightRatioW328H240 import team.duckie.app.android.common.compose.rememberToast import team.duckie.app.android.common.compose.ui.BackPressedTopAppBar +import team.duckie.app.android.common.compose.ui.QuackDivider import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.common.compose.ui.icon.v2.Download import team.duckie.app.android.common.compose.ui.icon.v2.DuckieTextLogo +import team.duckie.app.android.common.compose.util.ComposeToBitmap import team.duckie.app.android.common.kotlin.toHourMinuteSecond import team.duckie.app.android.feature.exam.result.R import team.duckie.app.android.feature.exam.result.viewmodel.ExamResultState import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.icon.QuackIcon -import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackIcon import team.duckie.quackquack.ui.QuackText -import team.duckie.quackquack.ui.component.QuackDivider import team.duckie.quackquack.ui.icons import team.duckie.quackquack.ui.span import team.duckie.quackquack.ui.sugar.QuackBody1 @@ -251,9 +251,9 @@ private fun ExamResultImage( Spacer(space = 4.dp) QuackBody1(text = "${state.solvedCount}명 중 ${round(state.percent)}%!") Spacer(space = 24.dp) - QuackImage( + QuackIcon( modifier = Modifier.size(48.dp, 16.dp), - src = QuackIcon.DuckieTextLogo, + icon = QuackIcon.DuckieTextLogo, ) } } diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/quiz/QuizResultContent.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/quiz/QuizResultContent.kt index 04147218c..b63c3e0b6 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/quiz/QuizResultContent.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/quiz/QuizResultContent.kt @@ -10,7 +10,6 @@ package team.duckie.app.android.feature.exam.result.screen.quiz import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -51,6 +50,7 @@ import team.duckie.quackquack.ui.QuackText import team.duckie.quackquack.ui.span import team.duckie.quackquack.ui.sugar.QuackHeadLine1 import team.duckie.quackquack.ui.sugar.QuackHeadLine2 +import team.duckie.quackquack.ui.sugar.QuackQuote import java.util.Locale @Composable @@ -70,7 +70,6 @@ internal fun QuizResultContent( isPerfectChallenge: Boolean, profileImg: String, myAnswer: String, - onHeartComment: (Int) -> Unit, comments: ImmutableList, commentsTotal: Int, showCommentSheet: () -> Unit, @@ -136,22 +135,7 @@ internal fun QuizResultContent( Spacer(space = 16.dp) // TODO(limsaehyun): QuackText Quote의 버그가 픽스된 후 아래 코드로 변경해야 함 // https://duckie-team.slack.com/archives/C054HU0CKMY/p1688278156256779 - // QuackText(text = message, typography = QuackTypography.Quote) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 30.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - QuackHeadLine1(text = "\"") - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.Center, - ) { - QuackHeadLine1(text = message) - } - QuackHeadLine1(text = "\"") - } + QuackQuote(text = message) Spacer(space = 24.dp) QuackMaxWidthDivider() Row( @@ -223,7 +207,6 @@ internal fun QuizResultContent( ChallengeCommentSection( profileUrl = profileImg, myAnswer = myAnswer, - onHeartComment = onHeartComment, comments = comments, commentsTotal = commentsTotal, showCommentSheet = showCommentSheet, diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeComment.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeComment.kt index 68291f0c9..576436ff2 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeComment.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeComment.kt @@ -43,9 +43,9 @@ import team.duckie.quackquack.material.icon.QuackIcon import team.duckie.quackquack.material.icon.quackicon.Outlined import team.duckie.quackquack.material.icon.quackicon.outlined.Flag import team.duckie.quackquack.material.icon.quackicon.outlined.Heart +import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.ui.QuackIcon import team.duckie.quackquack.ui.QuackText -import team.duckie.quackquack.ui.modifier.quackClickable import team.duckie.quackquack.ui.sugar.QuackBody1 @Composable @@ -118,12 +118,12 @@ internal fun ChallengeComment( modifier: Modifier = Modifier, wrongComment: ExamResultState.Success.ChallengeCommentUiModel, innerPaddingValues: PaddingValues = PaddingValues(), - onHeartClick: (Int) -> Unit, + onHeartClick: ((Int) -> Unit)?, visibleHeart: Boolean, showCommentSheet: () -> Unit, ) { val animateHeartColor = - animateQuackColorAsState(targetValue = if (wrongComment.isHeart) QuackColor.Gray1 else QuackColor.Gray2) + animateQuackColorAsState(targetValue = if (wrongComment.isHeart)QuackColor.Gray1 else QuackColor.Gray2) Row( modifier = modifier @@ -131,7 +131,7 @@ internal fun ChallengeComment( .background(QuackColor.White.value) .padding(innerPaddingValues) .quackClickable( - rippleEnabled = true, + rippleEnabled = false, onClick = showCommentSheet, ) .padding(vertical = 8.dp), @@ -175,7 +175,9 @@ internal fun ChallengeComment( .size(24.dp) .quackClickable( onClick = { - onHeartClick(wrongComment.id) + if (onHeartClick != null) { + onHeartClick(wrongComment.id) + } }, ), icon = if (wrongComment.isHeart) { diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeCommentBottomSheetContent.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeCommentBottomSheetContent.kt index 6de752df0..550d2b898 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeCommentBottomSheetContent.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeCommentBottomSheetContent.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import team.duckie.app.android.common.compose.WrapScaffoldLayout +import team.duckie.app.android.common.compose.ui.QuackDivider import team.duckie.app.android.common.compose.ui.QuackIconWrapper import team.duckie.app.android.common.compose.ui.icon.v1.ArrowSendId import team.duckie.app.android.common.compose.ui.icon.v2.Order18 @@ -39,7 +40,6 @@ import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.icon.QuackIcon import team.duckie.quackquack.ui.QuackText -import team.duckie.quackquack.ui.component.QuackDivider @Composable internal fun ChallengeCommentBottomSheetContent( diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeCommentSection.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeCommentSection.kt index ffd7810ab..0217ffaf3 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeCommentSection.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/screen/wronganswer/ChallengeCommentSection.kt @@ -57,7 +57,6 @@ private val PaleOrange: Color = Color(0xFFFFEFCF) internal fun ColumnScope.ChallengeCommentSection( profileUrl: String, myAnswer: String, - onHeartComment: (Int) -> Unit, commentsTotal: Int, comments: ImmutableList, showCommentSheet: () -> Unit, @@ -155,10 +154,12 @@ internal fun ColumnScope.ChallengeCommentSection( ChallengeComment( modifier = Modifier.fillMaxScreenWidth(), wrongComment = item, - onHeartClick = onHeartComment, innerPaddingValues = PaddingValues(horizontal = 16.dp), visibleHeart = true, showCommentSheet = showCommentSheet, + onHeartClick = { + showCommentSheet() // 하트 클릭 미지원 + }, ) Spacer(space = 8.dp) } diff --git a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/viewmodel/ExamResultViewModel.kt b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/viewmodel/ExamResultViewModel.kt index 37e9e22fd..323459441 100644 --- a/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/viewmodel/ExamResultViewModel.kt +++ b/feature/exam-result/src/main/java/team/duckie/app/android/feature/exam/result/viewmodel/ExamResultViewModel.kt @@ -158,6 +158,7 @@ class ExamResultViewModel @Inject constructor( updateRefreshState(true) getChallengeCommentList() state.updateCommentCreateAt() + refreshQuiz() if (forceLoading) delay(pullToRefreshMinLoadingDelay) updateRefreshState(false) } @@ -183,10 +184,19 @@ class ExamResultViewModel @Inject constructor( } } + private var isWriteSending: Boolean = false + private val existSendingMessage: String = "열심히 댓글을 등록하고 있어요! 조금만 기다려 주세요." + fun writeChallengeComment() = intent { val state = state as ExamResultState.Success val myComment = state.myWrongComment + if (isWriteSending) { + postSideEffect(ExamResultSideEffect.SendErrorToast(existSendingMessage)) + return@intent + } + isWriteSending = true + if (myComment.isNotEmpty()) { writeChallengeCommentUseCase( challengeId = state.examId, @@ -205,6 +215,8 @@ class ExamResultViewModel @Inject constructor( } }.onFailure { exception -> postSideEffect(ExamResultSideEffect.ReportError(exception)) + }.also { + isWriteSending = false } } } @@ -459,6 +471,28 @@ class ExamResultViewModel @Inject constructor( } } + private fun refreshQuiz() = intent { + val state = state as ExamResultState.Success + getQuizUseCase(state.examId).onSuccess { quizResult -> + reduce { + with(quizResult) { + state.copy( + popularComments = popularComments?.fastMap(ChallengeComment::toUiModel) + ?.toImmutableList() ?: persistentListOf(), + commentsTotal = commentsTotal ?: 0, + equalAnswerCount = wrongAnswer?.meTotal ?: 0, + ) + } + } + }.onFailure { + it.printStackTrace() + reduce { + ExamResultState.Error(exception = it) + } + postSideEffect(ExamResultSideEffect.ReportError(it)) + } + } + fun updateReaction(reaction: String) = intent { val state = state as ExamResultState.Success diff --git a/feature/friends/build.gradle.kts b/feature/friends/build.gradle.kts index 60c50cfbd..9146e1e10 100644 --- a/feature/friends/build.gradle.kts +++ b/feature/friends/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { libs.ktx.lifecycle.runtime, libs.compose.ui.material, // needs for Scaffold libs.compose.lifecycle.runtime, - libs.quack.ui.components, + libs.quack.v2.ui, + libs.kotlin.collections.immutable, ) } diff --git a/feature/friends/src/main/java/team/duckie/app/android/feature/friends/FriendsActivity.kt b/feature/friends/src/main/java/team/duckie/app/android/feature/friends/FriendsActivity.kt index 2b379a561..ebce07de5 100644 --- a/feature/friends/src/main/java/team/duckie/app/android/feature/friends/FriendsActivity.kt +++ b/feature/friends/src/main/java/team/duckie/app/android/feature/friends/FriendsActivity.kt @@ -14,14 +14,14 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import dagger.hilt.android.AndroidEntryPoint import org.orbitmvi.orbit.viewmodel.observe -import team.duckie.app.android.feature.friends.viewmodel.FriendsViewModel -import team.duckie.app.android.feature.friends.viewmodel.sideeffect.FriendsSideEffect -import team.duckie.app.android.navigator.feature.profile.ProfileNavigator import team.duckie.app.android.common.android.exception.handling.reporter.reportToCrashlyticsIfNeeded import team.duckie.app.android.common.android.ui.BaseActivity import team.duckie.app.android.common.android.ui.const.Extras import team.duckie.app.android.common.android.ui.finishWithAnimation -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.app.android.feature.friends.viewmodel.FriendsViewModel +import team.duckie.app.android.feature.friends.viewmodel.sideeffect.FriendsSideEffect +import team.duckie.app.android.navigator.feature.profile.ProfileNavigator +import team.duckie.quackquack.material.theme.QuackTheme import javax.inject.Inject @AndroidEntryPoint diff --git a/feature/friends/src/main/java/team/duckie/app/android/feature/friends/FriendsScreen.kt b/feature/friends/src/main/java/team/duckie/app/android/feature/friends/FriendsScreen.kt index 4481cd03b..f2de31a07 100644 --- a/feature/friends/src/main/java/team/duckie/app/android/feature/friends/FriendsScreen.kt +++ b/feature/friends/src/main/java/team/duckie/app/android/feature/friends/FriendsScreen.kt @@ -13,7 +13,9 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager @@ -23,6 +25,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -32,19 +35,21 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch -import team.duckie.app.android.feature.friends.viewmodel.FriendsViewModel -import team.duckie.app.android.feature.friends.viewmodel.state.FriendsState +import team.duckie.app.android.common.compose.systemBarPaddings import team.duckie.app.android.common.compose.ui.BackPressedHeadLine2TopAppBar import team.duckie.app.android.common.compose.ui.ErrorScreen import team.duckie.app.android.common.compose.ui.NoItemScreen import team.duckie.app.android.common.compose.ui.Spacer -import team.duckie.app.android.common.compose.ui.UserFollowingLayout +import team.duckie.app.android.common.compose.ui.content.UserFollowingLayout import team.duckie.app.android.common.compose.ui.skeleton -import team.duckie.app.android.common.compose.systemBarPaddings import team.duckie.app.android.common.kotlin.FriendsType -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackHeadLine2 -import team.duckie.quackquack.ui.component.QuackMainTab +import team.duckie.app.android.feature.friends.viewmodel.FriendsViewModel +import team.duckie.app.android.feature.friends.viewmodel.state.FriendsState +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackTab +import team.duckie.quackquack.ui.QuackTabColors +import team.duckie.quackquack.ui.QuackText @Composable internal fun FriendScreen( @@ -59,31 +64,36 @@ internal fun FriendScreen( ) } val state by viewModel.container.stateFlow.collectAsStateWithLifecycle() - val pagerState = rememberPagerState(initialPage = state.friendType.index) + val pagerState = rememberPagerState( + initialPage = state.friendType.index, + pageCount = { FriendsType.values().size }, + ) val coroutineScope = rememberCoroutineScope() Column( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor) + .background(color = QuackColor.White.value) .padding(systemBarPaddings), ) { BackPressedHeadLine2TopAppBar( title = state.targetName, onBackPressed = onPrevious, ) - QuackMainTab( - titles = tabs, - selectedTabIndex = pagerState.currentPage, - onTabSelected = { index -> - coroutineScope.launch { - pagerState.animateScrollToPage(index) + QuackTab( + index = pagerState.currentPage, + colors = QuackTabColors.defaultTabColors(), + ) { + tabs.forEach { label -> + tab(label) { index -> + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } } - }, - ) + } + } HorizontalPager( modifier = Modifier.fillMaxSize(), - pageCount = FriendsType.values().size, state = pagerState, ) { index -> if (state.isError) { @@ -198,7 +208,9 @@ private fun FriendNotFoundScreen( ) } else { NoItemScreen( - modifier = Modifier.padding(top = 72.dp), + modifier = Modifier + .fillMaxWidth() + .wrapContentWidth(CenterHorizontally), title = stringResource( id = if (isFollower) { R.string.follower_not_found_title @@ -228,11 +240,13 @@ private fun MyPageFriendNotFoundScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(space = 74.5.dp) - QuackHeadLine2( + QuackText( modifier = Modifier.skeleton(isLoading), text = title, - color = QuackColor.Gray1, - align = TextAlign.Center, + typography = QuackTypography.HeadLine2.change( + color = QuackColor.Gray1, + textAlign = TextAlign.Center, + ), ) } } @@ -257,10 +271,10 @@ private fun FriendListScreen( favoriteTag = item.favoriteTag, tier = item.tier, isFollowing = item.isFollowing, - onClickFollow = { follow -> + onClickTrailingButton = { follow -> onClickFollow(item.userId, follow) }, - isMine = myUserId == item.userId, + visibleTrailingButton = myUserId != item.userId, onClickUserProfile = onClickUserProfile, ) } diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index f2a35bd98..60b93a5fd 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -32,7 +32,7 @@ dependencies { projects.navigator, libs.orbit.viewmodel, libs.orbit.compose, - libs.quack.ui.components, + libs.kotlin.collections.immutable, libs.quack.v2.ui, libs.compose.lifecycle.runtime, libs.compose.ui.navigation, diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/BaseBottomLayout.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/BaseBottomLayout.kt index 257eb72ae..3aa960314 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/BaseBottomLayout.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/BaseBottomLayout.kt @@ -20,8 +20,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackDivider +import team.duckie.app.android.common.compose.ui.DuckieDivider +import team.duckie.quackquack.material.QuackColor @Composable internal fun BaseBottomLayout( @@ -36,11 +36,11 @@ internal fun BaseBottomLayout( .fillMaxWidth() .height(48.dp), ) { - QuackDivider() + DuckieDivider() Row( modifier = Modifier .fillMaxSize() - .background(QuackColor.White.composeColor) + .background(QuackColor.White.value) .padding(contentPadding), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/DuckTestBottomNavigation.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/DuckTestBottomNavigation.kt index 3d3faceb6..d5e164824 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/DuckTestBottomNavigation.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/DuckTestBottomNavigation.kt @@ -18,21 +18,23 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import okhttp3.internal.immutableListOf -import team.duckie.app.android.feature.home.R import team.duckie.app.android.common.kotlin.fastForEachIndexed -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody2 -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.app.android.feature.home.R +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackText /** * [DuckTestBottomNavigation] 를 그리는데 필요한 리소스들을 정의합니다. @@ -41,7 +43,7 @@ private object DuckTestBottomNavigationDefaults { val Height = 52.dp val BackgroundColor = QuackColor.White - val IconSize = DpSize(all = 24.dp) + val IconSize = DpSize(width = 24.dp, height = 24.dp) } /** @@ -71,7 +73,7 @@ internal fun DuckTestBottomNavigation( height = Height, ) .background( - color = BackgroundColor.composeColor, + color = BackgroundColor.value, ), ) { rememberBottomNavigationIcons().fastForEachIndexed { index, icons -> @@ -83,25 +85,29 @@ internal fun DuckTestBottomNavigation( .fillMaxSize() .quackClickable( rippleEnabled = false, - ) { - onClick( - /* index = */ - index, - ) - }, + onClick = { + onClick(index) + }, + ), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { QuackImage( + modifier = Modifier.size(IconSize), src = icons.pick( isSelected = index == selectedIndex, ), - size = IconSize, ) Spacer(modifier = Modifier.height(8.dp)) - QuackBody2( + QuackText( text = stringResource(id = icons.title), - color = if (index == selectedIndex) QuackColor.Black else QuackColor.Gray1, + typography = QuackTypography.Body2.change( + color = if (index == selectedIndex) { + QuackColor.Black + } else { + QuackColor.Gray1 + }, + ), ) } } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/HeadLineTopAppBar.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/HeadLineTopAppBar.kt index 75e752aae..bd890976f 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/HeadLineTopAppBar.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/HeadLineTopAppBar.kt @@ -15,7 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import team.duckie.quackquack.ui.component.QuackHeadLine1 +import team.duckie.quackquack.ui.sugar.QuackHeadLine1 @Composable internal fun HeadLineTopAppBar( diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/HomeTopAppBar.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/HomeTopAppBar.kt index 9ba9647dd..fb6274483 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/HomeTopAppBar.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/component/HomeTopAppBar.kt @@ -22,24 +22,23 @@ import okhttp3.internal.immutableListOf import team.duckie.app.android.common.compose.ui.TextTabLayout import team.duckie.app.android.feature.home.R import team.duckie.quackquack.material.QuackTypography -import team.duckie.quackquack.ui.util.DpSize import team.duckie.quackquack.material.QuackColor as QuackV2Color -internal val HomeIconSize = DpSize(24.dp) - -@Suppress("UnusedPrivateMember") // 시험 생성하기를 추후에 다시 활용하기 위함 +@Suppress("unused") // 진행중 API 추가 작업을 위함 @Composable internal fun HomeTopAppBar( modifier: Modifier = Modifier, selectedTabIndex: Int, onTabSelected: (Int) -> Unit, onClickedCreate: () -> Unit, + onClickedNotice: () -> Unit, ) { val context = LocalContext.current val homeTextTabTitles = remember { immutableListOf( context.getString(R.string.recommend), + // context.getString(R.string.proceed), context.getString(R.string.following), ) } @@ -53,15 +52,25 @@ internal fun HomeTopAppBar( ) { TextTabLayout( titles = homeTextTabTitles.toImmutableList(), + selectedTabStyle = QuackTypography.HeadLine1, selectedTabIndex = selectedTabIndex, onTabSelected = onTabSelected, - tabStyle = QuackTypography.Title2.change(color = QuackV2Color.Gray2), + tabStyle = QuackTypography.HeadLine1.change(color = QuackV2Color.Gray2), ) -// TODO(limsaehyun): 시험 생성하기가 가능한 스펙에서 활용 -// QuackImage( -// src = QuackIcon.Create, -// onClick = onClickedCreate, -// size = HomeIconSize, -// ) + + // QuackImage( + // src = R.drawable.home_ic_notice, + // modifier = Modifier + // .quackClickable(onClick = onClickedNotice) + // .size(DpSize(24.dp, 24.dp)), + // ) + // + // // TODO(riflockle7): 임시로 활성화 + // QuackIcon( + // modifier = Modifier + // .quackClickable(onClick = onClickedCreate) + // .size(DpSize(24.dp, 24.dp)), + // icon = OutlinedGroup.Create, + // ) } } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/constants/HomeStep.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/constants/HomeStep.kt index e4dc92a1b..1200d7d42 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/constants/HomeStep.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/constants/HomeStep.kt @@ -25,6 +25,11 @@ internal enum class HomeStep( HomeFollowingScreen( index = 1, ), + + // TODO(riflockle7): 추후 index = 1 + HomeProceedScreen( + index = 2, + ), ; companion object { diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/MainActivity.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/MainActivity.kt index 59bab7cce..01243bca0 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/MainActivity.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/MainActivity.kt @@ -43,7 +43,7 @@ import team.duckie.app.android.navigator.feature.profile.ViewAllNavigator import team.duckie.app.android.navigator.feature.search.SearchNavigator import team.duckie.app.android.navigator.feature.setting.SettingNavigator import team.duckie.app.android.navigator.feature.tagedit.TagEditNavigator -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.quackquack.material.theme.QuackTheme import javax.inject.Inject @AllowMagicNumber("앱 종료 시간에 대해서 매직 넘버 처리") @@ -57,7 +57,7 @@ class MainActivity : BaseActivity() { private val homeViewModel: HomeViewModel by viewModels() @Inject - lateinit var createProblemNavigator: CreateProblemNavigator + lateinit var createExamNavigator: CreateProblemNavigator @Inject lateinit var notificationNavigator: NotificationNavigator @@ -162,11 +162,6 @@ class MainActivity : BaseActivity() { } } - override fun onStart() { - super.onStart() - myPageViewModel.getUserProfile() - } - private fun startedGuide(intent: Intent) { intent.getBooleanExtra(Extras.StartGuide, false).also { start -> if (start) { @@ -186,6 +181,7 @@ class MainActivity : BaseActivity() { startActivityWithAnimation( intentBuilder = { putExtra(Extras.SearchTag, sideEffect.searchTag) + putExtra(Extras.AutoFocusing, sideEffect.autoFocusing) }, ) } @@ -200,7 +196,7 @@ class MainActivity : BaseActivity() { } is MainSideEffect.NavigateToCreateProblem -> { - createProblemNavigator.navigateFrom(activity = this) + createExamNavigator.navigateFrom(activity = this) } is MainSideEffect.NavigateToSetting -> { @@ -233,6 +229,15 @@ class MainActivity : BaseActivity() { is MainSideEffect.CopyExamIdDynamicLink -> { DynamicLinkHelper.createAndShareLink(this, sideEffect.examId) } + + is MainSideEffect.NavigateToProfile -> { + profileNavigator.navigateFrom( + activity = this, + intentBuilder = { + putExtra(Extras.UserId, sideEffect.userId) + }, + ) + } } } } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/MainScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/MainScreen.kt index e2b092dc5..f168c6a7b 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/MainScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/MainScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material.Divider import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout @@ -37,8 +38,7 @@ import team.duckie.app.android.feature.home.viewmodel.home.HomeViewModel import team.duckie.app.android.feature.home.viewmodel.mypage.MyPageViewModel import team.duckie.app.android.feature.home.viewmodel.ranking.RankingViewModel import team.duckie.app.android.feature.profile.viewmodel.state.ExamType -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackDivider +import team.duckie.quackquack.material.QuackColor private const val MainCrossFacadeLayoutId = "MainCrossFacade" private const val MainBottomNavigationDividerLayoutId = "MainBottomNavigationDivider" @@ -114,7 +114,7 @@ internal fun MainScreen( Layout( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor) + .background(color = QuackColor.White.value) .padding(systemBarPaddings), content = { HomeGuideScreen( @@ -141,6 +141,7 @@ internal fun MainScreen( navigateToCreateProblem = { mainViewModel.navigateToCreateProblem() }, + navigateToProfile = mainViewModel::navigateToProfile, setTargetExamId = { examId -> mainViewModel.setTargetExamId(examId) }, @@ -209,7 +210,7 @@ internal fun MainScreen( } } - QuackDivider( + Divider( modifier = Modifier.layoutId(MainBottomNavigationDividerLayoutId), ) DuckTestBottomNavigation( diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/guide/HomeGuideFeatureScrren.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/guide/HomeGuideFeatureScrren.kt index fc4cad724..6bd5e8c98 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/guide/HomeGuideFeatureScrren.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/guide/HomeGuideFeatureScrren.kt @@ -28,15 +28,14 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.DuckieHorizontalPagerIndicator import team.duckie.app.android.feature.home.R import team.duckie.app.android.feature.home.component.BaseBottomLayout import team.duckie.app.android.feature.home.constants.GuideStep -import team.duckie.app.android.common.compose.ui.DuckieHorizontalPagerIndicator -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackHeadLine1 -import team.duckie.quackquack.ui.component.QuackHeadLine2 -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.modifier.quackClickable +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackText @Composable internal fun HomeGuideFeatureScreen( @@ -46,17 +45,21 @@ internal fun HomeGuideFeatureScreen( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, content = { - QuackHeadLine2( + QuackText( modifier = Modifier .padding(top = 41.dp), text = stringResource(id = guideStep.subtitle), - color = QuackColor.White, - align = TextAlign.Center, + typography = QuackTypography.HeadLine2.change( + color = QuackColor.White, + textAlign = TextAlign.Center, + ), ) - QuackHeadLine1( + QuackText( text = stringResource(id = guideStep.title), - color = QuackColor.DuckieOrange, - align = TextAlign.Center, + typography = QuackTypography.HeadLine1.change( + color = QuackColor.DuckieOrange, + textAlign = TextAlign.Center, + ), ) Image( modifier = Modifier @@ -84,15 +87,13 @@ internal fun HomeGuideFeatureBottomLayout( modifier = Modifier .fillMaxWidth() .height(48.dp) - .quackClickable { - onNext() - } - .background(color = QuackColor.DuckieOrange.composeColor), + .quackClickable(onClick = onNext) + .background(color = QuackColor.DuckieOrange.value), contentAlignment = Alignment.Center, ) { - QuackHeadLine2( + QuackText( text = stringResource(id = R.string.start_duckie), - color = QuackColor.White, + typography = QuackTypography.HeadLine2.change(color = QuackColor.White), ) } } else { @@ -105,10 +106,12 @@ internal fun HomeGuideFeatureBottomLayout( ) }, trailingContent = { - QuackSubtitle( + QuackText( + modifier = Modifier.quackClickable(onClick = onNext), text = stringResource(id = R.string.next), - color = QuackColor.DuckieOrange, - onClick = onNext, + typography = QuackTypography.Subtitle.change( + color = QuackColor.DuckieOrange, + ), ) }, ) diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/guide/HomeGuideScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/guide/HomeGuideScreen.kt index 736b433ad..c16a63376 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/guide/HomeGuideScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/guide/HomeGuideScreen.kt @@ -5,7 +5,7 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -@file:OptIn(ExperimentalFoundationApi::class) +@file:OptIn(ExperimentalFoundationApi::class, ExperimentalQuackQuackApi::class) package team.duckie.app.android.feature.home.screen.guide @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -34,17 +35,17 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState +import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.ui.temp.TempFlexiblePrimaryLargeButton import team.duckie.app.android.feature.home.R import team.duckie.app.android.feature.home.constants.GuideStep import team.duckie.app.android.feature.home.viewmodel.guide.HomeGuideViewModel -import team.duckie.app.android.common.compose.ui.Spacer -import team.duckie.app.android.common.compose.activityViewModel -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody2 -import team.duckie.quackquack.ui.component.QuackHeadLine1 -import team.duckie.quackquack.ui.component.QuackSmallButton -import team.duckie.quackquack.ui.component.QuackSmallButtonType -import team.duckie.quackquack.ui.modifier.quackClickable +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi @Composable internal fun HomeGuideScreen( @@ -53,14 +54,17 @@ internal fun HomeGuideScreen( onClose: () -> Unit, ) { val state = vm.collectAsState().value - val pagerState = rememberPagerState() - val pageCount = GuideStep.values().size + val pageCount = remember { GuideStep.values().size } + val pagerState = rememberPagerState( + + pageCount = { pageCount }, + ) val coroutineScope = rememberCoroutineScope() Box( modifier = modifier .fillMaxSize() - .background(color = QuackColor.Black.composeColor.copy(alpha = 0.9F)), + .background(color = QuackColor.Black.value.copy(alpha = 0.9F)), contentAlignment = Alignment.BottomCenter, ) { if (state.isGuideStarted) { @@ -82,18 +86,17 @@ internal fun HomeGuideScreen( .navigationBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { - QuackBody2( + QuackText( modifier = Modifier + .quackClickable(onClick = onClose) .fillMaxWidth() .wrapContentWidth(Alignment.End) .padding(top = 40.dp, end = 16.dp), text = stringResource(id = R.string.skip), - color = QuackColor.Gray3, - onClick = onClose, + typography = QuackTypography.Body2.change(color = QuackColor.Gray3), ) HorizontalPager( modifier = Modifier.fillMaxSize(), - pageCount = pageCount, state = pagerState, ) { index -> HomeGuideFeatureScreen( @@ -136,25 +139,26 @@ private fun HomeGuideStartScreen( contentScale = ContentScale.Fit, ) Spacer(space = 12.dp) - QuackHeadLine1( + QuackText( + modifier = Modifier.align(Alignment.CenterHorizontally), text = stringResource(id = R.string.guide_start_message), - color = QuackColor.White, - align = TextAlign.Center, + typography = QuackTypography.HeadLine1.change( + color = QuackColor.White, + textAlign = TextAlign.Center, + ), ) Spacer(space = 20.dp) - QuackSmallButton( - modifier = Modifier - .size(118.dp, 44.dp), - type = QuackSmallButtonType.Fill, + TempFlexiblePrimaryLargeButton( text = stringResource(id = R.string.guide_start_accept_message), + modifier = Modifier.size(118.dp, 44.dp), enabled = true, onClick = onNext, ) Spacer(space = 16.dp) - QuackBody2( + QuackText( + modifier = Modifier.quackClickable(onClick = onClosed), text = stringResource(id = R.string.guide_start_deny_message), - color = QuackColor.Gray2, - onClick = onClosed, + typography = QuackTypography.Body2.change(color = QuackColor.Gray2), ) } } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeProceedScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeProceedScreen.kt new file mode 100644 index 000000000..28038ede3 --- /dev/null +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeProceedScreen.kt @@ -0,0 +1,526 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +@file:OptIn( + ExperimentalMaterialApi::class, + ExperimentalQuackQuackApi::class, +) + +@file:Suppress("unused") // 더미 값, 미구현 된 내용 +@file:AllowMagicNumber("더미 값, 미구현 된 내용") + +package team.duckie.app.android.feature.home.screen.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import team.duckie.app.android.common.compose.GetHeightRatioW129H84 +import team.duckie.app.android.common.compose.GetHeightRatioW328H240 +import team.duckie.app.android.common.compose.GetHeightRatioW360H90 +import team.duckie.app.android.common.compose.GetHeightRatioW85H63 +import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.ui.icon.v1.ArrowRightId +import team.duckie.app.android.common.compose.ui.quack.QuackProfileImage +import team.duckie.app.android.common.kotlin.AllowMagicNumber +import team.duckie.app.android.feature.home.R +import team.duckie.app.android.feature.home.component.HomeTopAppBar +import team.duckie.app.android.feature.home.constants.HomeStep +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedCategory.categories +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedCategory.categoryThumbnailUrl +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedCategory.items +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedItemView.currentExamCount +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedItemView.joinCount +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedItemView.maximumExamCount +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedItemView.nickname +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedItemView.profileImageUrl +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedItemView.remainCount +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedItemView.thumbnailUrl +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.ProceedItemView.title +import team.duckie.app.android.feature.home.screen.home.HomeProceedTempConstants.username +import team.duckie.app.android.feature.home.viewmodel.home.HomeState +import team.duckie.app.android.feature.home.viewmodel.home.HomeViewModel +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackTag +import team.duckie.quackquack.ui.QuackTagStyle +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackBody1 +import team.duckie.quackquack.ui.sugar.QuackSubtitle2 +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi + +object HomeProceedTempConstants { + const val username = "무지개양말" + object ProceedItemView { + const val title = "투바투 덕력고사" + const val thumbnailUrl = + "https://duckie-resource.s3.ap-northeast-2.amazonaws.com/exam/thumbnail/1684545439537" + const val maximumExamCount = 10 + const val currentExamCount = 9 + val remainCount: Int + get() = maximumExamCount - currentExamCount + const val profileImageUrl = + "https://duckie-resource.s3.ap-northeast-2.amazonaws.com/profile/1686068260083" + const val nickname = "킹도로" + const val joinCount = 5 + } + + object ProceedCategory { + val categories = listOf("전체", "애니", "아이돌", "영화", "운동", "트렌드") + const val categoryThumbnailUrl = + "https://duckie-resource.s3.ap-northeast-2.amazonaws.com/exam/thumbnail/1684545439537" + val items = listOf("투바투 덕력고사", "베스킨라빈스 31 덕력고사", "예능 덕력고사", "코난 극장판 덕력고사", "아따맘마 덕력고사") + } +} + +@Suppress("unused") // 더미 값 +@Composable +internal fun HomeProceedScreen( + modifier: Modifier = Modifier, + state: HomeState, + homeViewModel: HomeViewModel = activityViewModel(), + navigateToCreateProblem: () -> Unit, + navigateToHomeDetail: (Int) -> Unit, + navigateToSearch: (String) -> Unit, + openExamBottomSheet: (Int) -> Unit, +) { + val pullRefreshState = rememberPullRefreshState( + refreshing = state.isHomeProceedPullRefreshLoading, + onRefresh = { + homeViewModel.refreshProceeds(forceLoading = true) + }, + ) + + Box(modifier = Modifier.pullRefresh(pullRefreshState)) { + LazyColumn(modifier = modifier.fillMaxSize()) { + item { + HomeTopAppBar( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + selectedTabIndex = state.homeSelectedIndex.index, + onTabSelected = { step -> + homeViewModel.changedHomeScreen(HomeStep.toStep(step)) + }, + onClickedCreate = navigateToCreateProblem, + onClickedNotice = {}, + ) + } + + // 공백 + item { + Spacer(Modifier.height(12.dp)) + } + + // 제목 + item { + QuackText( + modifier = Modifier.padding(start = 16.dp), + text = stringResource(id = R.string.home_proceed_title), + typography = QuackTypography.HeadLine1, + ) + } + + // 공백 + item { + Spacer(Modifier.height(12.dp)) + } + + // 진행중인 덕력고사 목록 뷰 + items(10) { + ProceedItemView() + } + + // 공백 + item { + Spacer(modifier = Modifier.height(8.dp)) + } + + // 전체보기 버튼 + item { + ProceedViewAllButton(onViewAllClick = {}) + } + + // 공백 + item { + Spacer(Modifier.height(48.dp)) + } + + // 덕력고사 진행중 배너 뷰 + item { + ProceedBannerView() + } + + // 공백 + item { + Spacer(Modifier.height(48.dp)) + } + + // 덕력고사 진행 중 카테고리 영역 뷰 + item { + ProceedCategorySection( + selectedTagIndex = 0, + tagItemClick = {}, + categories = categories, + items = items, + ) + } + } + PullRefreshIndicator( + modifier = Modifier.align(Alignment.TopCenter), + refreshing = state.isHomeRecommendPullRefreshLoading, + state = pullRefreshState, + ) + } +} + +/** 진행중인 덕력고사 Item 뷰 */ +@Composable +fun ProceedItemView() { + Column( + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 16.dp) + .clip(RoundedCornerShape(8.dp)) + .border( + width = 1.dp, + color = QuackColor.Gray3.value, + shape = RoundedCornerShape(8.dp), + ) + .quackClickable( + onClick = {}, + ), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip( + RoundedCornerShape( + topStart = 8.dp, + topEnd = 8.dp, + bottomEnd = 0.dp, + bottomStart = 0.dp, + ), + ), + ) { + // 덕퀴즈/덕질고사 썸네일 이미지 + AsyncImage( + model = thumbnailUrl, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(ratio = GetHeightRatioW328H240), + contentScale = ContentScale.FillBounds, + contentDescription = null, + ) + + // 오픈까지 x문제 + QuackText( + modifier = Modifier + .clip(RoundedCornerShape(bottomEnd = 4.dp)) + .background( + if (remainCount == 1) { + QuackColor.DuckieOrange.value + } else { + Color(0xFF222222) + }, + ) + .padding(vertical = 4.dp, horizontal = 8.dp), + text = stringResource( + id = R.string.home_proceed_item_count_down, + remainCount, + ), + typography = QuackTypography.Body3.change(color = QuackColor.White), + ) + } + + // 만들어진 문제 개수 / 최대 문제 개수 비율 막대 그래프 + Row(modifier = Modifier.height(8.dp)) { + // 첫 번째 막대 (8:2 비율) + Box( + modifier = Modifier + .fillMaxHeight() + .weight(currentExamCount.toFloat()) + .background(QuackColor.DuckieOrange.value), + ) + + // 두 번째 막대 (8:2 비율) + Box( + modifier = Modifier + .fillMaxHeight() + .weight(remainCount.toFloat()) + .background(QuackColor.Gray3.value), + ) + } + + // 덕력고사 정보 & 참여율 + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + // 덕력고사 이름 + QuackText(text = title, typography = QuackTypography.HeadLine2) + + Spacer(modifier = Modifier.height(4.dp)) + + Row(horizontalArrangement = Arrangement.Center) { + // 프로필 이미지 + QuackProfileImage( + modifier = Modifier.size(DpSize(width = 16.dp, height = 16.dp)), + profileUrl = profileImageUrl, + ) + + // 닉네임 + 참여 인원수 + QuackText( + modifier = Modifier.padding(start = 4.dp), + text = stringResource( + R.string.home_proceed_item_info, + nickname, + stringResource(id = R.string.home_proceed_item_join_count, joinCount), + ), + typography = QuackTypography.Body2.change(color = QuackColor.Gray1), + ) + + // 너비 + Spacer(weight = 1f) + + // 만들어진 문제 개수 / 최대 문제 개수 + QuackText( + modifier = Modifier.padding(start = 4.dp), + text = stringResource( + R.string.home_proceed_item_problem_count, + currentExamCount, + maximumExamCount, + ), + typography = QuackTypography.Body2.change(color = QuackColor.Gray1), + ) + } + } + } +} + +/** 배너 뷰 */ +@Composable +fun ProceedBannerView() { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(ratio = GetHeightRatioW360H90) + .background(Color(0xFFFFF8CF)), + ) { + Column( + modifier = Modifier + .padding(start = 16.dp) + .align(Alignment.CenterStart), + verticalArrangement = Arrangement.SpaceBetween, + ) { + // 직접 덕력고사를 열고 싶다면 + QuackText( + modifier = Modifier.padding(start = 8.dp), + text = stringResource(id = R.string.home_proceed_banner_title), + typography = QuackTypography.HeadLine2, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // 출제 제안하기 버튼 + Row( + modifier = Modifier + .clip(RoundedCornerShape(100.dp)) + .background(QuackColor.White.value) + .padding(vertical = 6.dp, horizontal = 12.dp), + ) { + // 텍스트 + QuackSubtitle2( + text = stringResource(id = R.string.home_proceed_banner_submit_button_title), + ) + + // 공백 + Spacer(space = 4.dp) + + // 우측 방향 화살표 + QuackImage( + modifier = Modifier.size(DpSize(width = 16.dp, height = 16.dp)), + src = QuackIcon.ArrowRightId, + ) + } + } + + // 배너 우측 이미지 + QuackImage( + modifier = Modifier + .padding(top = 6.dp) + .aspectRatio(GetHeightRatioW129H84) + .align(Alignment.CenterEnd), + src = R.drawable.home_proceed_banner_right, + ) + } +} + +/** 진행중인 덕력고사 카테고리 섹션 */ +@Composable +fun ProceedCategorySection( + selectedTagIndex: Int = 0, + tagItemClick: (String) -> Unit, + categories: List, + items: List, +) { + // 제목 + QuackText( + modifier = Modifier.padding(start = 16.dp), + text = stringResource( + id = R.string.home_proceed_category_title, + username, + ), + typography = QuackTypography.HeadLine1, + ) + + // 공백 + Spacer(modifier = Modifier.height(14.dp)) + + // 카테고리 목록 + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + itemsIndexed(items = categories) { index, tagName -> + QuackTag( + text = tagName, + style = QuackTagStyle.Outlined, + onClick = { + tagItemClick(tagName) + }, + selected = index == selectedTagIndex, + ) + } + } + + // 공백 + Spacer(modifier = Modifier.height(14.dp)) + + // 카테고리에 해당하는 덕력고사 목록 + Column { + items.forEach { item -> + ProceedCategoryItemView(categoryItem = item) + } + } + + // 전체보기 버튼 + ProceedViewAllButton(onViewAllClick = {}) + + // 공백 + Spacer(modifier = Modifier.height(20.dp)) +} + +/** 카테고리별 뷰[ProceedCategorySection]에 보이는 Item 뷰 */ +@Composable +fun ProceedCategoryItemView(categoryItem: String) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // 덕퀴즈/덕질고사 썸네일 이미지 + AsyncImage( + model = categoryThumbnailUrl, + modifier = Modifier + .width(85.dp) + .clip(RoundedCornerShape(8.dp)) + .aspectRatio(ratio = GetHeightRatioW85H63), + contentScale = ContentScale.FillBounds, + contentDescription = null, + ) + + // 덕력고사 정보 & 참여율 + Column(modifier = Modifier.padding(start = 8.dp)) { + // 덕력고사 이름 + QuackText(text = title, typography = QuackTypography.HeadLine2) + + // 공백 + Spacer(modifier = Modifier.height(4.dp)) + + Row(horizontalArrangement = Arrangement.Center) { + // 프로필 이미지 + QuackProfileImage( + modifier = Modifier.size(DpSize(width = 16.dp, height = 16.dp)), + profileUrl = profileImageUrl, + ) + + // 닉네임 + 참여 인원수 + QuackText( + modifier = Modifier.padding(start = 4.dp), + text = stringResource( + R.string.home_proceed_item_info, + nickname, + stringResource(id = R.string.home_proceed_item_join_count, joinCount), + ), + typography = QuackTypography.Body2.change(color = QuackColor.Gray1), + ) + } + } + } + + // 공백 + Spacer(modifier = Modifier.height(16.dp)) +} + +/** 전체 보기 버튼 */ +@Composable +private fun ProceedViewAllButton(onViewAllClick: () -> Unit) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(size = 8.dp)) + .quackClickable(onClick = onViewAllClick) + .border( + width = 1.dp, + color = QuackColor.Gray3.value, + shape = RoundedCornerShape(8.dp), + ) + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + QuackBody1( + text = stringResource(id = R.string.home_proceed_view_all_button_title), + ) + + Spacer(space = 4.dp) + + QuackImage( + modifier = Modifier.size(DpSize(width = 24.dp, height = 24.dp)), + src = QuackIcon.ArrowRightId, + ) + } +} diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendFollowingExamScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendFollowingExamScreen.kt index 9d2f6fbb4..3236f9ece 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendFollowingExamScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendFollowingExamScreen.kt @@ -40,29 +40,30 @@ import androidx.compose.ui.unit.dp import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemsIndexed import coil.compose.AsyncImage +import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.common.compose.collectAndHandleState +import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.ui.quack.QuackProfileImage +import team.duckie.app.android.common.compose.ui.skeleton import team.duckie.app.android.feature.home.R import team.duckie.app.android.feature.home.component.HomeTopAppBar import team.duckie.app.android.feature.home.constants.HomeStep +import team.duckie.app.android.feature.home.constants.MainScreenType import team.duckie.app.android.feature.home.viewmodel.home.HomeState import team.duckie.app.android.feature.home.viewmodel.home.HomeViewModel -import team.duckie.app.android.common.compose.ui.Spacer -import team.duckie.app.android.common.compose.ui.skeleton -import team.duckie.app.android.common.compose.activityViewModel -import team.duckie.app.android.common.compose.collectAndHandleState -import team.duckie.app.android.common.compose.ui.quack.QuackProfileImage -import team.duckie.app.android.feature.home.constants.MainScreenType -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody2 -import team.duckie.quackquack.ui.component.QuackBody3 -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.component.QuackSubtitle2 -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackBody2 +import team.duckie.quackquack.ui.sugar.QuackBody3 +import team.duckie.quackquack.ui.sugar.QuackSubtitle2 internal const val ThumbnailRatio = 4f / 3f private val HomeProfileSize: DpSize = DpSize( - all = 24.dp, + width = 32.dp, + height = 32.dp, ) @Composable @@ -73,6 +74,7 @@ internal fun HomeRecommendFollowingExamScreen( state: HomeState, navigateToCreateProblem: () -> Unit, navigateToHomeDetail: (Int) -> Unit, + navigateToProfile: (Int) -> Unit, ) { val followingExam = vm.followingExam.collectAndHandleState(vm::handleLoadRecommendFollowingState) @@ -105,6 +107,7 @@ internal fun HomeRecommendFollowingExamScreen( followingExam = followingExam, navigateToCreateProblem = navigateToCreateProblem, navigateToHomeDetail = navigateToHomeDetail, + navigateToProfile = navigateToProfile, ) } } @@ -118,6 +121,7 @@ private fun HomeRecommendFollowingSuccessScreen( followingExam: LazyPagingItems, navigateToCreateProblem: () -> Unit, navigateToHomeDetail: (Int) -> Unit, + navigateToProfile: (Int) -> Unit, ) { Box( modifier = Modifier @@ -136,6 +140,7 @@ private fun HomeRecommendFollowingSuccessScreen( vm.changedHomeScreen(HomeStep.toStep(step)) }, onClickedCreate = navigateToCreateProblem, + onClickedNotice = {}, ) } @@ -148,10 +153,10 @@ private fun HomeRecommendFollowingSuccessScreen( title = maker?.title ?: "", tier = maker?.owner?.tier ?: "", favoriteTag = maker?.owner?.favoriteTag ?: "", - onClickUserProfile = { - // TODO(limsaehyun): 마이페이지로 이동 + onUserProfileClick = { + navigateToProfile(maker?.owner?.userId ?: 0) }, - onClickTestCover = { + onTestClick = { navigateToHomeDetail(maker?.examId ?: 0) }, cover = maker?.coverUrl ?: "", @@ -176,9 +181,9 @@ private fun TestCoverWithMaker( name: String, tier: String, favoriteTag: String, - onClickTestCover: () -> Unit, - onClickUserProfile: () -> Unit, + onTestClick: () -> Unit, isLoading: Boolean, + onUserProfileClick: () -> Unit, ) { Column( modifier = modifier, @@ -188,9 +193,7 @@ private fun TestCoverWithMaker( .aspectRatio(ThumbnailRatio) .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) - .quackClickable { - onClickTestCover() - } + .quackClickable(onClick = onTestClick) .skeleton(isLoading), model = cover, contentDescription = null, @@ -203,8 +206,9 @@ private fun TestCoverWithMaker( name = name, tier = tier, favoriteTag = favoriteTag, - onClickUserProfile = onClickUserProfile, isLoading = isLoading, + onUserProfileClick = onUserProfileClick, + onLayoutClick = onTestClick, ) } } @@ -218,17 +222,22 @@ private fun TestMakerLayout( tier: String, favoriteTag: String, isLoading: Boolean, - onClickUserProfile: (() -> Unit)? = null, + onLayoutClick: () -> Unit, + onUserProfileClick: () -> Unit, ) { Row( - modifier = modifier, + modifier = modifier.quackClickable( + rippleEnabled = false, + ) { + onLayoutClick() + }, verticalAlignment = Alignment.CenterVertically, ) { QuackProfileImage( modifier = Modifier.skeleton(isLoading), profileUrl = profileImageUrl, size = HomeProfileSize, - onClick = onClickUserProfile, + onClick = onUserProfileClick, ) Column(modifier = Modifier.padding(start = 8.dp)) { QuackSubtitle2( @@ -241,10 +250,10 @@ private fun TestMakerLayout( text = name, ) Spacer(modifier = Modifier.width(8.dp)) - QuackBody3( + QuackText( modifier = Modifier.skeleton(isLoading), text = "$tier · $favoriteTag", - color = QuackColor.Gray2, + typography = QuackTypography.Body3.change(color = QuackColor.Gray2), ) } } @@ -271,11 +280,12 @@ private fun HomeFollowingExamNotFoundScreen( onClickedCreate = { navigateToCreateProblem() }, + onClickedNotice = {}, ) Spacer(space = 60.dp) - QuackSubtitle( + QuackText( text = stringResource(id = R.string.home_following_exam_not_found_title), - align = TextAlign.Center, + typography = QuackTypography.Subtitle.change(textAlign = TextAlign.Center), ) Spacer(space = 12.dp) QuackBody2(text = stringResource(id = R.string.home_following_exam_not_found_subtitle)) diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendFollowingScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendFollowingScreen.kt index e4eda5707..53e64dedc 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendFollowingScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendFollowingScreen.kt @@ -24,18 +24,19 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import org.orbitmvi.orbit.compose.collectAsState +import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.common.compose.ui.QuackMaxWidthDivider +import team.duckie.app.android.common.kotlin.fastForEach import team.duckie.app.android.feature.home.R import team.duckie.app.android.feature.home.component.HomeTopAppBar import team.duckie.app.android.feature.home.constants.HomeStep +import team.duckie.app.android.feature.home.constants.MainScreenType import team.duckie.app.android.feature.home.viewmodel.home.HomeState import team.duckie.app.android.feature.home.viewmodel.home.HomeViewModel -import team.duckie.app.android.common.compose.ui.QuackMaxWidthDivider -import team.duckie.app.android.common.compose.ui.UserFollowingLayout -import team.duckie.app.android.common.compose.activityViewModel -import team.duckie.app.android.common.kotlin.fastForEach -import team.duckie.app.android.feature.home.constants.MainScreenType -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.component.QuackTitle2 +import team.duckie.app.android.common.compose.ui.content.UserFollowingLayout +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackTitle2 @Composable internal fun HomeRecommendFollowingScreen( @@ -60,6 +61,7 @@ internal fun HomeRecommendFollowingScreen( vm.changedHomeScreen(HomeStep.toStep(step)) }, onClickedCreate = navigateToCreateProblem, + onClickedNotice = {}, ) } @@ -70,9 +72,9 @@ internal fun HomeRecommendFollowingScreen( .height(213.dp), contentAlignment = Alignment.Center, ) { - QuackSubtitle( + QuackText( text = stringResource(id = R.string.home_following_initial_title), - align = TextAlign.Center, + typography = QuackTypography.Subtitle.change(textAlign = TextAlign.Center), ) } } @@ -115,13 +117,13 @@ private fun HomeFollowingInitialRecommendUsers( recommendUser.fastForEach { user -> UserFollowingLayout( userId = user.userId, - isMine = myUserId == user.userId, + visibleTrailingButton = myUserId != user.userId, profileImgUrl = user.profileImgUrl, nickname = user.nickname, favoriteTag = user.favoriteTag, tier = user.tier, isFollowing = user.isFollowing, - onClickFollow = { + onClickTrailingButton = { onClickFollowing(user.userId, it) }, ) diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendScreen.kt index cf7469fcd..1da5fde76 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeRecommendScreen.kt @@ -8,6 +8,7 @@ @file:OptIn( ExperimentalMaterialApi::class, ExperimentalFoundationApi::class, + ExperimentalQuackQuackApi::class, ) package team.duckie.app.android.feature.home.screen.home @@ -27,6 +28,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi @@ -34,6 +36,10 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -47,12 +53,16 @@ import coil.compose.AsyncImage import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.ui.DuckExamSmallCover import team.duckie.app.android.common.compose.ui.DuckTestCoverItem import team.duckie.app.android.common.compose.ui.DuckieHorizontalPagerIndicator import team.duckie.app.android.common.compose.ui.quack.todo.QuackAnnotatedText import team.duckie.app.android.common.compose.ui.skeleton +import team.duckie.app.android.common.compose.ui.temp.TempFlexiblePrimaryLargeButton import team.duckie.app.android.common.kotlin.addHashTag import team.duckie.app.android.domain.exam.model.Exam import team.duckie.app.android.domain.recommendation.model.ExamType @@ -61,13 +71,14 @@ import team.duckie.app.android.feature.home.component.HomeTopAppBar import team.duckie.app.android.feature.home.constants.HomeStep import team.duckie.app.android.feature.home.viewmodel.home.HomeState import team.duckie.app.android.feature.home.viewmodel.home.HomeViewModel -import team.duckie.quackquack.ui.component.QuackBody1 -import team.duckie.quackquack.ui.component.QuackBody3 -import team.duckie.quackquack.ui.component.QuackLarge1 -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackBody3 +import team.duckie.quackquack.ui.sugar.QuackLarge1 +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi private val HomeHorizontalPadding = PaddingValues(horizontal = 16.dp) +private const val JumbotronSwipeInterval = 3000L @Composable internal fun HomeRecommendScreen( @@ -79,7 +90,33 @@ internal fun HomeRecommendScreen( navigateToSearch: (String) -> Unit, openExamBottomSheet: (Int) -> Unit, ) { - val pageState = rememberPagerState() + val pageState = rememberPagerState( + pageCount = { state.jumbotrons.size }, + initialPage = state.jumbotronPage, + ) + + val isLocateMiddle by remember { + derivedStateOf { + pageState.currentPageOffsetFraction != 0.0f + } + } + + LaunchedEffect(key1 = Unit) { + if (isLocateMiddle) { + pageState.scrollToPage(state.jumbotronPage) + } + } + + LaunchedEffect(key1 = pageState.currentPage) { + delay(JumbotronSwipeInterval) + withContext(NonCancellable) { + if (pageState.isLastPage) { + pageState.animateScrollToPage(0.also(homeViewModel::saveJumbotronPage)) + } else { + pageState.animateScrollToPage((pageState.currentPage + 1).also(homeViewModel::saveJumbotronPage)) + } + } + } val lazyRecommendations = homeViewModel.recommendations.collectAsLazyPagingItems() @@ -91,8 +128,7 @@ internal fun HomeRecommendScreen( ) Box( - modifier = Modifier - .pullRefresh(pullRefreshState), + modifier = Modifier.pullRefresh(pullRefreshState), ) { LazyColumn( modifier = modifier @@ -110,18 +146,16 @@ internal fun HomeRecommendScreen( onClickedCreate = { navigateToCreateProblem() }, + onClickedNotice = {}, ) } item { - HorizontalPager( - pageCount = state.jumbotrons.size, - state = pageState, - ) { page -> + HorizontalPager(state = pageState) { page -> HomeRecommendJumbotronLayout( modifier = Modifier .padding(HomeHorizontalPadding), - recommendItem = state.jumbotrons[page], + item = state.jumbotrons.getOrNull(page % state.jumbotrons.size), onStartClicked = { examId -> navigateToHomeDetail(examId) }, @@ -167,13 +201,16 @@ internal fun HomeRecommendScreen( } } +private val PagerState.isLastPage: Boolean + get() = currentPage == pageCount - 1 + @Composable private fun HomeRecommendJumbotronLayout( modifier: Modifier = Modifier, - recommendItem: HomeState.HomeRecommendJumbotron, + item: HomeState.HomeRecommendJumbotron?, onStartClicked: (Int) -> Unit, isLoading: Boolean, -) { +) = item?.let { recommendItem -> Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, @@ -194,18 +231,19 @@ private fun HomeRecommendJumbotronLayout( text = recommendItem.title, ) Spacer(modifier = Modifier.height(12.dp)) - QuackBody1( + QuackText( modifier = Modifier.skeleton(isLoading), text = recommendItem.content, - align = TextAlign.Center, + typography = QuackTypography.Body1.change(textAlign = TextAlign.Center), ) Spacer(modifier = Modifier.height(24.dp)) - QuackLargeButton( - modifier = Modifier.skeleton(isLoading), - type = QuackLargeButtonType.Fill, + + TempFlexiblePrimaryLargeButton( + modifier = Modifier + .fillMaxWidth() + .skeleton(isLoading), text = recommendItem.buttonContent, onClick = { onStartClicked(recommendItem.examId) }, - enabled = true, ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeScreen.kt index 2c78cb521..95b13e3e6 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/home/HomeScreen.kt @@ -25,15 +25,15 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import okhttp3.internal.immutableListOf import org.orbitmvi.orbit.compose.collectAsState -import team.duckie.app.android.feature.home.constants.HomeStep -import team.duckie.app.android.feature.home.viewmodel.home.HomeSideEffect -import team.duckie.app.android.feature.home.viewmodel.home.HomeViewModel +import team.duckie.app.android.common.android.exception.handling.reporter.reportToCrashlyticsIfNeeded import team.duckie.app.android.common.compose.ui.ErrorScreen import team.duckie.app.android.common.compose.ui.dialog.DuckieSelectableBottomSheetDialog -import team.duckie.app.android.common.compose.ui.quack.QuackCrossfade -import team.duckie.app.android.common.android.exception.handling.reporter.reportToCrashlyticsIfNeeded import team.duckie.app.android.common.compose.ui.dialog.DuckieSelectableType +import team.duckie.app.android.common.compose.ui.quack.QuackCrossfade +import team.duckie.app.android.feature.home.constants.HomeStep import team.duckie.app.android.feature.home.constants.MainScreenType +import team.duckie.app.android.feature.home.viewmodel.home.HomeSideEffect +import team.duckie.app.android.feature.home.viewmodel.home.HomeViewModel private val HomeHorizontalPadding = PaddingValues(horizontal = 16.dp) @@ -47,6 +47,7 @@ internal fun HomeScreen( navigateToCreateProblem: () -> Unit, navigateToHomeDetail: (Int) -> Unit, navigateToSearch: (String) -> Unit, + navigateToProfile: (Int) -> Unit, ) { val state = vm.collectAsState().value val bottomSheetDialogState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) @@ -106,6 +107,20 @@ internal fun HomeScreen( }, ) + page == HomeStep.HomeProceedScreen -> HomeProceedScreen( + state = state, + homeViewModel = vm, + navigateToCreateProblem = navigateToCreateProblem, + navigateToHomeDetail = navigateToHomeDetail, + navigateToSearch = navigateToSearch, + openExamBottomSheet = { exam -> + setTargetExamId(exam) + coroutineScope.launch { + bottomSheetDialogState.show() + } + }, + ) + page == HomeStep.HomeFollowingScreen -> if (state.isFollowingExist) { HomeRecommendFollowingExamScreen( initState = initState, @@ -113,6 +128,7 @@ internal fun HomeScreen( state = state, navigateToHomeDetail = navigateToHomeDetail, navigateToCreateProblem = navigateToCreateProblem, + navigateToProfile = navigateToProfile, ) } else { HomeRecommendFollowingScreen( diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/mypage/MypageScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/mypage/MypageScreen.kt index 0f05aa9db..9d8e1ec4c 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/mypage/MypageScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/mypage/MypageScreen.kt @@ -44,7 +44,7 @@ import team.duckie.app.android.feature.home.viewmodel.mypage.MyPageViewModel import team.duckie.app.android.feature.profile.screen.MyProfileScreen import team.duckie.app.android.feature.profile.viewmodel.state.ExamType import team.duckie.app.android.feature.profile.viewmodel.state.ProfileStep -import team.duckie.quackquack.ui.color.QuackColor +import team.duckie.quackquack.material.QuackColor @Composable internal fun MyPageScreen( @@ -147,7 +147,7 @@ internal fun MyPageScreen( MyProfileScreen( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor), + .background(color = QuackColor.White.value), userProfile = state.userProfile, isLoading = state.isLoading, onClickSetting = viewModel::clickSetting, diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/ExamSection.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/ExamSection.kt index 00ddd7eb4..ce6415206 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/ExamSection.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/ExamSection.kt @@ -41,15 +41,15 @@ import team.duckie.app.android.common.compose.ui.DuckExamSmallCoverForColumn import team.duckie.app.android.common.compose.ui.DuckTestCoverItem import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.common.compose.ui.TextTabLayout +import team.duckie.app.android.common.compose.ui.quack.todo.QuackOutLinedSingeLazyRowTag import team.duckie.app.android.common.compose.ui.skeleton import team.duckie.app.android.common.kotlin.fastMap import team.duckie.app.android.feature.home.R import team.duckie.app.android.feature.home.viewmodel.ranking.RankingViewModel +import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackSingeLazyRowTag -import team.duckie.quackquack.ui.component.QuackTagType -import team.duckie.quackquack.ui.component.QuackTitle2 +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackTitle2 import team.duckie.quackquack.material.QuackColor as QuackV2Color @Composable @@ -78,13 +78,13 @@ internal fun ExamSection( .padding(top = 10.dp), ) { // TODO:(EvergreenTree97) 태그의 inner padding이 바뀌어야 함 - QuackSingeLazyRowTag( + QuackOutLinedSingeLazyRowTag( modifier = Modifier .fillMaxWidth() .skeleton(state.isTagLoading), items = tagNames, itemSelections = state.tagSelections, - tagType = QuackTagType.Circle(), + trailingIcon = null, onClick = viewModel::changeSelectedTags, ) Spacer(space = 28.dp) @@ -196,13 +196,13 @@ private fun RankingEdge(rank: Int) { ), contentAlignment = Alignment.Center, ) { - QuackTitle2( + QuackText( modifier = Modifier.padding( horizontal = 9.dp, vertical = 2.dp, ), - color = QuackColor.White, text = rank.toString(), + typography = QuackTypography.Title2.change(color = QuackColor.White), ) } } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/ExamineeSection.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/ExamineeSection.kt index 1628f6013..1975f0442 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/ExamineeSection.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/ExamineeSection.kt @@ -16,28 +16,28 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.material.Divider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemsIndexed import team.duckie.app.android.common.compose.itemsIndexedPagingKey +import team.duckie.app.android.common.compose.ui.DuckieDivider import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.common.compose.ui.quack.QuackProfileImage import team.duckie.app.android.common.compose.ui.skeleton import team.duckie.app.android.domain.user.model.User import team.duckie.app.android.feature.home.viewmodel.ranking.RankingViewModel -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody2 -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.component.QuackTitle2 -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.sugar.QuackBody2 +import team.duckie.quackquack.ui.sugar.QuackSubtitle +import team.duckie.quackquack.ui.sugar.QuackTitle2 @Composable internal fun ExamineeSection( @@ -104,7 +104,10 @@ private fun ExamineeContent( QuackProfileImage( modifier = Modifier.skeleton(isLoading), profileUrl = profileImageUrl, - size = DpSize(all = 44.dp), + size = DpSize( + width = 44.dp, + height = 44.dp, + ), ) Spacer(space = 8.dp) QuackTitle2( @@ -127,6 +130,6 @@ private fun ExamineeContent( } } } - Divider(color = QuackColor.Gray4.composeColor) + DuckieDivider(color = QuackColor.Gray4) } } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/RankingScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/RankingScreen.kt index 7e8fd4622..44d582b19 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/RankingScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/ranking/RankingScreen.kt @@ -44,16 +44,17 @@ import com.google.firebase.ktx.Firebase import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import team.duckie.app.android.common.compose.ui.ErrorScreen +import team.duckie.app.android.common.compose.ui.dialog.DuckieSelectableBottomSheetDialog +import team.duckie.app.android.common.kotlin.AllowMagicNumber +import team.duckie.app.android.common.kotlin.fastForEach import team.duckie.app.android.feature.home.R import team.duckie.app.android.feature.home.component.HeadLineTopAppBar +import team.duckie.app.android.feature.home.constants.MainScreenType import team.duckie.app.android.feature.home.constants.RankingPage import team.duckie.app.android.feature.home.viewmodel.ranking.RankingSideEffect import team.duckie.app.android.feature.home.viewmodel.ranking.RankingViewModel -import team.duckie.app.android.common.compose.ui.ErrorScreen -import team.duckie.app.android.common.compose.ui.dialog.DuckieSelectableBottomSheetDialog -import team.duckie.app.android.common.kotlin.AllowMagicNumber -import team.duckie.app.android.feature.home.constants.MainScreenType -import team.duckie.quackquack.ui.component.QuackMainTab +import team.duckie.quackquack.ui.QuackTab @Composable internal fun RankingScreen( @@ -73,7 +74,10 @@ internal fun RankingScreen( context.getString(R.string.exam), ) } - val pagerState = rememberPagerState(initialPage = state.selectedTab) + val pagerState = rememberPagerState( + initialPage = state.selectedTab, + pageCount = { tabs.size }, + ) val coroutineScope = rememberCoroutineScope() val lazyListState = rememberLazyListState() val lazyGridState = rememberLazyGridState() @@ -171,20 +175,19 @@ internal fun RankingScreen( onRetryClick = viewModel::refresh, ) } else { - QuackMainTab( - titles = tabs, - selectedTabIndex = state.selectedTab, - onTabSelected = { - viewModel.setSelectedTab(it) - coroutineScope.launch { - pagerState.animateScrollToPage(it) + QuackTab(index = state.selectedTab) { + tabs.fastForEach { label -> + tab(label) { index -> + viewModel.setSelectedTab(index) + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } } - }, - ) + } + } HorizontalPager( modifier = Modifier.fillMaxSize(), state = pagerState, - pageCount = tabs.size, key = { tabs[it] }, ) { page -> when (page) { diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/search/SearchMainScreen.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/search/SearchMainScreen.kt index 29d91f7dd..6efa25ac0 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/search/SearchMainScreen.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/screen/search/SearchMainScreen.kt @@ -5,19 +5,27 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -@file:OptIn(ExperimentalMaterialApi::class) +@file:OptIn( + ExperimentalMaterialApi::class, + ExperimentalLayoutApi::class, + ExperimentalQuackQuackApi::class, +) package team.duckie.app.android.feature.home.screen.search import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi @@ -34,26 +42,27 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState -import team.duckie.app.android.feature.home.R -import team.duckie.app.android.feature.home.viewmodel.MainViewModel import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.common.compose.ui.icon.v1.SearchId import team.duckie.app.android.common.kotlin.AllowMagicNumber +import team.duckie.app.android.feature.home.R import team.duckie.app.android.feature.home.constants.MainScreenType +import team.duckie.app.android.feature.home.viewmodel.MainViewModel import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackTag +import team.duckie.quackquack.ui.QuackTagStyle import team.duckie.quackquack.ui.QuackText - import team.duckie.quackquack.ui.sugar.QuackTitle2 -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackLazyVerticalGridTag -import team.duckie.quackquack.ui.component.QuackTagType -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi private val SearchScreenHorizontalPaddingDp = 16.dp @@ -102,12 +111,16 @@ internal fun SearchMainScreen( verticalAlignment = Alignment.CenterVertically, ) { QuackImage( - src = QuackIcon.Search, - size = DpSize(24.dp), + modifier = Modifier.size(DpSize(width = 24.dp, height = 24.dp)), + src = QuackIcon.SearchId, ) Spacer(modifier = Modifier.width(8.dp)) Box( modifier = Modifier + .quackClickable( + rippleEnabled = false, + onClick = vm::navigateToSearch, + ) .fillMaxWidth() .height(36.dp) .background( @@ -116,11 +129,6 @@ internal fun SearchMainScreen( size = 8.dp, ), ) - .quackClickable( - rippleEnabled = false, - ) { - vm.navigateToSearch() - } .padding(start = 12.dp), contentAlignment = Alignment.CenterStart, ) { @@ -132,22 +140,30 @@ internal fun SearchMainScreen( ) } } - Spacer(modifier = Modifier.height(22.dp)) + Spacer(modifier = Modifier.height(16.dp)) QuackTitle2( modifier = Modifier.padding(start = SearchScreenHorizontalPaddingDp), text = stringResource(id = R.string.popular_tag), ) - Spacer(modifier = Modifier.height(8.dp)) - // TODO(limsaehyun): 추후 꽥꽥에서, 전체 너비만큼 태그 Composable 을 넣을 수 있는 Composable 적용 필요 - QuackLazyVerticalGridTag( + Spacer(modifier = Modifier.height(12.dp)) + FlowRow( modifier = Modifier.padding(horizontal = SearchScreenHorizontalPaddingDp), - items = state.popularTags.map { it.name }, - tagType = QuackTagType.Round, - onClick = { index -> - vm.navigateToSearch(searchTag = state.popularTags[index].name) - }, - itemChunkedSize = 5, - ) + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + state.popularTags.forEach { tag -> + QuackTag( + text = tag.name, + style = QuackTagStyle.Filled, + selected = false, + ) { + vm.navigateToSearch( + searchTag = tag.name, + autoFocusing = false, + ) + } + } + } } PullRefreshIndicator( diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/MainSideEffect.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/MainSideEffect.kt index d73866cca..17462f146 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/MainSideEffect.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/MainSideEffect.kt @@ -22,7 +22,7 @@ internal sealed class MainSideEffect { /** * [SearchResultActivity] 로 이동하는 SideEffect 입니다. */ - class NavigateToSearch(val searchTag: String?) : MainSideEffect() + class NavigateToSearch(val searchTag: String?, val autoFocusing: Boolean) : MainSideEffect() /** * [HomeDetailActivity] 로 이동하는 SideEffect 입니다. @@ -35,12 +35,14 @@ internal sealed class MainSideEffect { object NavigateToSetting : MainSideEffect() /** - * [CreateProblemActivity] 로 이동하는 SideEffect 입니다. + * [CreateExamActivity] 로 이동하는 SideEffect 입니다. */ object NavigateToCreateProblem : MainSideEffect() object NavigateToNotification : MainSideEffect() + data class NavigateToProfile(val userId: Int) : MainSideEffect() + object ClickRankingRetry : MainSideEffect() class NavigateToFriends(val friendType: FriendsType, val myUserId: Int, val nickname: String) : diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/MainViewModel.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/MainViewModel.kt index 670f1540a..d00d66599 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/MainViewModel.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/MainViewModel.kt @@ -16,12 +16,12 @@ import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container import team.duckie.app.android.common.android.ui.const.Extras -import team.duckie.app.android.domain.report.usecase.ReportUseCase -import team.duckie.app.android.domain.tag.usecase.FetchPopularTagsUseCase -import team.duckie.app.android.feature.home.constants.BottomNavigationStep import team.duckie.app.android.common.compose.ui.dialog.ReportAlreadyExists import team.duckie.app.android.common.kotlin.FriendsType import team.duckie.app.android.common.kotlin.exception.isReportAlreadyExists +import team.duckie.app.android.domain.report.usecase.ReportUseCase +import team.duckie.app.android.domain.tag.usecase.FetchPopularTagsUseCase +import team.duckie.app.android.feature.home.constants.BottomNavigationStep import team.duckie.app.android.feature.home.constants.MainScreenType import javax.inject.Inject @@ -136,8 +136,9 @@ internal class MainViewModel @Inject constructor( /** 검색 화면으로 이동한다 */ fun navigateToSearch( searchTag: String? = null, + autoFocusing: Boolean = true, ) = intent { - postSideEffect(MainSideEffect.NavigateToSearch(searchTag)) + postSideEffect(MainSideEffect.NavigateToSearch(searchTag, autoFocusing)) } /** 홈 디테일 화면으로 이동한다 */ @@ -171,6 +172,10 @@ internal class MainViewModel @Inject constructor( postSideEffect(MainSideEffect.NavigateToNotification) } + fun navigateToProfile(userId: Int) = intent { + postSideEffect(MainSideEffect.NavigateToProfile(userId)) + } + /** 온보딩(가이드) 활성화 여부를 업데이트한다 */ fun updateGuideVisible(visible: Boolean) = intent { reduce { state.copy(guideVisible = visible) } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/home/HomeState.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/home/HomeState.kt index 38f294840..b74a5f056 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/home/HomeState.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/home/HomeState.kt @@ -21,14 +21,17 @@ internal data class HomeState( val isHomeRecommendLoading: Boolean = false, val isHomeRecommendFollowingExamLoading: Boolean = false, + val isHomeProceedLoading: Boolean = false, val isHomeRecommendFollowingExamRefreshLoading: Boolean = false, val isHomeRecommendPullRefreshLoading: Boolean = false, + val isHomeProceedPullRefreshLoading: Boolean = false, val homeSelectedIndex: HomeStep = HomeStep.HomeRecommendScreen, val jumbotrons: ImmutableList = skeletonJumbotrons, val recommendTopics: ImmutableList = persistentListOf(), + val jumbotronPage: Int = 0, val isFollowingExist: Boolean = true, val recommendFollowing: ImmutableList = persistentListOf(), @@ -60,13 +63,14 @@ internal data class HomeState( val profileImgUrl: String, val favoriteTag: String, val tier: String, + val userId: Int, ) { /** * [User] 의 Empty Model 입니다. * 초기화 혹은 Skeleton UI 등에 필요한 Mock Data 로 쓰입니다. */ companion object { - fun empty() = User("", "", "", "") + fun empty() = User("", "", "", "", 0) } } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/home/HomeViewModel.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/home/HomeViewModel.kt index abe5d29d8..a098a0f55 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/home/HomeViewModel.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/home/HomeViewModel.kt @@ -26,6 +26,9 @@ import org.orbitmvi.orbit.syntax.simple.intent import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container +import team.duckie.app.android.common.kotlin.exception.isFollowingAlreadyExists +import team.duckie.app.android.common.kotlin.exception.isFollowingNotFound +import team.duckie.app.android.common.kotlin.fastMap import team.duckie.app.android.domain.exam.model.Exam import team.duckie.app.android.domain.follow.model.FollowBody import team.duckie.app.android.domain.follow.usecase.FollowUseCase @@ -40,9 +43,6 @@ import team.duckie.app.android.feature.home.constants.HomeStep import team.duckie.app.android.feature.home.viewmodel.mapper.toFollowingModel import team.duckie.app.android.feature.home.viewmodel.mapper.toJumbotronModel import team.duckie.app.android.feature.home.viewmodel.mapper.toUiModel -import team.duckie.app.android.common.kotlin.exception.isFollowingAlreadyExists -import team.duckie.app.android.common.kotlin.exception.isFollowingNotFound -import team.duckie.app.android.common.kotlin.fastMap import javax.inject.Inject @HiltViewModel @@ -110,6 +110,12 @@ internal class HomeViewModel @Inject constructor( } } + fun saveJumbotronPage(page: Int) = intent { + reduce { + state.copy(jumbotronPage = page) + } + } + /** * 팔로잉 추천 탭을 새로고침한다. * [forceLoading] - PullRefresh 를 할 경우 사용자에게 새로고침이 됐음을 알리기 위한 최소한의 로딩 시간을 부여한다. @@ -125,6 +131,21 @@ internal class HomeViewModel @Inject constructor( } } + /** + * 진행중 탭을 새로고침한다. + * + * [forceLoading] - PullRefresh 를 할 경우 사용자에게 새로고침이 됐음을 알리기 위한 최소한의 로딩 시간을 부여한다. + */ + fun refreshProceeds(forceLoading: Boolean = false) { + viewModelScope.launch { + updateHomeProceedRefreshLoading(true) + // TODO(riflockle7): 진행중 시험 API 로직 필요 + // fetchRecommendFollowingExam() + if (forceLoading) delay(pullToRefreshMinLoadingDelay) + updateHomeProceedRefreshLoading(false) + } + } + /** 홈 화면의 jumbotron을 가져온다. */ internal fun fetchJumbotrons() = intent { startHomeRecommendLoading() @@ -135,7 +156,7 @@ internal class HomeViewModel @Inject constructor( isHomeRecommendLoading = false, jumbotrons = jumbotrons .fastMap(Exam::toJumbotronModel) - .toPersistentList(), + .toImmutableList(), ) } }.onFailure { exception -> @@ -288,4 +309,17 @@ internal class HomeViewModel @Inject constructor( ) } } + + /** 홈 화면의 진행중 탭의 pull refresh 로딩 상태를 [loading]으로 바꾼다. */ + private fun updateHomeProceedRefreshLoading( + loading: Boolean, + ) = intent { + reduce { + state.copy( + isHomeProceedPullRefreshLoading = loading, + isHomeProceedLoading = loading, // for skeleton UI + isError = false, + ) + } + } } diff --git a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/mapper/mapper.kt b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/mapper/mapper.kt index c7e7aa680..bf59c773d 100644 --- a/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/mapper/mapper.kt +++ b/feature/home/src/main/kotlin/team/duckie/app/android/feature/home/viewmodel/mapper/mapper.kt @@ -50,5 +50,6 @@ internal fun Exam.toFollowingModel() = profileImgUrl = user?.profileImageUrl ?: "", favoriteTag = user?.duckPower?.tag?.name ?: "", tier = user?.duckPower?.tier ?: "", + userId = user?.id ?: 0, ), ) diff --git a/feature/home/src/main/res/drawable/home_ic_notice.png b/feature/home/src/main/res/drawable/home_ic_notice.png new file mode 100644 index 0000000000000000000000000000000000000000..9b34af7d12f9f29392dfe9c6f07a97ab702feee6 GIT binary patch literal 1267 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 literal 0 HcmV?d00001 diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml index ecb90714c..f29d0b502 100644 --- a/feature/home/src/main/res/values/strings.xml +++ b/feature/home/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ 팔로우 응시자 추천 + 진행중 "관심있는 키워드를 검색해보세요!" 인기 태그 @@ -66,4 +67,15 @@ 팔로우한 유저 중에\n아직 올라온 덕력고사가 없어요. 다른 유저들을 더 찾아보세요! + + + 출제 임박 덕력고사 + 오픈까지 %s문제! + %s | %s + %s명 참여 + %s/%s + 전체보기 + 직접 덕력고사를 열고 싶다면? + 출제 제안하기 + %s,\n이런 덕력고사도 있덕 diff --git a/feature/notification/build.gradle.kts b/feature/notification/build.gradle.kts index 947c7415e..51df2b689 100644 --- a/feature/notification/build.gradle.kts +++ b/feature/notification/build.gradle.kts @@ -28,9 +28,10 @@ dependencies { projects.common.compose, libs.orbit.viewmodel, libs.orbit.compose, - libs.quack.ui.components, libs.compose.lifecycle.runtime, libs.compose.ui.material, // needs for CircularProgressIndicator libs.firebase.crashlytics, + libs.quack.v2.ui, + libs.kotlin.collections.immutable, ) } diff --git a/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/NotificationActivity.kt b/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/NotificationActivity.kt index c39f56244..e0d03fb23 100644 --- a/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/NotificationActivity.kt +++ b/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/NotificationActivity.kt @@ -22,13 +22,13 @@ import com.google.firebase.crashlytics.ktx.crashlytics import com.google.firebase.ktx.Firebase import dagger.hilt.android.AndroidEntryPoint import org.orbitmvi.orbit.viewmodel.observe +import team.duckie.app.android.common.android.ui.BaseActivity +import team.duckie.app.android.common.android.ui.finishWithAnimation import team.duckie.app.android.feature.notification.screen.NotificationScreen import team.duckie.app.android.feature.notification.viewmodel.NotificationSideEffect import team.duckie.app.android.feature.notification.viewmodel.NotificationViewModel import team.duckie.app.android.navigator.feature.home.HomeNavigator -import team.duckie.app.android.common.android.ui.BaseActivity -import team.duckie.app.android.common.android.ui.finishWithAnimation -import team.duckie.quackquack.ui.color.QuackColor +import team.duckie.quackquack.material.QuackColor import javax.inject.Inject @AndroidEntryPoint @@ -48,11 +48,10 @@ class NotificationActivity : BaseActivity() { NotificationScreen( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor) + .background(color = QuackColor.White.value) .systemBarsPadding() .navigationBarsPadding() - .padding(top = 12.dp) - .padding(horizontal = 12.dp), + .padding(horizontal = 16.dp), ) } viewModel.observe( diff --git a/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/screen/NoticationScreen.kt b/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/screen/NoticationScreen.kt index 5be73745a..db0f5f865 100644 --- a/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/screen/NoticationScreen.kt +++ b/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/screen/NoticationScreen.kt @@ -8,55 +8,46 @@ package team.duckie.app.android.feature.notification.screen import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import team.duckie.app.android.feature.notification.R -import team.duckie.app.android.feature.notification.viewmodel.NotificationViewModel -import team.duckie.app.android.common.compose.ui.ErrorScreen import team.duckie.app.android.common.compose.ui.NoItemScreen -import team.duckie.app.android.common.compose.ui.quack.QuackCrossfade +import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.common.compose.ui.skeleton -import team.duckie.app.android.common.compose.activityViewModel -import team.duckie.app.android.common.kotlin.getDiffDayFromToday -import team.duckie.quackquack.ui.component.QuackBody2 -import team.duckie.quackquack.ui.component.QuackBody3 -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackTopAppBar -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.shape.SquircleShape -import team.duckie.quackquack.ui.util.DpSize -import java.util.Date +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.material.shape.SquircleShape +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.sugar.QuackBody2 +import team.duckie.quackquack.ui.sugar.QuackBody3 @Composable internal fun NotificationScreen( modifier: Modifier = Modifier, - viewModel: NotificationViewModel = activityViewModel(), + // viewModel: NotificationViewModel = activityViewModel(), ) { - val state by viewModel.container.stateFlow.collectAsStateWithLifecycle() - - LaunchedEffect(Unit) { - viewModel.getNotifications() - } + // val state by viewModel.container.stateFlow.collectAsStateWithLifecycle() Column(modifier = modifier) { - QuackTopAppBar( - leadingIcon = QuackIcon.ArrowBack, + // TODO(EvergreenTree97) : 알림 화면 구현 후 제거 + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(space = 120.dp) + NoItemScreen( + title = "아직 알림기능이 준비중입니다", + description = "조금만 기다려주세요:)", + ) + } + /*QuackTopAppBar( + leadingIcon = QuackIcon.Outlined.ArrowBack, leadingText = stringResource(id = R.string.notification), onLeadingIconClick = viewModel::clickBackPress, ) @@ -74,10 +65,12 @@ internal fun NotificationScreen( } isEmpty -> { - Box( + Column( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, ) { + Spacer(space = 120.dp) NoItemScreen( title = stringResource(id = R.string.empty_notfications), description = stringResource(id = R.string.check_notifications_after_activity), @@ -110,15 +103,16 @@ internal fun NotificationScreen( } } } - } + }*/ } } +@Suppress("UnusedPrivateMember") @Composable private fun NotificationItem( thumbnailUrl: String, body: String, - createdAt: Date, + createdAt: String, isLoading: Boolean, onClick: () -> Unit, ) { @@ -129,12 +123,13 @@ private fun NotificationItem( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { QuackImage( - modifier = Modifier.skeleton( - visible = isLoading, - shape = SquircleShape, - ), + modifier = Modifier + .size(36.dp) + .skeleton( + visible = isLoading, + shape = SquircleShape, + ), src = thumbnailUrl, - size = DpSize(all = 36.dp), ) Column( modifier = Modifier.fillMaxWidth(), @@ -146,7 +141,7 @@ private fun NotificationItem( ) QuackBody3( modifier = Modifier.skeleton(visible = isLoading), - text = createdAt.getDiffDayFromToday(), + text = createdAt, ) } } diff --git a/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/viewmodel/NotificationViewModel.kt b/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/viewmodel/NotificationViewModel.kt index 27e21187e..bb17fb18a 100644 --- a/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/viewmodel/NotificationViewModel.kt +++ b/feature/notification/src/main/kotlin/team/duckie/app/android/feature/notification/viewmodel/NotificationViewModel.kt @@ -9,6 +9,7 @@ package team.duckie.app.android.feature.notification.viewmodel import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.syntax.simple.intent @@ -29,27 +30,40 @@ internal class NotificationViewModel @Inject constructor( override val container = container(NotificationState()) - fun getNotifications() = intent { - startLoading() - getNotificationsUseCase().onSuccess { notifications -> + init { + // TODO(EvergreenTree97) : 알림 기능 구현 후 제거 + intent { reduce { state.copy( isLoading = false, - isError = false, - notifications = notifications.toImmutableList(), - ) - } - }.onFailure { - reduce { - state.copy( - isLoading = false, - isError = true, - notifications = emptyList().toImmutableList(), + notifications = persistentListOf(), ) } } } + fun getNotifications() = intent { + startLoading() + getNotificationsUseCase() + .onSuccess { notifications -> + reduce { + state.copy( + isLoading = false, + isError = false, + notifications = notifications.toImmutableList(), + ) + } + }.onFailure { + reduce { + state.copy( + isLoading = false, + isError = true, + notifications = emptyList().toImmutableList(), + ) + } + } + } + fun clickBackPress() = intent { postSideEffect(NotificationSideEffect.FinishActivity) } fun clickNotification(id: Int) = intent { diff --git a/feature/onboard/build.gradle.kts b/feature/onboard/build.gradle.kts index 0b04a5fc5..9b980e926 100644 --- a/feature/onboard/build.gradle.kts +++ b/feature/onboard/build.gradle.kts @@ -33,8 +33,10 @@ dependencies { libs.orbit.compose, libs.ktx.lifecycle.runtime, libs.compose.ui.material, // needs for ModalBottomSheet + libs.compose.ui.foundation, libs.compose.lifecycle.runtime, libs.compose.ui.accompanist.flowlayout, - libs.quack.ui.components, + libs.kotlin.collections.immutable, + libs.quack.v2.ui, ) } diff --git a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/OnboardActivity.kt b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/OnboardActivity.kt index 2569c5e4c..97553a60d 100644 --- a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/OnboardActivity.kt +++ b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/OnboardActivity.kt @@ -14,6 +14,7 @@ import androidx.activity.addCallback import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.getValue @@ -49,9 +50,8 @@ import team.duckie.app.android.feature.onboard.screen.TagScreen import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel import team.duckie.app.android.feature.onboard.viewmodel.sideeffect.OnboardSideEffect import team.duckie.app.android.feature.onboard.viewmodel.state.OnboardState -import team.duckie.quackquack.ui.animation.QuackAnimatedContent -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.theme.QuackTheme import javax.inject.Inject @AndroidEntryPoint @@ -129,11 +129,12 @@ class OnboardActivity : BaseActivity() { setContent { QuackTheme { - QuackAnimatedContent( + AnimatedContent( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor), + .background(color = QuackColor.White.value), targetState = onboardStepState, + label = "AnimatedContent", ) { onboardStep -> when (onboardStep) { OnboardStep.Activity, OnboardStep.Login -> LoginScreen() diff --git a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/common/OnboardTopAppBar.kt b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/common/OnboardTopAppBar.kt index 9bbadcd32..2f58439c4 100644 --- a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/common/OnboardTopAppBar.kt +++ b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/common/OnboardTopAppBar.kt @@ -10,27 +10,24 @@ package team.duckie.app.android.feature.onboard.common import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import team.duckie.app.android.feature.onboard.constant.OnboardStep -import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.systemBarPaddings -import team.duckie.quackquack.ui.component.QuackTopAppBar -import team.duckie.quackquack.ui.icon.QuackIcon +import team.duckie.app.android.common.compose.ui.quack.todo.QuackTopAppBar +import team.duckie.app.android.feature.onboard.constant.OnboardStep +import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.Outlined +import team.duckie.quackquack.material.icon.quackicon.outlined.ArrowBack @Composable internal fun OnboardTopAppBar( modifier: Modifier = Modifier, currentStep: OnboardStep, - horizontalPadding: Dp = 20.dp, vm: OnboardViewModel = activityViewModel(), ) { QuackTopAppBar( - modifier = modifier - .padding(top = systemBarPaddings.calculateTopPadding()) - .padding(horizontal = horizontalPadding - 8.dp), // 내부에서 8.dp 가 들어감 - leadingIcon = QuackIcon.ArrowBack, + modifier = modifier.padding(top = systemBarPaddings.calculateTopPadding()), + leadingIcon = QuackIcon.Outlined.ArrowBack, onLeadingIconClick = { vm.navigateStep(currentStep - 1) }, ) } diff --git a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/common/TitleAndDescription.kt b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/common/TitleAndDescription.kt index 9017b098b..47ee9a00b 100644 --- a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/common/TitleAndDescription.kt +++ b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/common/TitleAndDescription.kt @@ -15,8 +15,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import team.duckie.quackquack.ui.component.QuackBody1 -import team.duckie.quackquack.ui.component.QuackHeadLine1 +import team.duckie.quackquack.ui.sugar.QuackBody1 +import team.duckie.quackquack.ui.sugar.QuackHeadLine1 @Composable internal fun TitleAndDescription( diff --git a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/0_LoginScreen.kt b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/0_LoginScreen.kt index 2e82a3f23..4768fc819 100644 --- a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/0_LoginScreen.kt +++ b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/0_LoginScreen.kt @@ -20,9 +20,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -35,23 +33,20 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import kotlinx.coroutines.launch -import team.duckie.app.android.feature.onboard.R -import team.duckie.app.android.feature.onboard.constant.OnboardStep -import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.asLoose import team.duckie.app.android.common.compose.systemBarPaddings import team.duckie.app.android.common.kotlin.fastFirstOrNull import team.duckie.app.android.common.kotlin.npe -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody3 -import team.duckie.quackquack.ui.component.QuackHeadLine2 -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.textstyle.QuackTextStyle +import team.duckie.app.android.feature.onboard.R +import team.duckie.app.android.feature.onboard.constant.OnboardStep +import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackText @Suppress("UnusedPrivateMember", "unused") private val currentStep = OnboardStep.Login @@ -125,21 +120,25 @@ private fun LoginScreenWelcome() { ), ) { QuackImage( - src = R.drawable.img_duckie_talk, - size = DpSize( + modifier = Modifier.size( width = 180.dp, height = 248.dp, ), + src = R.drawable.img_duckie_talk, ) - QuackHeadLine2( + QuackText( + modifier = Modifier.align(Alignment.CenterHorizontally), text = stringResource(R.string.kakaologin_welcome_message), - align = TextAlign.Center, + typography = QuackTypography.HeadLine2.change( + textAlign = TextAlign.Center, + ), ) } } private const val LoginScreenLoginAreaKakaoSymbolLayoutId = "LoginScreenLoginAreaKakaoSymbol" -private const val LoginScreenLoginAreaKakaoLoginLabelLayoutId = "LoginScreenLoginAreaKakaoLoginLabel" +private const val LoginScreenLoginAreaKakaoLoginLabelLayoutId = + "LoginScreenLoginAreaKakaoLoginLabel" private val LoginScreenLoginAreaMeasurePolicy = MeasurePolicy { measurables, constraints -> val extraLooseConstraints = constraints.asLoose(width = true) @@ -180,7 +179,6 @@ private val LoginScreenLoginAreaMeasurePolicy = MeasurePolicy { measurables, con @Composable private fun LoginScreenLoginArea(vm: OnboardViewModel = activityViewModel()) { val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() Column( modifier = Modifier.layoutId(LoginScreenLoginAreaLayoutId), @@ -203,9 +201,7 @@ private fun LoginScreenLoginArea(vm: OnboardViewModel = activityViewModel()) { ), ) .clickable { - coroutineScope.launch { - vm.getKakaoAccessTokenAndJoin() - } + vm.getKakaoAccessTokenAndJoin() }, content = { Image( @@ -224,25 +220,25 @@ private fun LoginScreenLoginArea(vm: OnboardViewModel = activityViewModel()) { contentDescription = null, ) // QuackColor 생성자가 internal 이라 BasicText 사용 - BasicText( + QuackText( modifier = Modifier.layoutId(LoginScreenLoginAreaKakaoLoginLabelLayoutId), text = stringResource(R.string.kakaologin_button_label), - style = QuackTextStyle.HeadLine2.asComposeStyle().copy( - color = Color( - ContextCompat.getColor( - context, - R.color.kakao_login_button_label, - ), + typography = QuackTypography.HeadLine2.change( + color = QuackColor( + Color(ContextCompat.getColor(context, R.color.kakao_login_button_label)), ), ), ) }, measurePolicy = LoginScreenLoginAreaMeasurePolicy, ) - QuackBody3( + QuackText( + modifier = Modifier.align(Alignment.CenterHorizontally), text = stringResource(R.string.kakaologin_login_terms), - color = QuackColor.Gray2, - align = TextAlign.Center, + typography = QuackTypography.Body3.change( + color = QuackColor.Gray2, + textAlign = TextAlign.Center, + ), ) } } diff --git a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/1_ProfileScreen.kt b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/1_ProfileScreen.kt index ee77f9d61..cc3098cd8 100644 --- a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/1_ProfileScreen.kt +++ b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/1_ProfileScreen.kt @@ -5,7 +5,12 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -@file:OptIn(FlowPreview::class, ExperimentalComposeUiApi::class) +@file:OptIn( + FlowPreview::class, + ExperimentalComposeUiApi::class, + ExperimentalQuackQuackApi::class, + ExperimentalDesignToken::class, +) @file:Suppress("ConstPropertyName", "PrivatePropertyName") package team.duckie.app.android.feature.onboard.screen @@ -14,9 +19,12 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.launch +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardActions @@ -35,13 +43,11 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.MeasurePolicy -import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.core.net.toUri @@ -53,102 +59,34 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState +import team.duckie.app.android.common.android.ui.const.Debounce +import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.common.compose.rememberToast +import team.duckie.app.android.common.compose.systemBarPaddings +import team.duckie.app.android.common.compose.ui.ImeSpacer import team.duckie.app.android.common.compose.ui.PhotoPicker import team.duckie.app.android.common.compose.ui.PhotoPickerConstants +import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.ui.constant.SharedIcon +import team.duckie.app.android.common.compose.ui.quack.todo.QuackErrorableTextField +import team.duckie.app.android.common.compose.ui.quack.todo.QuackErrorableTextFieldState +import team.duckie.app.android.common.compose.ui.temp.TempFlexiblePrimaryLargeButton +import team.duckie.app.android.common.kotlin.runIf import team.duckie.app.android.feature.onboard.R import team.duckie.app.android.feature.onboard.common.OnboardTopAppBar import team.duckie.app.android.feature.onboard.common.TitleAndDescription import team.duckie.app.android.feature.onboard.constant.OnboardStep import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel import team.duckie.app.android.feature.onboard.viewmodel.state.ProfileScreenState -import team.duckie.app.android.common.compose.ui.constant.SharedIcon -import team.duckie.app.android.common.compose.activityViewModel -import team.duckie.app.android.common.compose.asLoose -import team.duckie.app.android.common.compose.rememberToast -import team.duckie.app.android.common.compose.systemBarPaddings -import team.duckie.app.android.common.kotlin.fastFirstOrNull -import team.duckie.app.android.common.kotlin.npe -import team.duckie.app.android.common.kotlin.runIf -import team.duckie.app.android.common.android.ui.const.Debounce -import team.duckie.quackquack.ui.animation.QuackAnimatedContent -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackErrorableTextField -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.shape.SquircleShape -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.material.shape.SquircleShape +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi private val currentStep = OnboardStep.Profile -private const val ProfileScreenTopAppBarLayoutId = "ProfileScreenTopAppBar" -private const val ProfileScreenTitleAndDescriptionLayoutId = "ProfileScreenTitleAndDescription" -private const val ProfileScreenProfileImageLayoutId = "ProfileScreenProfileImage" -private const val ProfileScreenNicknameTextFieldLayoutId = "ProfileScreenNicknameTextField" -private const val ProfileScreenNextButtonLayoutId = "ProfileScreenNextButton" - -private val ProfileScreenMeasurePolicy = MeasurePolicy { measurables, constraints -> - val looseConstraints = constraints.asLoose() - val extraLooseConstraints = constraints.asLoose(width = true) - - val topAppBarPlaceable = measurables.fastFirstOrNull { measurable -> - measurable.layoutId == ProfileScreenTopAppBarLayoutId - }?.measure(looseConstraints) ?: npe() - - val titileAndDescriptionPlaceable = measurables.fastFirstOrNull { measurable -> - measurable.layoutId == ProfileScreenTitleAndDescriptionLayoutId - }?.measure(looseConstraints) ?: npe() - - val profileImagePlaceable = measurables.fastFirstOrNull { measurable -> - measurable.layoutId == ProfileScreenProfileImageLayoutId - }?.measure(extraLooseConstraints) ?: npe() - - val nicknameTextFieldPlaceable = measurables.fastFirstOrNull { measurable -> - measurable.layoutId == ProfileScreenNicknameTextFieldLayoutId - }?.measure(looseConstraints) ?: npe() - - val nextButtonPlaceable = measurables.fastFirstOrNull { measurable -> - measurable.layoutId == ProfileScreenNextButtonLayoutId - }?.measure(looseConstraints) ?: npe() - - val topAppBarHeight = topAppBarPlaceable.height - val titleAndDescriptionHeight = titileAndDescriptionPlaceable.height - val profileImageHeight = profileImagePlaceable.height - val nextButtonHeight = nextButtonPlaceable.height - - layout( - width = constraints.maxWidth, - height = constraints.maxHeight, - ) { - topAppBarPlaceable.place( - x = 0, - y = 0, - ) - titileAndDescriptionPlaceable.place( - x = 0, - y = topAppBarHeight, - ) - profileImagePlaceable.place( - x = Alignment.CenterHorizontally.align( - size = profileImagePlaceable.width, - space = constraints.maxWidth, - layoutDirection = layoutDirection, - ), - y = topAppBarHeight + titleAndDescriptionHeight, - ) - nicknameTextFieldPlaceable.place( - x = 0, - y = topAppBarHeight + titleAndDescriptionHeight + profileImageHeight, - ) - nextButtonPlaceable.place( - x = 0, - y = constraints.maxHeight - nextButtonHeight, - ) - } -} - private const val MaxNicknameLength = 10 @Composable @@ -204,11 +142,11 @@ internal fun ProfileScreen(vm: OnboardViewModel = activityViewModel()) { }, ) } - var lastErrorText by remember { mutableStateOf("") } LaunchedEffect(vm) { val nicknameInputFlow = snapshotFlow { nickname } nicknameInputFlow + .onEach { vm.nicknameChecking() } .onEach { vm.readyToScreenCheck(currentStep) } .debounce(Debounce.SearchSecond) .collect { nickname -> @@ -221,39 +159,31 @@ internal fun ProfileScreen(vm: OnboardViewModel = activityViewModel()) { } Box(modifier = Modifier.fillMaxSize()) { - Layout( + Column( modifier = Modifier .zIndex(1f) .fillMaxSize() + .padding(horizontal = 16.dp) .padding(bottom = systemBarPaddings.calculateBottomPadding() + 16.dp), - content = { - val profileScreenState = vm.collectAsState().value.profileState - OnboardTopAppBar( - modifier = Modifier.layoutId(ProfileScreenTopAppBarLayoutId), - currentStep = currentStep, - ) - TitleAndDescription( - modifier = Modifier - .layoutId(ProfileScreenTitleAndDescriptionLayoutId) - .padding( - top = 12.dp, - start = 20.dp, - end = 20.dp, - ), - titleRes = R.string.profile_title, - descriptionRes = R.string.profile_description, - ) + ) { + val profileScreenState = vm.collectAsState().value.profileState + OnboardTopAppBar(currentStep = currentStep) + TitleAndDescription( + modifier = Modifier + .padding(top = 12.dp), + titleRes = R.string.profile_title, + descriptionRes = R.string.profile_description, + ) + Box(modifier = Modifier.align(Alignment.CenterHorizontally)) { ProfilePhoto( modifier = Modifier - .layoutId(ProfileScreenProfileImageLayoutId) .padding( - // 항상 center 에 배치돼서 horizontal padding 불필요 top = 32.dp, bottom = 20.dp, ), profilePhoto = profilePhoto, resetProfilePhoto = { - profilePhoto = QuackIcon.Profile + profilePhoto = "" profilePhotoLastSelectionIndex?.let { lastSelectionIndex -> profilePhotoSelections[lastSelectionIndex] = false } @@ -262,50 +192,57 @@ internal fun ProfileScreen(vm: OnboardViewModel = activityViewModel()) { photoPickerVisible = true }.takeIf { vm.isImagePermissionGranted == true }, ) - // TODO(sungbin): https://github.com/duckie-team/quack-quack-android/issues/438 - QuackErrorableTextField( - modifier = Modifier - .layoutId(ProfileScreenNicknameTextFieldLayoutId) - // 패딩이 왜 2배로 들어가지?? - .padding(horizontal = 10.dp), - text = nickname, - onTextChanged = { text -> - if (text.length <= MaxNicknameLength) { - nickname = text - } - }, - placeholderText = stringResource(R.string.profile_nickname_placeholder), - isError = ProfileScreenState.errorState.contains(profileScreenState), - maxLength = MaxNicknameLength, - errorText = when (profileScreenState) { - ProfileScreenState.NicknameRuleError -> stringResource(R.string.profile_nickname_rule_error) - ProfileScreenState.NicknameDuplicateError -> stringResource(R.string.profile_nickname_duplicate_error) - ProfileScreenState.NicknameEmpty -> stringResource(R.string.profile_nickname_empty) - else -> lastErrorText // 안하면 invisible 될 때 갑자기 텍스트가 사라짐 (애니메이션 X) - }.also { errorText -> - lastErrorText = errorText - }, - keyboardActions = KeyboardActions { - keyboard?.hide() - }, - ) - QuackLargeButton( - modifier = Modifier - .layoutId(ProfileScreenNextButtonLayoutId) - .padding(horizontal = 20.dp), - text = stringResource(R.string.button_next), - type = QuackLargeButtonType.Fill, - imeAnimation = true, - enabled = profileScreenState == ProfileScreenState.Valid && nickname.isNotEmpty(), - ) { - navigateNextStep( - vm = vm, - nickname = nickname, + } + + QuackErrorableTextField( + modifier = Modifier, + text = nickname, + onTextChanged = { text -> + if (text.length <= MaxNicknameLength) { + nickname = text + } + }, + placeholderText = stringResource(R.string.profile_nickname_placeholder), + maxLength = MaxNicknameLength, + textFieldState = when (profileScreenState) { + ProfileScreenState.NicknameRuleError -> QuackErrorableTextFieldState.Error( + errorText = stringResource(R.string.profile_nickname_rule_error), ) - } - }, - measurePolicy = ProfileScreenMeasurePolicy, - ) + + ProfileScreenState.NicknameDuplicateError -> QuackErrorableTextFieldState.Error( + errorText = stringResource(R.string.profile_nickname_duplicate_error), + ) + + ProfileScreenState.NicknameEmpty -> QuackErrorableTextFieldState.Error( + errorText = stringResource(R.string.profile_nickname_empty), + ) + + ProfileScreenState.Valid -> QuackErrorableTextFieldState.Success( + successText = stringResource(R.string.profile_nickname_valid), + ) + + else -> QuackErrorableTextFieldState.Normal + }, + keyboardActions = KeyboardActions { + keyboard?.hide() + }, + ) + + Spacer(weight = 1f) + TempFlexiblePrimaryLargeButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.button_next), + enabled = profileScreenState == ProfileScreenState.Valid && + nickname.isNotEmpty(), + ) { + navigateNextStep( + vm = vm, + nickname = nickname, + ) + } + + ImeSpacer() + } // TODO(sungbin): 효율적인 애니메이션 (카메라가 로드되면서 생기는 프라임드랍 때문에 애니메이션 제거) if (photoPickerVisible) { @@ -318,7 +255,7 @@ internal fun ProfileScreen(vm: OnboardViewModel = activityViewModel()) { modifier = Modifier .padding(top = systemBarPaddings.calculateTopPadding()) .fillMaxSize() - .background(color = QuackColor.White.composeColor), + .background(color = QuackColor.White.value), imageUris = galleryImages, imageSelections = profilePhotoSelections, onCameraClick = { @@ -350,9 +287,6 @@ internal fun ProfileScreen(vm: OnboardViewModel = activityViewModel()) { } } -private val ProfilePhotoShape = SquircleShape -private val ProfilePhotoSize = DpSize(all = 80.dp) - @Composable private fun ProfilePhoto( modifier: Modifier = Modifier, @@ -360,20 +294,37 @@ private fun ProfilePhoto( resetProfilePhoto: () -> Unit, openPhotoPicker: (() -> Unit)?, ) { - QuackAnimatedContent( + AnimatedContent( modifier = modifier - .size(ProfilePhotoSize) - .clip(ProfilePhotoShape) - .quackClickable(onClick = openPhotoPicker), + .quackClickable(onClick = openPhotoPicker) + .size(DpSize(width = 80.dp, height = 80.dp)) + .clip(SquircleShape), targetState = profilePhoto, + label = "AnimatedContent", ) { photo -> - QuackImage( - src = if (photo == "") SharedIcon.ic_default_profile else photo, - size = ProfilePhotoSize, - contentScale = ContentScale.Crop, - onClick = openPhotoPicker ?: {}, // required when onLongClick is used - onLongClick = resetProfilePhoto, - ) + if ("$photo".isEmpty()) { + QuackImage( + modifier = Modifier + .quackClickable( + onClick = openPhotoPicker ?: {}, // required when onLongClick is used + onLongClick = resetProfilePhoto, + ) + .size(DpSize(width = 80.dp, height = 80.dp)), + src = SharedIcon.ic_default_profile, + contentScale = ContentScale.Crop, + ) + } else { + QuackImage( + modifier = Modifier + .quackClickable( + onClick = openPhotoPicker ?: {}, // required when onLongClick is used + onLongClick = resetProfilePhoto, + ) + .size(DpSize(width = 80.dp, height = 80.dp)), + src = photo, + contentScale = ContentScale.Crop, + ) + } } } diff --git a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/2_CategoryScreen.kt b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/2_CategoryScreen.kt index 7ddea5fce..6eca2de60 100644 --- a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/2_CategoryScreen.kt +++ b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/2_CategoryScreen.kt @@ -25,29 +25,27 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.MeasurePolicy import androidx.compose.ui.layout.layoutId import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.toImmutableList import org.orbitmvi.orbit.compose.collectAsState -import team.duckie.app.android.feature.onboard.R -import team.duckie.app.android.feature.onboard.common.OnboardTopAppBar -import team.duckie.app.android.feature.onboard.common.TitleAndDescription -import team.duckie.app.android.feature.onboard.constant.OnboardStep -import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.asLoose import team.duckie.app.android.common.compose.systemBarPaddings +import team.duckie.app.android.common.compose.ui.quack.todo.QuackGridLayout +import team.duckie.app.android.common.compose.ui.quack.todo.QuackSelectableImage +import team.duckie.app.android.common.compose.ui.quack.todo.QuackSelectableImageType +import team.duckie.app.android.common.compose.ui.temp.TempFlexiblePrimaryLargeButton import team.duckie.app.android.common.kotlin.fastAny import team.duckie.app.android.common.kotlin.fastFirstOrNull import team.duckie.app.android.common.kotlin.fastMapIndexedNotNull import team.duckie.app.android.common.kotlin.npe -import team.duckie.quackquack.ui.animation.QuackAnimatedVisibility -import team.duckie.quackquack.ui.component.QuackGridLayout -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType -import team.duckie.quackquack.ui.component.QuackSelectableImage -import team.duckie.quackquack.ui.component.QuackSelectableImageType -import team.duckie.quackquack.ui.component.QuackTitle2 -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.app.android.feature.onboard.R +import team.duckie.app.android.feature.onboard.common.OnboardTopAppBar +import team.duckie.app.android.feature.onboard.common.TitleAndDescription +import team.duckie.app.android.feature.onboard.constant.OnboardStep +import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel +import team.duckie.quackquack.ui.sugar.QuackTitle2 private val currentStep = OnboardStep.Category @@ -102,16 +100,17 @@ private val CategoryScreenMeasurePolicy = MeasurePolicy { measurables, constrain internal fun CategoryScreen(vm: OnboardViewModel = activityViewModel()) { val onboardState by vm.collectAsState() - val categoriesSelectedIndex = remember(onboardState.categories, onboardState.selectedCategories) { - mutableStateListOf( - elements = Array( - size = onboardState.categories.size, - init = { index -> - onboardState.selectedCategories.contains(onboardState.categories[index]) - }, - ), - ) - } + val categoriesSelectedIndex = + remember(onboardState.categories, onboardState.selectedCategories) { + mutableStateListOf( + elements = Array( + size = onboardState.categories.size, + init = { index -> + onboardState.selectedCategories.contains(onboardState.categories[index]) + }, + ), + ) + } Layout( modifier = Modifier @@ -158,35 +157,26 @@ internal fun CategoryScreen(vm: OnboardViewModel = activityViewModel()) { ) } } - QuackAnimatedVisibility( + TempFlexiblePrimaryLargeButton( modifier = Modifier .layoutId(CategoryScreenNextButtonLayoutId) - .padding(horizontal = 20.dp) - .fillMaxWidth(), - visible = categoriesSelectedIndex.fastAny { it }, + .fillMaxWidth() + .padding(horizontal = 20.dp), + text = stringResource(R.string.button_next), + enabled = categoriesSelectedIndex.fastAny { it }, ) { - QuackLargeButton( - type = QuackLargeButtonType.Fill, - enabled = true, - text = stringResource(R.string.button_next), - ) { - vm.updateUserSelectCategories( - categories = categoriesSelectedIndex.fastMapIndexedNotNull { index, selected -> - onboardState.categories[index].takeIf { selected } - }, - ) - vm.navigateStep(currentStep + 1) - } + vm.updateUserSelectCategories( + categories = categoriesSelectedIndex.fastMapIndexedNotNull { index, selected -> + onboardState.categories[index].takeIf { selected } + }, + ) + vm.navigateStep(currentStep + 1) } }, measurePolicy = CategoryScreenMeasurePolicy, ) } -// FIXME(sungbin): 디자인상 100.dp 가 맞는데, 100 을 주면 디바이스 너비에 압축됨 -private val CategoryImageSize = DpSize(all = 80.dp) -private val CategoryItemShape = RoundedCornerShape(size = 12.dp) - @Composable private fun CategoryItem( imageUrl: String, @@ -203,12 +193,13 @@ private fun CategoryItem( ) { QuackSelectableImage( src = imageUrl, - size = CategoryImageSize, - shape = CategoryItemShape, + size = DpSize(width = 80.dp, height = 80.dp), + shape = RoundedCornerShape(size = 12.dp), selectableType = QuackSelectableImageType.CheckOverlay, isSelected = isSelected, onClick = onClick, ) + QuackTitle2(text = name) } } diff --git a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/3_TagScreen.kt b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/3_TagScreen.kt index 1bb42eb3f..29b1d0b3b 100644 --- a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/3_TagScreen.kt +++ b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/screen/3_TagScreen.kt @@ -5,7 +5,11 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -@file:OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) +@file:OptIn( + ExperimentalMaterialApi::class, + ExperimentalComposeUiApi::class, + ExperimentalQuackQuackApi::class, +) @file:Suppress( "ConstPropertyName", "PrivatePropertyName", @@ -42,7 +46,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.MeasurePolicy import androidx.compose.ui.layout.layoutId -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.google.accompanist.flowlayout.FlowRow @@ -50,10 +53,14 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState +import team.duckie.app.android.common.compose.HideKeyboardWhenBottomSheetHidden import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.asLoose import team.duckie.app.android.common.compose.systemBarPaddings import team.duckie.app.android.common.compose.ui.domain.DuckieTagAddBottomSheet +import team.duckie.app.android.common.compose.ui.quack.todo.QuackCircleTag +import team.duckie.app.android.common.compose.ui.quack.todo.QuackOutLinedSingeLazyRowTag +import team.duckie.app.android.common.compose.ui.temp.TempFlexiblePrimaryLargeButton import team.duckie.app.android.common.kotlin.AllowMagicNumber import team.duckie.app.android.common.kotlin.fastAny import team.duckie.app.android.common.kotlin.fastFirstOrNull @@ -68,15 +75,12 @@ import team.duckie.app.android.feature.onboard.common.TitleAndDescription import team.duckie.app.android.feature.onboard.constant.OnboardStep import team.duckie.app.android.feature.onboard.viewmodel.OnboardViewModel import team.duckie.app.android.feature.onboard.viewmodel.state.OnboardState -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackCircleTag -import team.duckie.quackquack.ui.component.QuackHeadLine2 -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType -import team.duckie.quackquack.ui.component.QuackSingeLazyRowTag -import team.duckie.quackquack.ui.component.QuackTagType -import team.duckie.quackquack.ui.component.QuackTitle2 -import team.duckie.quackquack.ui.icon.QuackIcon +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackTitle2 +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi private val currentStep = OnboardStep.Tag @@ -124,7 +128,6 @@ private val TagScreenMeasurePolicy = MeasurePolicy { measurables, constraints -> @Composable internal fun TagScreen(vm: OnboardViewModel = activityViewModel()) { - val keyboard = LocalSoftwareKeyboardController.current val coroutineScope = rememberCoroutineScope() var isLoadingToFinish by remember { mutableStateOf(false) } @@ -137,14 +140,7 @@ internal fun TagScreen(vm: OnboardViewModel = activityViewModel()) { skipHalfExpanded = true, ) - LaunchedEffect(sheetState) { - val sheetStateFlow = snapshotFlow { sheetState.currentValue } - sheetStateFlow.collect { state -> - if (state == ModalBottomSheetValue.Hidden) { - keyboard?.hide() - } - } - } + HideKeyboardWhenBottomSheetHidden(sheetState) BackHandler(sheetState.isVisible) { coroutineScope.launch { @@ -182,14 +178,17 @@ internal fun TagScreen(vm: OnboardViewModel = activityViewModel()) { addedTags.remove(addedTags[index]) }, ) - QuackLargeButton( + + // TODO(riflockle7): 문제 있으므로 꽥꽥 이슈 해결할 때까지 주석 제거하지 않음 + // type = QuackLargeButtonType.Fill, + // isLoading = isLoadingToFinish, + TempFlexiblePrimaryLargeButton( modifier = Modifier .layoutId(TagScreenQuackLargeButtonLayoutId) + .fillMaxWidth() .padding(horizontal = 20.dp), text = stringResource(id = R.string.button_start_duckie), - type = QuackLargeButtonType.Fill, enabled = true, - isLoading = isLoadingToFinish, ) { updateUserAndFinishOnboard( coroutineScope = coroutineScope, @@ -308,31 +307,29 @@ private fun TagSelection( QuackCircleTag( text = tag.name, isSelected = false, - trailingIcon = QuackIcon.Close, ) { requestRemoveAddedTag(index) } } } } - QuackHeadLine2( - modifier = Modifier.padding( - top = if (addedTags.isNotEmpty()) 0.dp else 4.dp, - start = 10.dp, - ), + QuackText( + modifier = Modifier + .quackClickable( + onClick = { + coroutineScope.launch { + sheetState.show() + } + }, + ) + .padding( + top = if (addedTags.isNotEmpty()) 0.dp else 8.dp, + start = 20.dp, + end = 10.dp, + bottom = 8.dp, + ), text = stringResource(R.string.tag_add_manual), - padding = PaddingValues( - top = if (addedTags.isNotEmpty()) 0.dp else 4.dp, - start = 10.dp, - end = 10.dp, - bottom = 8.dp, - ), - color = QuackColor.DuckieOrange, - onClick = { - coroutineScope.launch { - sheetState.show() - } - }, + typography = QuackTypography.HeadLine2.change(color = QuackColor.DuckieOrange), ) } @AllowMagicNumber(because = "(34 - 8).dp") @@ -344,11 +341,11 @@ private fun TagSelection( verticalArrangement = Arrangement.spacedBy(16.dp), ) { onboardState.selectedCategories.fastForEachIndexed { categoryIndex, category -> - QuackSingeLazyRowTag( + QuackOutLinedSingeLazyRowTag( title = stringResource(R.string.tag_hottest_tag, category.name), items = hottestTags[categoryIndex], itemSelections = hottestTagSelections[categoryIndex], - tagType = QuackTagType.Circle(), + trailingIcon = null, contentPadding = PaddingValues(horizontal = 20.dp), onClick = { tagIndex -> hottestTagSelections[categoryIndex][tagIndex] = diff --git a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/viewmodel/OnboardViewModel.kt b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/viewmodel/OnboardViewModel.kt index f9724af35..56e4cf676 100644 --- a/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/viewmodel/OnboardViewModel.kt +++ b/feature/onboard/src/main/kotlin/team/duckie/app/android/feature/onboard/viewmodel/OnboardViewModel.kt @@ -35,6 +35,7 @@ import org.orbitmvi.orbit.viewmodel.container import team.duckie.app.android.common.android.permission.PermissionCompat import team.duckie.app.android.common.android.savedstate.SaveableMutableStateFlow import team.duckie.app.android.common.android.viewmodel.context +import team.duckie.app.android.common.kotlin.exception.isKakaoCancelled import team.duckie.app.android.common.kotlin.seconds import team.duckie.app.android.domain.auth.usecase.AttachAccessTokenToHeaderUseCase import team.duckie.app.android.domain.auth.usecase.JoinUseCase @@ -182,6 +183,10 @@ internal class OnboardViewModel @AssistedInject constructor( } } + fun nicknameChecking() = intent { + reduce { state.copy(profileState = ProfileScreenState.Checking) } + } + /** 닉네임을 체크한다. */ fun checkNickname(nickname: String) { val isNicknameRuleError = checkNicknameRule(nickname) @@ -267,7 +272,7 @@ internal class OnboardViewModel @AssistedInject constructor( /* ----- Api ----- */ - suspend fun getKakaoAccessTokenAndJoin() = intent { + fun getKakaoAccessTokenAndJoin() = intent { getKakaoAccessTokenUseCase() .onSuccess { token -> postSideEffect(OnboardSideEffect.DelegateJoin(token)) @@ -275,7 +280,7 @@ internal class OnboardViewModel @AssistedInject constructor( .attachExceptionHandling() } - suspend fun join(kakaoAccessToken: String) = intent { + fun join(kakaoAccessToken: String) = intent { joinUseCase(kakaoAccessToken) .onSuccess { response -> reduce { @@ -371,7 +376,10 @@ internal class OnboardViewModel @AssistedInject constructor( additinal: suspend (exception: Throwable) -> Unit = {}, ) = intent { onFailure { exception -> - postSideEffect(OnboardSideEffect.ReportError(exception)) + when { + exception.isKakaoCancelled -> return@onFailure // this is not error + else -> postSideEffect(OnboardSideEffect.ReportError(exception)) + } additinal(exception) } } diff --git a/feature/onboard/src/main/res/values/strings.xml b/feature/onboard/src/main/res/values/strings.xml index e2ae0d65f..b74b0361a 100644 --- a/feature/onboard/src/main/res/values/strings.xml +++ b/feature/onboard/src/main/res/values/strings.xml @@ -29,6 +29,7 @@ 문자, 숫자, 밑줄, 마침표만 사용할 수 있어요. 이미 있는 이름이에요 :( 닉네임을 입력해주세요. + 사용 가능한 닉네임이에요! 어떤 분야를 좋아하나요? 1개 이상 골라보세요.\n취향에 맞춰 피드를 추천해드려요 :) @@ -37,7 +38,7 @@ 태그 할수록 더키의 추천이 정확해져요! 추가한 태그 + 직접 태그 추가하기 - 태그 입력하기 + 태그 입력하기 (최대 10자) %s 분야 인기 태그 이미 추가한 태그예요. 태그 생성에 실패했어요. 실패한 태그: %s diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts index 5f22a464a..e4d272466 100644 --- a/feature/profile/build.gradle.kts +++ b/feature/profile/build.gradle.kts @@ -31,7 +31,6 @@ dependencies { libs.ktx.lifecycle.runtime, libs.compose.ui.material, // needs for Scaffold libs.compose.lifecycle.runtime, - libs.quack.ui.components, libs.quack.v2.ui, libs.kotlin.collections.immutable, libs.paging.runtime, diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/ProfileActivity.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/ProfileActivity.kt index 1c713d17b..9066b93b9 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/ProfileActivity.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/ProfileActivity.kt @@ -55,7 +55,7 @@ class ProfileActivity : BaseActivity() { private val viewModel: ProfileViewModel by viewModels() @Inject - lateinit var createProblemNavigator: CreateProblemNavigator + lateinit var createExamNavigator: CreateProblemNavigator @Inject lateinit var notificationNavigator: NotificationNavigator @@ -170,7 +170,7 @@ class ProfileActivity : BaseActivity() { } ProfileSideEffect.NavigateToMakeExam -> { - createProblemNavigator.navigateFrom(this@ProfileActivity) + createExamNavigator.navigateFrom(this@ProfileActivity) } is ProfileSideEffect.NavigateToExamDetail -> { diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/component/EditTopAppBar.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/component/EditTopAppBar.kt index c2dd032aa..53addbedc 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/component/EditTopAppBar.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/component/EditTopAppBar.kt @@ -23,7 +23,7 @@ import team.duckie.quackquack.material.icon.QuackIcon import team.duckie.quackquack.material.icon.quackicon.Outlined import team.duckie.quackquack.material.icon.quackicon.outlined.ArrowBack import team.duckie.quackquack.material.quackClickable -import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackIcon import team.duckie.quackquack.ui.QuackText import team.duckie.quackquack.ui.sugar.QuackHeadLine2 @@ -47,9 +47,9 @@ internal fun EditTopAppBar( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - QuackImage( + QuackIcon( modifier = Modifier.quackClickable(onClick = onBackPressed), - src = QuackIcon.Outlined.ArrowBack, + icon = QuackIcon.Outlined.ArrowBack, ) QuackHeadLine2(text = title) diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/MyProfileScreen.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/MyProfileScreen.kt index b2d67e090..370a3c823 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/MyProfileScreen.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/MyProfileScreen.kt @@ -4,6 +4,7 @@ * Licensed under the MIT. * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ + @file:OptIn(ExperimentalQuackQuackApi::class) package team.duckie.app.android.feature.profile.screen @@ -12,7 +13,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -23,7 +24,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import team.duckie.app.android.common.compose.ui.BackPressedHeadLineTopAppBar import team.duckie.app.android.common.compose.ui.DuckTestCoverItem -import team.duckie.app.android.common.compose.ui.icon.v1.Notice +import team.duckie.app.android.common.compose.ui.temp.TempFlexibleSecondaryLargeButton import team.duckie.app.android.common.kotlin.FriendsType import team.duckie.app.android.domain.exam.model.ProfileExam import team.duckie.app.android.domain.user.model.UserProfile @@ -38,13 +39,11 @@ import team.duckie.app.android.feature.profile.viewmodel.state.mapper.toUiModel import team.duckie.quackquack.material.icon.QuackIcon import team.duckie.quackquack.material.icon.quackicon.Outlined import team.duckie.quackquack.material.icon.quackicon.outlined.Create +import team.duckie.quackquack.material.icon.quackicon.outlined.Notice import team.duckie.quackquack.material.icon.quackicon.outlined.Setting import team.duckie.quackquack.material.quackClickable -import team.duckie.quackquack.ui.QuackImage -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType +import team.duckie.quackquack.ui.QuackIcon import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi -import team.duckie.quackquack.ui.icon.QuackIcon as QuackV1Icon @Suppress("UnusedPrivateMember") // 시험 생성하기를 추후에 다시 활용하기 위함 @Composable @@ -67,17 +66,13 @@ fun MyProfileScreen( @Composable fun BackPressedHeadLineTopBarInternal() { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - QuackImage( - modifier = Modifier - .size(24.dp, 24.dp) - .quackClickable(onClick = onClickNotification), - src = QuackV1Icon.Companion.Notice, + QuackIcon( + modifier = Modifier.quackClickable(onClick = onClickNotification), + icon = QuackIcon.Outlined.Notice, ) - QuackImage( - modifier = Modifier - .size(24.dp, 24.dp) - .quackClickable(onClick = onClickSetting), - src = QuackIcon.Outlined.Setting, + QuackIcon( + modifier = Modifier.quackClickable(onClick = onClickSetting), + icon = QuackIcon.Outlined.Setting, ) } } @@ -125,8 +120,8 @@ fun MyProfileScreen( title = stringResource(id = R.string.my_favorite_tag), tags = tags, emptySection = { - QuackLargeButton( - type = QuackLargeButtonType.Compact, + TempFlexibleSecondaryLargeButton( + modifier = Modifier.fillMaxWidth(), text = stringResource(id = R.string.add_favorite_tag), onClick = onClickEditTag, ) @@ -150,8 +145,8 @@ fun MyProfileScreen( EmptyText(message = stringResource(id = R.string.not_yet_submit_exam)) // TODO(limsaehyun): 시험 생성하기가 가능한 스펙에서 활용 // Spacer(modifier = Modifier.padding(8.dp)) -// QuackLargeButton( -// type = QuackLargeButtonType.Compact, +// QuackButton( +// style = QuackButtonStyle.SecondaryLarge, // text = stringResource(id = R.string.make_exam), // onClick = onClickMakeExam, // ) diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/OtherProfileScreen.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/OtherProfileScreen.kt index 338870ff2..c35e65b51 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/OtherProfileScreen.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/OtherProfileScreen.kt @@ -11,7 +11,8 @@ package team.duckie.app.android.feature.profile.screen import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.rememberModalBottomSheetState @@ -22,7 +23,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.persistentListOf @@ -48,7 +48,7 @@ import team.duckie.quackquack.material.icon.quackicon.Outlined import team.duckie.quackquack.material.icon.quackicon.outlined.Create import team.duckie.quackquack.material.icon.quackicon.outlined.More import team.duckie.quackquack.material.quackClickable -import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackIcon @Composable internal fun OtherProfileScreen( @@ -102,6 +102,9 @@ internal fun OtherProfileScreen( isLoading = state.isLoading, editSection = { FollowSection( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), enabled = state.follow, onClick = viewModel::clickFollow, ) @@ -112,9 +115,8 @@ internal fun OtherProfileScreen( isLoading = state.isLoading, onBackPressed = viewModel::clickBackPress, trailingContent = { - QuackImage( + QuackIcon( modifier = Modifier - .size(DpSize(24.dp, 24.dp)) .quackClickable( onClick = { viewModel.updateBottomSheetDialogType(DuckieSelectableType.Ignore) @@ -123,7 +125,7 @@ internal fun OtherProfileScreen( } }, ), - src = QuackIcon.Outlined.More, + icon = QuackIcon.Outlined.More, ) }, ) diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/ProfileScreen.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/ProfileScreen.kt index 8a50191e3..6a9462b99 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/ProfileScreen.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/ProfileScreen.kt @@ -65,16 +65,13 @@ fun ProfileScreen( Column( modifier = Modifier .verticalScroll(scrollState) - .padding( - horizontal = 16.dp, - vertical = 24.dp, - ), + .padding(vertical = 24.dp), ) { with(userProfile) { ProfileSection( userId = user?.id ?: 0, profile = user?.profileImageUrl ?: "", - duckPower = user?.duckPower?.tier ?: "", + duckPower = user?.duckPower?.tier ?: "0덕", follower = followerCount, following = followingCount, introduce = user?.introduction diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/edit/ProfileEditScreen.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/edit/ProfileEditScreen.kt index 341fd257e..688a739ca 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/edit/ProfileEditScreen.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/edit/ProfileEditScreen.kt @@ -5,7 +5,11 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ -@file:OptIn(ExperimentalComposeUiApi::class) +@file:OptIn( + ExperimentalComposeUiApi::class, + ExperimentalDesignToken::class, + ExperimentalQuackQuackApi::class, +) package team.duckie.app.android.feature.profile.screen.edit @@ -14,6 +18,9 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.launch import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,6 +30,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect @@ -43,7 +51,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import team.duckie.app.android.common.compose.ui.PhotoPicker import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.ui.quack.todo.QuackErrorableTextField +import team.duckie.app.android.common.compose.ui.quack.todo.QuackErrorableTextFieldState import team.duckie.app.android.common.compose.ui.skeleton +import team.duckie.app.android.common.compose.util.addFocusCleaner import team.duckie.app.android.feature.profile.R import team.duckie.app.android.feature.profile.component.EditTopAppBar import team.duckie.app.android.feature.profile.component.GrayBorderButton @@ -54,10 +65,13 @@ import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.shape.SquircleShape import team.duckie.quackquack.ui.QuackImage import team.duckie.quackquack.ui.QuackText -import team.duckie.quackquack.ui.component.QuackErrorableTextField -import team.duckie.quackquack.ui.component.QuackReviewTextArea +import team.duckie.quackquack.ui.QuackTextArea +import team.duckie.quackquack.ui.QuackTextAreaStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.textAreaCounter +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi -private const val MaxNicknameLength = 12 +private const val MaxNicknameLength = 10 private const val MaxIntroductionLength = 60 @Composable @@ -67,6 +81,10 @@ internal fun ProfileEditScreen( val context = LocalContext.current.applicationContext val state by vm.container.stateFlow.collectAsStateWithLifecycle() + // keyboard focused + val interactionSource = remember { MutableInteractionSource() } + val introduceTextFieldFocused = interactionSource.collectIsFocusedAsState().value + val galleryState = remember(state) { state.galleryState } val keyboardController = LocalSoftwareKeyboardController.current val coroutineScope = rememberCoroutineScope() @@ -118,6 +136,7 @@ internal fun ProfileEditScreen( modifier = Modifier .fillMaxSize() .background(QuackColor.White.value) + .addFocusCleaner() .navigationBarsPadding() .systemBarsPadding(), ) { @@ -136,14 +155,14 @@ internal fun ProfileEditScreen( Spacer(space = 40.dp) ProfileEditSection( profile = state.profile, - onClickEditProfile = vm::clickEditProfile, + onClickEditProfile = vm::loadGalleryImages, ) Spacer(space = 40.dp) QuackText( text = stringResource(R.string.nickname), typography = QuackTypography.Body1.change(color = QuackColor.Gray1), ) - // TODO(riflockle7): quack v1 -> quack v2 + Spacer(space = 16.dp) QuackErrorableTextField( modifier = Modifier.skeleton(state.isLoading), text = state.nickname, @@ -153,12 +172,21 @@ internal fun ProfileEditScreen( } }, placeholderText = stringResource(R.string.profile_nickname_placeholder), - isError = state.nicknameState.isInValid(), maxLength = MaxNicknameLength, - errorText = when (state.nicknameState) { - NicknameState.NicknameRuleError -> stringResource(R.string.profile_nickname_rule_error) - NicknameState.NicknameDuplicateError -> stringResource(R.string.profile_nickname_duplicate_error) - else -> "" + textFieldState = when (state.nicknameState) { + NicknameState.NicknameRuleError -> QuackErrorableTextFieldState.Error( + errorText = stringResource(R.string.profile_nickname_rule_error), + ) + + NicknameState.NicknameDuplicateError -> QuackErrorableTextFieldState.Error( + errorText = stringResource(R.string.profile_nickname_duplicate_error), + ) + + NicknameState.Valid -> QuackErrorableTextFieldState.Success( + successText = stringResource(R.string.profile_nickname_valid), + ) + + else -> QuackErrorableTextFieldState.Normal }, keyboardActions = KeyboardActions { keyboardController?.hide() @@ -170,18 +198,28 @@ internal fun ProfileEditScreen( typography = QuackTypography.Body1.change(color = QuackColor.Gray1), ) Spacer(space = 8.dp) - // TODO(riflockle7): quack v1 -> quack v2 - QuackReviewTextArea( - // TODO(evergreenTree97) 배포 후 글자 수 제한있는 텍스트필드 구현 - modifier = Modifier.skeleton(state.isLoading), - text = state.introduce, - onTextChanged = { + QuackTextArea( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = if (introduceTextFieldFocused) { + QuackColor.DuckieOrange.value + } else { + QuackColor.Gray3.value + }, + shape = RoundedCornerShape(8.dp), + ) + .textAreaCounter(maxLength = 60), + style = QuackTextAreaStyle.Default, + value = state.introduce, + onValueChange = { if (it.length <= MaxIntroductionLength) { vm.inputIntroduce(it) } }, - focused = state.introduceFocused, placeholderText = stringResource(id = R.string.please_input_introduce), + interactionSource = interactionSource, ) } } diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ButtonSection.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ButtonSection.kt index b367ecf46..56314c7b1 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ButtonSection.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ButtonSection.kt @@ -7,29 +7,30 @@ package team.duckie.app.android.feature.profile.screen.section +import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import team.duckie.app.android.feature.profile.R -import team.duckie.quackquack.ui.border.QuackBorder -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackSurface -import team.duckie.quackquack.ui.component.internal.QuackText -import team.duckie.quackquack.ui.textstyle.QuackTextStyle +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackText @Composable internal fun FollowSection( + modifier: Modifier = Modifier, enabled: Boolean, onClick: () -> Unit, ) { EditButton( - modifier = Modifier.fillMaxWidth(), + modifier = modifier, text = if (enabled) { stringResource(id = R.string.follow) } else { @@ -48,39 +49,39 @@ fun EditButton( enabled: Boolean = false, plainTextColor: QuackColor = QuackColor.Gray1, ) { - QuackSurface( - modifier = modifier.fillMaxWidth(), - backgroundColor = if (enabled) { - QuackColor.White - } else { - QuackColor.Gray4 - }, - border = if (enabled) { - QuackBorder( + QuackText( + modifier = modifier + .clip(RoundedCornerShape(size = 8.dp)) + .quackClickable(onClick = onClick) + .background( + if (enabled) { + QuackColor.White.toBrush() + } else { + QuackColor.Gray4.toBrush() + }, + ) + .border( width = 1.dp, - color = QuackColor.DuckieOrange, + brush = if (enabled) { + QuackColor.DuckieOrange.toBrush() + } else { + QuackColor.Unspecified.toBrush() + }, + shape = RoundedCornerShape(size = 8.dp), ) - } else { - null - }, - shape = RoundedCornerShape(size = 8.dp), - onClick = onClick, - ) { - QuackText( - modifier = Modifier.padding( + .padding( vertical = 8.dp, horizontal = 12.dp, ), - text = text, - style = QuackTextStyle.Body1.change( - color = if (enabled) { - QuackColor.DuckieOrange - } else { - plainTextColor - }, - textAlign = TextAlign.Center, - ), - singleLine = true, - ) - } + text = text, + typography = QuackTypography.Body1.change( + color = if (enabled) { + QuackColor.DuckieOrange + } else { + plainTextColor + }, + textAlign = TextAlign.Center, + ), + singleLine = true, + ) } diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/EditSection.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/EditSection.kt index b1292bf8c..dfe32bbaf 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/EditSection.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/EditSection.kt @@ -10,6 +10,7 @@ package team.duckie.app.android.feature.profile.screen.section import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -17,7 +18,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.feature.profile.R -import team.duckie.quackquack.ui.color.QuackColor +import team.duckie.quackquack.material.QuackColor @Composable internal fun EditSection( @@ -25,7 +26,9 @@ internal fun EditSection( onClickEditTag: () -> Unit, ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ExamSection.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ExamSection.kt index 3743a9f68..316a317aa 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ExamSection.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ExamSection.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -30,7 +31,7 @@ import team.duckie.app.android.feature.profile.R import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.material.quackClickable -import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackIcon import team.duckie.quackquack.ui.QuackText import team.duckie.quackquack.ui.sugar.QuackTitle2 @@ -47,7 +48,9 @@ fun ExamSection( ) { Column(modifier = Modifier.fillMaxWidth()) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -55,11 +58,11 @@ fun ExamSection( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, ) { - QuackImage( + QuackIcon( modifier = Modifier .size(DpSize(24.dp, 24.dp)) .skeleton(isLoading), - src = icon, + icon = icon, ) QuackTitle2( modifier = Modifier.skeleton(isLoading), @@ -81,6 +84,9 @@ fun ExamSection( LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { + item { + Spacer(space = 16.dp) + } items(exams) { item -> DuckExamSmallCover( isLoading = isLoading, diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/FavoriteSection.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/FavoriteSection.kt index 07bde2d58..322aa8526 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/FavoriteSection.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/FavoriteSection.kt @@ -9,37 +9,44 @@ package team.duckie.app.android.feature.profile.screen.section import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList +import team.duckie.app.android.common.compose.ui.quack.todo.QuackOutLinedSingeLazyRowTag import team.duckie.app.android.common.compose.ui.skeleton import team.duckie.app.android.domain.tag.model.Tag -import team.duckie.quackquack.ui.component.QuackSingeLazyRowTag -import team.duckie.quackquack.ui.component.QuackTagType import team.duckie.quackquack.ui.sugar.QuackTitle2 @Composable internal fun FavoriteTagSection( isLoading: Boolean, title: String, - emptySection: @Composable () -> Unit, + emptySection: @Composable ColumnScope.() -> Unit, tags: ImmutableList, onClickTag: (String) -> Unit, ) { val tagList = remember(tags) { tags.map { it.name }.toList() } - Column(verticalArrangement = Arrangement.spacedBy(13.5.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(13.5.dp), + ) { QuackTitle2(text = title) if (tags.isEmpty()) { emptySection() } else { // TODO(riflockle7): quack v2 에서 대체할 수 있는 내용 찾기 - QuackSingeLazyRowTag( + QuackOutLinedSingeLazyRowTag( modifier = Modifier.skeleton(isLoading), items = tagList, - tagType = QuackTagType.Circle(), + trailingIcon = null, onClick = { index -> onClickTag(tagList[index]) }, ) } diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ProfileSection.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ProfileSection.kt index 8722cbfa4..54afc228c 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ProfileSection.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/section/ProfileSection.kt @@ -11,7 +11,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,7 +40,11 @@ internal fun ProfileSection( isLoading: Boolean, onClickFriend: (FriendsType, Int) -> Unit, ) { - Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/viewall/ViewAllScreen.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/viewall/ViewAllScreen.kt index 8a6f4bb70..3c6dca280 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/viewall/ViewAllScreen.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/screen/viewall/ViewAllScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -21,6 +22,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems +import team.duckie.app.android.common.compose.getUniqueKey import team.duckie.app.android.common.compose.itemsPagingKey import team.duckie.app.android.common.compose.ui.BackPressedHeadLine2TopAppBar import team.duckie.app.android.common.compose.ui.DuckExamSmallCoverForColumn @@ -33,6 +35,21 @@ import team.duckie.app.android.feature.profile.viewmodel.state.ExamType import team.duckie.app.android.feature.profile.viewmodel.state.mapper.toUiModel import team.duckie.quackquack.material.QuackColor +private const val GRID_COUNT: Int = 2 + +/** + * [GRID_COUNT]만큼 item 을 생성하여 [Arrangement.spacedBy]로 스크롤에 여백을 만듭니다. + */ +private fun LazyGridScope.spaceItem( + maxIndex: Int, + content: @Composable () -> Unit = {}, +) { + val count = maxIndex % GRID_COUNT + 1 + repeat(count) { + item { content() } + } +} + @Composable fun ViewAllScreen( examType: ExamType, @@ -52,26 +69,31 @@ fun ViewAllScreen( title = getViewAllTitle(examType = examType), onBackPressed = onBackPressed, ) - Spacer(space = 20.dp) LazyVerticalGrid( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp), - columns = GridCells.Fixed(2), + columns = GridCells.Fixed(GRID_COUNT), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { + repeat(GRID_COUNT) { + item { + Spacer(space = 20.dp) + } + } when (examType) { ExamType.Heart, ExamType.Created -> { items( count = profileExams.itemCount, key = itemsPagingKey( items = profileExams, - key = { profileExams[it]?.id }, + key = { profileExams[it]?.id?.getUniqueKey(it) }, ), ) { index -> profileExams[index]?.let { item -> val duckTestCoverItem = item.toUiModel() DuckExamSmallCoverForColumn( + modifier = Modifier.padding(bottom = 40.dp), duckTestCoverItem = duckTestCoverItem, onItemClick = { onItemClick(duckTestCoverItem) }, isLoading = profileExamInstances.loadState.append == LoadState.Loading, @@ -79,6 +101,7 @@ fun ViewAllScreen( ) } } + spaceItem(profileExams.itemCount) } ExamType.Solved -> { @@ -86,7 +109,7 @@ fun ViewAllScreen( count = profileExamInstances.itemCount, key = itemsPagingKey( items = profileExamInstances, - key = { profileExamInstances[it]?.id }, + key = { profileExams[it]?.id?.getUniqueKey(it) }, ), ) { index -> profileExamInstances[index]?.let { item -> @@ -98,7 +121,11 @@ fun ViewAllScreen( onMoreClick = onMoreClick, // 추후 신고하기 구현 필요 ) } + if (index == profileExams.itemCount) { + Spacer(space = 40.dp) + } } + spaceItem(profileExamInstances.itemCount) } } } diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/ProfileEditViewModel.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/ProfileEditViewModel.kt index cf0d26c87..ac124c84e 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/ProfileEditViewModel.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/ProfileEditViewModel.kt @@ -15,7 +15,9 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.launch import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.syntax.simple.intent @@ -111,10 +113,6 @@ class ProfileEditViewModel @Inject constructor( } } - fun clickEditProfile() = intent { - loadGalleryImages() - } - private fun initUser() = intent { updateLoading(true) getUserUseCase(state.userId).onSuccess { user -> @@ -135,17 +133,19 @@ class ProfileEditViewModel @Inject constructor( } } - private fun loadGalleryImages() = intent { - loadGalleryImagesUseCase().onSuccess { images -> - changeGalleryState( - galleryState = state.galleryState.copy( - images = persistentListOf(*images.toTypedArray()), - imagesSelections = images.fastMap { false }.toImmutableList(), - ), - ) - changePhotoPickerVisible(true) - }.onFailure { - postSideEffect(ProfileEditSideEffect.ReportError(it)) + fun loadGalleryImages() = intent { + viewModelScope.launch(Dispatchers.IO) { + loadGalleryImagesUseCase().onSuccess { images -> + changeGalleryState( + galleryState = state.galleryState.copy( + images = persistentListOf(*images.toTypedArray()), + imagesSelections = images.fastMap { false }.toImmutableList(), + ), + ) + changePhotoPickerVisible(true) + }.onFailure { + postSideEffect(ProfileEditSideEffect.ReportError(it)) + } } } @@ -200,8 +200,6 @@ class ProfileEditViewModel @Inject constructor( } fun clickEditComplete(applicationContext: Context?) = intent { - updateLoading(true) - getUploadableFileUrl( state.profile.toString(), applicationContext, @@ -217,14 +215,11 @@ class ProfileEditViewModel @Inject constructor( nickname = state.nickname, introduction = state.introduce, ).onSuccess { - updateLoading(false) postSideEffect(ProfileEditSideEffect.NavigateBack) }.onFailure { - updateLoading(false) postSideEffect(ProfileEditSideEffect.ReportError(it)) } }.onFailure { - updateLoading(false) postSideEffect(ProfileEditSideEffect.ReportError(it)) } } diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/ProfileViewModel.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/ProfileViewModel.kt index e35a2b60c..3441b238a 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/ProfileViewModel.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/ProfileViewModel.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.simple.SimpleSyntax import org.orbitmvi.orbit.syntax.simple.intent import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce @@ -87,7 +88,7 @@ internal class ProfileViewModel @Inject constructor( fun init() = intent { val userId = savedStateHandle.getStateFlow(Extras.UserId, 0).value - startLoading() + updateLoading(true) val job = viewModelScope.launch { getMeUseCase() @@ -97,7 +98,7 @@ internal class ProfileViewModel @Inject constructor( reduce { state.copy(step = ProfileStep.Error) } postSideEffect(ProfileSideEffect.ReportError(it)) }.also { - stopLoading() + updateLoading(false) } }.apply { join() } @@ -118,7 +119,7 @@ internal class ProfileViewModel @Inject constructor( reduce { state.copy(step = ProfileStep.Error) } postSideEffect(ProfileSideEffect.ReportError(it)) }.also { - stopLoading() + updateLoading(false) } } } @@ -156,7 +157,6 @@ internal class ProfileViewModel @Inject constructor( } fun getUserProfile() = intent { - startLoading() viewModelScope.launch { fetchUserProfileUseCase(state.userId) .onSuccess { profile -> @@ -170,8 +170,6 @@ internal class ProfileViewModel @Inject constructor( .onFailure { reduce { state.copy(step = ProfileStep.Error) } postSideEffect(ProfileSideEffect.ReportError(it)) - }.also { - stopLoading() } } } @@ -241,15 +239,9 @@ internal class ProfileViewModel @Inject constructor( postSideEffect(ProfileSideEffect.NavigateToMakeExam) } - private fun startLoading() = intent { - reduce { - state.copy(isLoading = true) - } - } - - private fun stopLoading() = intent { + private suspend fun SimpleSyntax.updateLoading(isLoading: Boolean) { reduce { - state.copy(isLoading = false) + state.copy(isLoading = isLoading) } } } diff --git a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/state/ProfileEditState.kt b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/state/ProfileEditState.kt index 4084f905c..6b84e6ae0 100644 --- a/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/state/ProfileEditState.kt +++ b/feature/profile/src/main/kotlin/team/duckie/app/android/feature/profile/viewmodel/state/ProfileEditState.kt @@ -19,7 +19,6 @@ data class ProfileEditState( val nicknameState: NicknameState = NicknameState.Checking, val galleryState: GalleryState = GalleryState(), val introduce: String = "", - val introduceFocused: Boolean = false, val userId: Int = 0, ) diff --git a/feature/profile/src/main/res/values/strings.xml b/feature/profile/src/main/res/values/strings.xml index 06a1d2778..657bc2edb 100644 --- a/feature/profile/src/main/res/values/strings.xml +++ b/feature/profile/src/main/res/values/strings.xml @@ -38,6 +38,7 @@ 이름에는 문자, 숫자, 밑줄, 마침표만 사용할 수 있어요. 이미 있는 이름이에요 전체보기 + 사용 가능한 닉네임이에요! 닉네임 소개 diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts index 3b189d08f..ec18da48a 100644 --- a/feature/search/build.gradle.kts +++ b/feature/search/build.gradle.kts @@ -28,10 +28,13 @@ dependencies { projects.common.compose, libs.orbit.viewmodel, libs.orbit.compose, - libs.quack.ui.components, libs.compose.lifecycle.runtime, + libs.compose.ui.material, libs.firebase.crashlytics, libs.paging.runtime, libs.paging.compose, + libs.quack.v2.ui, + libs.quack.v2.ui.plugin.interceptor.textfield, + libs.kotlin.collections.immutable, ) } diff --git a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchActivity.kt b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchActivity.kt index aeb7a2eee..37322b074 100644 --- a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchActivity.kt +++ b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchActivity.kt @@ -5,37 +5,74 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalMaterialApi::class) +// TODO(limsaehyun): The function onCreate is too long (127). +// The maximum length is 100. [LongMethod] 대응 필요 +@file:Suppress("LongMethod") + package team.duckie.app.android.feature.search.screen import android.os.Bundle +import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems import com.google.firebase.crashlytics.ktx.crashlytics import com.google.firebase.ktx.Firebase import dagger.hilt.android.AndroidEntryPoint +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState +import team.duckie.app.android.common.android.deeplink.DynamicLinkHelper import team.duckie.app.android.common.android.ui.BaseActivity import team.duckie.app.android.common.android.ui.const.Extras +import team.duckie.app.android.common.android.ui.finishWithAnimation +import team.duckie.app.android.common.android.ui.popStringExtra +import team.duckie.app.android.common.compose.collectAndHandleState +import team.duckie.app.android.common.compose.systemBarPaddings +import team.duckie.app.android.common.compose.ui.DuckieCircularProgressIndicator +import team.duckie.app.android.common.compose.ui.ErrorScreen +import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.ui.constant.SharedIcon +import team.duckie.app.android.common.compose.ui.dialog.DuckieSelectableBottomSheetDialog +import team.duckie.app.android.common.compose.ui.dialog.DuckieSelectableType +import team.duckie.app.android.common.compose.ui.dialog.ReportDialog +import team.duckie.app.android.common.compose.ui.quack.QuackNoUnderlineTextField +import team.duckie.app.android.common.compose.util.addFocusCleaner +import team.duckie.app.android.domain.exam.model.Exam import team.duckie.app.android.feature.search.R import team.duckie.app.android.feature.search.constants.SearchResultStep import team.duckie.app.android.feature.search.constants.SearchStep @@ -43,20 +80,15 @@ import team.duckie.app.android.feature.search.viewmodel.SearchViewModel import team.duckie.app.android.feature.search.viewmodel.sideeffect.SearchSideEffect import team.duckie.app.android.navigator.feature.detail.DetailNavigator import team.duckie.app.android.navigator.feature.profile.ProfileNavigator -import team.duckie.app.android.common.compose.ui.DuckieCircularProgressIndicator -import team.duckie.app.android.common.compose.ui.ErrorScreen -import team.duckie.app.android.common.compose.ui.constant.SharedIcon -import team.duckie.app.android.common.compose.ui.quack.QuackNoUnderlineTextField -import team.duckie.app.android.common.compose.collectAndHandleState -import team.duckie.app.android.common.android.ui.finishWithAnimation -import team.duckie.app.android.common.android.ui.popStringExtra -import team.duckie.app.android.common.compose.ui.Spacer -import team.duckie.quackquack.ui.animation.QuackAnimatedContent -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.theme.QuackTheme -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.Outlined +import team.duckie.quackquack.material.icon.quackicon.outlined.ArrowBack +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.material.theme.QuackTheme +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.plugin.interceptor.textfield.QuackTextFieldFontFamilyRemovalPlugin +import team.duckie.quackquack.ui.plugin.rememberQuackPlugins import javax.inject.Inject internal val SearchHorizontalPadding = PaddingValues(horizontal = 16.dp) @@ -84,88 +116,152 @@ class SearchActivity : BaseActivity() { setContent { val state = vm.collectAsState().value + val focusRequester = remember { FocusRequester() } + val bottomSheetDialogState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) + val coroutineScope = rememberCoroutineScope() + val keyboardController = LocalSoftwareKeyboardController.current + val searchText by vm.searchText.collectAsStateWithLifecycle() + val focusManager = LocalFocusManager.current vm.searchUsers.collectAndHandleState(handleLoadStates = vm::checkError) - vm.searchExams.collectAndHandleState(handleLoadStates = vm::checkError) + val searchExams = + vm.searchExams.collectAndHandleState(handleLoadStates = vm::checkError) LaunchedEffect(key1 = vm) { vm.container.sideEffectFlow - .onEach(::handleSideEffect) + .onEach { + handleSideEffect(it, searchExams) + } .launchIn(this) } - QuackTheme { - Box( + LaunchedEffect(key1 = state.searchAutoFocusing) { + if (state.searchAutoFocusing) { + focusRequester.requestFocus() + } + } + + QuackTheme( + plugins = rememberQuackPlugins { + +QuackTextFieldFontFamilyRemovalPlugin + }, + ) { + ReportDialog( + visible = state.reportDialogVisible, + onClick = { vm.updateReportDialogVisible(false) }, + onDismissRequest = { vm.updateReportDialogVisible(false) }, + ) + DuckieSelectableBottomSheetDialog( modifier = Modifier .fillMaxSize() - .systemBarsPadding(), - contentAlignment = Alignment.Center, + .systemBarsPadding() + .navigationBarsPadding(), + bottomSheetState = bottomSheetDialogState, + closeSheet = { + coroutineScope.launch { + bottomSheetDialogState.hide() + } + }, + onReport = vm::report, + onCopyLink = vm::copyExamDynamicLink, + types = persistentListOf( + DuckieSelectableType.CopyLink, + DuckieSelectableType.Report, + ), ) { - Column( - modifier = Modifier.background(QuackColor.White.composeColor), + Box( + modifier = Modifier + .fillMaxSize() + .background(QuackColor.White.value) + .addFocusCleaner(), + contentAlignment = Alignment.Center, ) { - SearchTextFieldTopBar( - searchKeyword = state.searchKeyword, - onSearchKeywordChanged = { keyword -> - vm.updateSearchKeyword(keyword = keyword) - }, - onPrevious = { - finishWithAnimation() - }, - clearSearchKeyword = { - vm.clearSearchKeyword() - }, - ) - QuackAnimatedContent( - targetState = state.searchStep, - ) { step -> - when (step) { - SearchStep.Search -> SearchScreen(vm = vm) - SearchStep.SearchResult -> { - if (state.isSearchProblemError && - state.tagSelectedTab == SearchResultStep.DuckExam - ) { - ErrorScreen( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding(), - false, - onRetryClick = { - vm.fetchSearchExams(state.searchKeyword) - }, - ) - } else if (state.isSearchUserError && - state.tagSelectedTab == SearchResultStep.User - ) { - ErrorScreen( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding(), - false, - onRetryClick = { - vm.fetchSearchUsers(state.searchKeyword) - }, - ) - } else { - SearchResultScreen( - navigateDetail = { examId -> - vm.navigateToDetail(examId = examId) - }, - ) + Column { + systemBarPaddings + SearchTextFieldTopBar( + searchKeyword = searchText, + onSearchKeywordChanged = { keyword -> + vm.updateSearchKeyword(keyword = keyword) + }, + onPrevious = { + finishWithAnimation() + }, + clearSearchKeyword = { + vm.clearSearchKeyword() + }, + onAction = { + focusManager.clearFocus() + }, + focusRequester = focusRequester, + ) + AnimatedContent( + targetState = state.searchStep, + label = "AnimatedContent", + ) { step -> + when (step) { + SearchStep.Search -> SearchScreen( + vm = vm, + onSearchComplete = { + focusManager.clearFocus() + }, + ) + + SearchStep.SearchResult -> { + if (state.isSearchProblemError && + state.tagSelectedTab == SearchResultStep.DuckExam + ) { + ErrorScreen( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding(), + false, + onRetryClick = { + vm.fetchSearchExams(searchText) + }, + ) + } else if (state.isSearchUserError && + state.tagSelectedTab == SearchResultStep.User + ) { + ErrorScreen( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding(), + false, + onRetryClick = { + vm.fetchSearchUsers(searchText) + }, + ) + } else { + SearchResultScreen( + navigateDetail = { examId -> + vm.navigateToDetail(examId = examId) + }, + openBottomSheet = { examId -> + vm.setTargetExamId(examId = examId) + coroutineScope.launch { + keyboardController?.hide() + bottomSheetDialogState.show() + } + }, + ) + } } } } } - } - if (state.isSearchLoading) { - DuckieCircularProgressIndicator() + if (state.isSearchLoading) { + DuckieCircularProgressIndicator() + } } } } } } - private fun handleSideEffect(sideEffect: SearchSideEffect) { + private fun handleSideEffect( + sideEffect: SearchSideEffect, + examPagingItems: LazyPagingItems, + ) { when (sideEffect) { is SearchSideEffect.ReportError -> { Firebase.crashlytics.recordException(sideEffect.exception) @@ -188,6 +284,18 @@ class SearchActivity : BaseActivity() { }, ) } + + is SearchSideEffect.SendToast -> { + Toast.makeText(this, sideEffect.message, Toast.LENGTH_SHORT).show() + } + + is SearchSideEffect.CopyDynamicLink -> { + DynamicLinkHelper.createAndShareLink(this, sideEffect.examId) + } + + SearchSideEffect.ExamRefresh -> { + examPagingItems.refresh() + } } } } @@ -199,6 +307,8 @@ private fun SearchTextFieldTopBar( onSearchKeywordChanged: (String) -> Unit, clearSearchKeyword: () -> Unit, onPrevious: () -> Unit, + onAction: () -> Unit, + focusRequester: FocusRequester, ) { Row( modifier = modifier @@ -210,21 +320,23 @@ private fun SearchTextFieldTopBar( ), verticalAlignment = Alignment.CenterVertically, ) { - QuackImage( - src = QuackIcon.ArrowBack, - size = DpSize(all = 24.dp), - onClick = onPrevious, + QuackIcon( + modifier = Modifier.quackClickable(onClick = onPrevious), + icon = QuackIcon.Outlined.ArrowBack, ) Spacer(space = 8.dp) QuackNoUnderlineTextField( + modifier = Modifier.focusRequester(focusRequester), text = searchKeyword, - onTextChanged = { keyword -> - onSearchKeywordChanged(keyword) - }, + onTextChanged = onSearchKeywordChanged, placeholderText = stringResource(id = R.string.try_search), - trailingIcon = SharedIcon.ic_textfield_delete_16, + trailingIcon = if (searchKeyword.isNotEmpty()) SharedIcon.ic_textfield_delete_16 else null, trailingIconOnClick = clearSearchKeyword, - trailingEndPadding = 12.dp, + keyboardActions = KeyboardActions( + onDone = { + onAction() + }, + ), ) } } diff --git a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchResultScreen.kt b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchResultScreen.kt index 4a3b2c274..b62ad035e 100644 --- a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchResultScreen.kt +++ b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchResultScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -32,49 +33,54 @@ import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.ui.DuckExamSmallCover import team.duckie.app.android.common.compose.ui.DuckTestCoverItem import team.duckie.app.android.common.compose.ui.Spacer -import team.duckie.app.android.common.compose.ui.UserFollowingLayout +import team.duckie.app.android.common.compose.ui.content.UserFollowingLayout +import team.duckie.app.android.common.kotlin.fastForEach import team.duckie.app.android.domain.exam.model.Exam import team.duckie.app.android.feature.search.R import team.duckie.app.android.feature.search.constants.SearchResultStep import team.duckie.app.android.feature.search.viewmodel.SearchViewModel import team.duckie.app.android.feature.search.viewmodel.state.SearchState -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody1 -import team.duckie.quackquack.ui.component.QuackHeadLine1 -import team.duckie.quackquack.ui.component.QuackMainTab +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackTab +import team.duckie.quackquack.ui.QuackText @Composable internal fun SearchResultScreen( modifier: Modifier = Modifier, vm: SearchViewModel = activityViewModel(), navigateDetail: (Int) -> Unit, + openBottomSheet: (Int) -> Unit, ) { val state = vm.collectAsState().value val searchUsers = vm.searchUsers.collectAsLazyPagingItems() val searchExams = vm.searchExams.collectAsLazyPagingItems() - val tabTitles = SearchResultStep.values().map { - it.title - }.toPersistentList() + val tabTitles = remember { + SearchResultStep.values().map { step -> + step.title + }.toPersistentList() + } Column( modifier = modifier .fillMaxSize() .nestedScroll(rememberNestedScrollInteropConnection()), ) { - QuackMainTab( - titles = tabTitles, - selectedTabIndex = state.tagSelectedTab.index, - onTabSelected = { index -> - vm.updateSearchResultTab(SearchResultStep.toStep(index)) - }, - ) + QuackTab(index = state.tagSelectedTab.index) { + tabTitles.fastForEach { label -> + tab(label) { index -> + vm.updateSearchResultTab(SearchResultStep.toStep(index)) + } + } + } when (state.tagSelectedTab) { SearchResultStep.DuckExam -> { SearchResultForExam( searchExams = searchExams, navigateDetail = navigateDetail, + onMoreClick = openBottomSheet, ) } @@ -108,14 +114,18 @@ private fun SearchResultForUser( .padding(top = 60.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - QuackHeadLine1( + QuackText( text = stringResource(id = R.string.no_search_user), - color = QuackColor.Gray1, + typography = QuackTypography.HeadLine1.change( + color = QuackColor.Gray1, + ), ) Spacer(space = 12.dp) - QuackBody1( + QuackText( text = stringResource(id = R.string.search_another_keyword), - color = QuackColor.Gray1, + typography = QuackTypography.Body1.change( + color = QuackColor.Gray1, + ), ) } } else { @@ -128,10 +138,10 @@ private fun SearchResultForUser( favoriteTag = item?.favoriteTag ?: "", tier = item?.tier ?: "", isFollowing = item?.isFollowing ?: false, - onClickFollow = { follow -> + onClickTrailingButton = { follow -> onClickFollow(item?.userId ?: 0, follow) }, - isMine = myUserId == item?.userId, + visibleTrailingButton = myUserId != item?.userId, onClickUserProfile = onClickUserProfile, ) } @@ -143,6 +153,7 @@ private fun SearchResultForUser( private fun SearchResultForExam( searchExams: LazyPagingItems, navigateDetail: (Int) -> Unit, + onMoreClick: (Int) -> Unit, ) { if (searchExams.itemCount == 0) { Column( @@ -151,18 +162,23 @@ private fun SearchResultForExam( .padding(top = 60.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - QuackHeadLine1( + QuackText( text = stringResource(id = R.string.no_search_exam), - color = QuackColor.Gray1, + typography = QuackTypography.HeadLine1.change( + color = QuackColor.Gray1, + ), ) Spacer(space = 12.dp) - QuackBody1( + QuackText( text = stringResource(id = R.string.search_another_keyword), - color = QuackColor.Gray1, + typography = QuackTypography.Body1.change( + color = QuackColor.Gray1, + ), ) } } else { LazyVerticalGrid( + modifier = Modifier.padding(top = 20.dp), columns = GridCells.Fixed(2), state = rememberLazyGridState(), verticalArrangement = Arrangement.spacedBy(48.dp), @@ -183,7 +199,9 @@ private fun SearchResultForExam( onItemClick = { navigateDetail(exam?.id ?: 0) }, - onMoreClick = {}, + onMoreClick = { + onMoreClick(exam?.id ?: 0) + }, ) } } diff --git a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchScreen.kt b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchScreen.kt index 1949f6376..1d8d3e6d1 100644 --- a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchScreen.kt +++ b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/screen/SearchScreen.kt @@ -36,19 +36,22 @@ import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.domain.tag.model.Tag import team.duckie.app.android.feature.search.R import team.duckie.app.android.feature.search.viewmodel.SearchViewModel -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody1 -import team.duckie.quackquack.ui.component.QuackBody2 -import team.duckie.quackquack.ui.component.QuackHeadLine1 -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackTitle2 -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.Outlined +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.material.icon.quackicon.outlined.Search +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackBody1 +import team.duckie.quackquack.ui.sugar.QuackTitle2 @Composable internal fun SearchScreen( vm: SearchViewModel, + onSearchComplete: () -> Unit, ) { LaunchedEffect(Unit) { vm.getRecentSearch() @@ -61,7 +64,7 @@ internal fun SearchScreen( .fillMaxSize() .imePadding(), ) { - Spacer(modifier = Modifier.height(22.dp)) + Spacer(modifier = Modifier.height(16.dp)) if (state.recentSearch.isEmpty()) { RecentSearchNotFoundScreen() } else { @@ -75,6 +78,7 @@ internal fun SearchScreen( vm.clearRecentSearch(keyword = keyword) }, navigateToResult = { keyword -> + onSearchComplete() vm.updateSearchKeyword( keyword = keyword, debounce = false, @@ -98,14 +102,18 @@ private fun RecentSearchNotFoundScreen() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - QuackHeadLine1( + QuackText( text = stringResource(id = R.string.no_recent_search), - color = QuackColor.Gray1, + typography = QuackTypography.HeadLine1.change( + color = QuackColor.Gray1, + ), ) Spacer(space = 12.dp) - QuackBody1( + QuackText( text = stringResource(id = R.string.search_favorite_exam), - color = QuackColor.Gray1, + typography = QuackTypography.Body1.change( + color = QuackColor.Gray1, + ), ) } } @@ -125,9 +133,9 @@ private fun LazyListScope.recommendKeywordSection( modifier = Modifier .fillMaxWidth() .height(44.dp) - .quackClickable { - onClickedSearch() - }, + .quackClickable( + onClick = onClickedSearch, + ), contentAlignment = Alignment.CenterStart, ) { QuackTitle2(text = tag?.name ?: "") // TODO(limsaehyun): QuackAnnotationTitle2 교체 필요 @@ -154,12 +162,14 @@ private fun LazyListScope.recentKeywordSection( ) { QuackTitle2(text = stringResource(id = R.string.recent_search)) Spacer(modifier = Modifier.weight(1f)) - QuackBody2( + QuackText( + modifier = Modifier.quackClickable( + onClick = onClickedClearAll, + ), text = stringResource(id = R.string.clear_all), - color = QuackColor.Gray1, - onClick = { - onClickedClearAll() - }, + typography = QuackTypography.Body2.change( + color = QuackColor.Gray1, + ), ) } } @@ -189,19 +199,23 @@ private fun RecentSearchLayout( .padding(SearchHorizontalPadding) .padding(vertical = 12.dp), ) { - QuackImage( - src = QuackIcon.Search, - size = DpSize(16.dp), + QuackIcon( + icon = QuackIcon.Outlined.Search, + size = 16.dp, tint = QuackColor.Gray2, ) Spacer(modifier = Modifier.width(8.dp)) QuackBody1(text = keyword) Spacer(modifier = Modifier.weight(1f)) - QuackImage( - src = QuackIcon.Close, - size = DpSize(16.dp), + QuackIcon( + modifier = Modifier.quackClickable( + onClick = { + onCloseClick(keyword) + }, + ), + icon = QuackIcon.Outlined.Close, + size = 16.dp, tint = QuackColor.Gray2, - onClick = { onCloseClick(keyword) }, ) } } diff --git a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/SearchViewModel.kt b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/SearchViewModel.kt index c9fd10083..16b21e11b 100644 --- a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/SearchViewModel.kt +++ b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/SearchViewModel.kt @@ -7,6 +7,7 @@ package team.duckie.app.android.feature.search.viewmodel +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.LoadState @@ -21,6 +22,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map @@ -31,9 +33,13 @@ import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container import team.duckie.app.android.common.android.ui.const.Debounce +import team.duckie.app.android.common.android.ui.const.Extras +import team.duckie.app.android.common.compose.ui.dialog.ReportAlreadyExists +import team.duckie.app.android.common.kotlin.exception.isReportAlreadyExists import team.duckie.app.android.domain.exam.model.Exam import team.duckie.app.android.domain.follow.model.FollowBody import team.duckie.app.android.domain.follow.usecase.FollowUseCase +import team.duckie.app.android.domain.report.usecase.ReportUseCase import team.duckie.app.android.domain.search.usecase.ClearAllRecentSearchUseCase import team.duckie.app.android.domain.search.usecase.ClearRecentSearchUseCase import team.duckie.app.android.domain.search.usecase.GetRecentSearchUseCase @@ -59,6 +65,8 @@ internal class SearchViewModel @Inject constructor( private val clearRecentSearchUseCase: ClearRecentSearchUseCase, private val followUseCase: FollowUseCase, private val getMeUseCase: GetMeUseCase, + private val reportUseCase: ReportUseCase, + private val savedStateHandle: SavedStateHandle, ) : ContainerHost, ViewModel() { override val container = container(SearchState()) @@ -70,8 +78,18 @@ internal class SearchViewModel @Inject constructor( MutableStateFlow>(PagingData.empty()) val searchUsers: Flow> = _searchUsers + private val _searchText = MutableStateFlow("") + val searchText: StateFlow = _searchText + init { initState() + getAutoFocusing() + } + + private fun getAutoFocusing() = intent { + val autoFocusing = savedStateHandle.getStateFlow(Extras.AutoFocusing, true).value + + reduce { state.copy(searchAutoFocusing = autoFocusing) } } /** [SearchViewModel]의 초기 상태를 설정한다. */ @@ -94,7 +112,7 @@ internal class SearchViewModel @Inject constructor( ).apply { intent { this@apply.debounce(Debounce.SearchSecond).collectLatest { query -> - refreshSearchStep(keyword = state.searchKeyword) + refreshSearchStep(keyword = _searchText.value) // TODO(limsaehyun): 추후 추천 검색어 비즈니스 로직을 이곳에서 작업해야 함 } } @@ -149,6 +167,12 @@ internal class SearchViewModel @Inject constructor( } } + fun updateReportDialogVisible(visible: Boolean) = intent { + reduce { + state.copy(reportDialogVisible = visible) + } + } + /** [keyword]에 따른 덕질고사 검색 결과를 가져온다. */ internal fun fetchSearchExams(keyword: String) { intent { reduce { state.copy(isSearchProblemError = false) } } @@ -178,6 +202,35 @@ internal class SearchViewModel @Inject constructor( } } + fun setTargetExamId(examId: Int) = intent { + reduce { + state.copy(targetExamId = examId) + } + } + + fun report() = intent { + reportUseCase(state.targetExamId) + .onSuccess { + updateReportDialogVisible(true) + postSideEffect(SearchSideEffect.ExamRefresh) + } + .onFailure { exception -> + when { + exception.isReportAlreadyExists -> postSideEffect( + SearchSideEffect.SendToast(ReportAlreadyExists), + ) + + else -> postSideEffect(SearchSideEffect.ReportError(exception)) + } + } + } + + fun copyExamDynamicLink() = intent { + val examId = state.targetExamId + + postSideEffect(SearchSideEffect.CopyDynamicLink(examId)) + } + /** 검색 화면에서 [query] 값에 맞는 검색 결과를 가져온다. */ private suspend fun recommendKeywords(query: String) { _getRecommendKeywords.emit(query) @@ -187,10 +240,9 @@ internal class SearchViewModel @Inject constructor( fun updateSearchKeyword( keyword: String, debounce: Boolean = true, - ) = intent { - reduce { - state.copy(searchKeyword = keyword) - }.run { + ) { + viewModelScope.launch { + _searchText.value = keyword recommendKeywords(query = keyword) if (!debounce) refreshSearchStep(keyword = keyword) } @@ -206,12 +258,12 @@ internal class SearchViewModel @Inject constructor( if (keyword.isEmpty()) { navigateSearchStep( step = SearchStep.Search, - keyword = state.searchKeyword, + keyword = _searchText.value, ) } else { navigateSearchStep( step = SearchStep.SearchResult, - keyword = state.searchKeyword, + keyword = _searchText.value, ) } } diff --git a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/sideeffect/SearchSideEffect.kt b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/sideeffect/SearchSideEffect.kt index 1e579a95f..f7c4a00eb 100644 --- a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/sideeffect/SearchSideEffect.kt +++ b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/sideeffect/SearchSideEffect.kt @@ -19,4 +19,10 @@ internal sealed class SearchSideEffect { class NavigateToDetail(val examId: Int) : SearchSideEffect() class NavigateToUserProfile(val userId: Int) : SearchSideEffect() + + class SendToast(val message: String) : SearchSideEffect() + + object ExamRefresh : SearchSideEffect() + + class CopyDynamicLink(val examId: Int) : SearchSideEffect() } diff --git a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/state/SearchState.kt b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/state/SearchState.kt index b1ea923d6..fce65e535 100644 --- a/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/state/SearchState.kt +++ b/feature/search/src/main/kotlin/team/duckie/app/android/feature/search/viewmodel/state/SearchState.kt @@ -7,6 +7,7 @@ package team.duckie.app.android.feature.search.viewmodel.state +import androidx.compose.runtime.Immutable import androidx.paging.PagingData import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -27,6 +28,7 @@ import team.duckie.app.android.feature.search.constants.SearchStep * [searchKeyword] 검색어 * [tagSelectedTab] 검색 결과에서 선택된 탭 */ +@Immutable internal data class SearchState( val me: User? = null, val isSearchLoading: Boolean = false, @@ -35,8 +37,11 @@ internal data class SearchState( val searchStep: SearchStep = SearchStep.Search, val recentSearch: ImmutableList = persistentListOf(), val recommendSearchs: Flow> = flow { PagingData.empty() }, - val searchKeyword: String = "", val tagSelectedTab: SearchResultStep = SearchResultStep.DuckExam, + val targetExamId: Int = 0, + val bottomSheetVisible: Boolean = false, + val reportDialogVisible: Boolean = false, + val searchAutoFocusing: Boolean = false, ) { data class SearchUser( val userId: Int, diff --git a/feature/setting/build.gradle.kts b/feature/setting/build.gradle.kts index 4fa518d00..c754a1f31 100644 --- a/feature/setting/build.gradle.kts +++ b/feature/setting/build.gradle.kts @@ -41,7 +41,7 @@ dependencies { projects.feature.devMode, libs.orbit.viewmodel, libs.orbit.compose, - libs.quack.ui.components, + libs.kotlin.collections.immutable, libs.quack.v2.ui, libs.compose.lifecycle.runtime, libs.compose.ui.accompanist.webview, diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/component/SettingContentLayout.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/component/SettingContentLayout.kt index a8e8f77c8..fb6333187 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/component/SettingContentLayout.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/component/SettingContentLayout.kt @@ -10,50 +10,52 @@ package team.duckie.app.android.feature.setting.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.util.fillMaxScreenWidth import team.duckie.app.android.feature.setting.constans.SettingDesignToken +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.ui.QuackText -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.sugar.QuackBody1 -import team.duckie.quackquack.ui.sugar.QuackTitle2 @Composable internal fun SettingContentLayout( + modifier: Modifier = Modifier, title: String, content: String? = null, trailingText: String? = null, onTrailingTextClick: (() -> Unit)? = null, - isBold: Boolean, + typography: QuackTypography = QuackTypography.Title2, + horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween, onClick: (() -> Unit)? = null, ) = with(SettingDesignToken) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .height(44.dp) + .fillMaxScreenWidth() .quackClickable( - rippleEnabled = false, - ) { - if (onClick != null) { - onClick() - } - }, + rippleEnabled = true, + onClick = { + if (onClick != null) { + onClick() + } + }, + ) + .padding( + horizontal = 16.dp, + vertical = 12.dp, + ), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = horizontalArrangement, ) { - if (isBold) { - QuackTitle2( - text = title, - ) - } else { - QuackBody1( - text = title, - ) - } + QuackText( + text = title, + typography = typography, + ) if (content != null) { QuackText( modifier = Modifier.padding(start = 12.dp), @@ -61,6 +63,7 @@ internal fun SettingContentLayout( typography = SettingHorizontalResultTypography, ) } + Spacer(weight = 1f) if (trailingText != null) { QuackText( modifier = Modifier diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/constans/SettingType.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/constans/SettingType.kt index 1638d8d61..33cdd071e 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/constans/SettingType.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/constans/SettingType.kt @@ -12,9 +12,12 @@ import kotlinx.collections.immutable.persistentListOf import team.duckie.app.android.feature.setting.R import team.duckie.app.android.feature.setting.constans.SettingType.AccountInfo import team.duckie.app.android.feature.setting.constans.SettingType.Inquiry +import team.duckie.app.android.feature.setting.constans.SettingType.ListOfIgnoreExam +import team.duckie.app.android.feature.setting.constans.SettingType.ListOfIgnoreUser import team.duckie.app.android.feature.setting.constans.SettingType.Main import team.duckie.app.android.feature.setting.constans.SettingType.MainPolicy import team.duckie.app.android.feature.setting.constans.SettingType.Notification +import team.duckie.app.android.feature.setting.constans.SettingType.PrivacyPolicy import team.duckie.app.android.feature.setting.constans.SettingType.Version /** @@ -31,6 +34,12 @@ enum class SettingType( @StringRes val titleRes: Int, ) { + ListOfIgnoreUser( + titleRes = R.string.list_of_ignore_user, + ), + ListOfIgnoreExam( + titleRes = R.string.list_of_ignore_exam, + ), Main( titleRes = R.string.app_setting, ), @@ -67,16 +76,16 @@ enum class SettingType( PrivacyPolicy, ) - /** [AccountInfo] 안에 위치한 설정 */ - private val accountInfoPages = persistentListOf( - WithDraw, + val userSettings = persistentListOf( + AccountInfo, + ListOfIgnoreUser, + ListOfIgnoreExam, ) - /** 메인 설정 페이지에 표시될 설정 */ - val settingPages = SettingType - .values() - .filter { it !in listOf(Main) } - .filter { it !in policyPages } - .filter { it !in accountInfoPages } + val otherSettings = persistentListOf( + Inquiry, + MainPolicy, + Version, + ) } } diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/constans/Withdraweason.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/constans/Withdraweason.kt index a0c2e720e..6e1543164 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/constans/Withdraweason.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/constans/Withdraweason.kt @@ -40,7 +40,7 @@ internal enum class Withdraweason( /** 기타 */ OTHERS( - description = R.string.withdraw_others, + description = R.string.others, ), ; diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingAccountInfoScreen.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingAccountInfoScreen.kt index b1886f905..00bbcfa8f 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingAccountInfoScreen.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingAccountInfoScreen.kt @@ -7,40 +7,29 @@ package team.duckie.app.android.feature.setting.screen -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import team.duckie.app.android.common.compose.ui.QuackMaxWidthDivider -import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.feature.setting.R import team.duckie.app.android.feature.setting.component.SettingContentLayout import team.duckie.app.android.feature.setting.constans.SettingDesignToken -import team.duckie.quackquack.ui.QuackImage -import team.duckie.quackquack.ui.QuackText -import team.duckie.quackquack.ui.sugar.QuackBody1 +import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.ui.sugar.QuackSubtitle2 -private val KakaoColor: Color = Color(0xFFFEE500) +// private val KakaoColor: Color = Color(0xFFFEE500) +@Suppress("UnusedPrivateMember") // TODO(limsaehyun) 추후 이메일 작업 필요 @Composable fun SettingAccountInfoScreen( email: String, @@ -61,36 +50,37 @@ fun SettingAccountInfoScreen( modifier = Modifier.padding(vertical = 12.dp), text = stringResource(id = R.string.sign_in_account), ) - Row( - modifier = Modifier - .fillMaxWidth() - .height(44.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - QuackBody1( - text = stringResource(id = R.string.email), - ) - Spacer(space = 12.dp) - Box( - modifier = Modifier - .size(18.dp) - .background( - color = KakaoColor, - shape = RoundedCornerShape(2.dp), - ), - contentAlignment = Alignment.Center, - ) { - QuackImage( - modifier = Modifier.size(12.dp, 10.dp), - src = R.drawable.ic_setting_kakao, - ) - } - Spacer(space = 4.dp) - QuackText( - text = email, - typography = SettingHorizontalResultTypography, - ) - } +// 이메일 로직: 필요시 주석 해제 후 사용 +// Row( +// modifier = Modifier +// .fillMaxWidth() +// .height(44.dp), +// verticalAlignment = Alignment.CenterVertically, +// ) { +// QuackBody1( +// text = stringResource(id = R.string.email), +// ) +// Spacer(space = 12.dp) +// Box( +// modifier = Modifier +// .size(18.dp) +// .background( +// color = KakaoColor, +// shape = RoundedCornerShape(2.dp), +// ), +// contentAlignment = Alignment.Center, +// ) { +// QuackImage( +// modifier = Modifier.size(12.dp, 10.dp), +// src = R.drawable.ic_setting_kakao, +// ) +// } +// Spacer(space = 4.dp) +// QuackText( +// text = email, +// typography = SettingHorizontalResultTypography, +// ) +// } QuackMaxWidthDivider(modifier = Modifier.padding(vertical = 16.dp)) LazyColumn( verticalArrangement = Arrangement.spacedBy(4.dp), @@ -98,8 +88,8 @@ fun SettingAccountInfoScreen( items(rememberAccountInfoItems) { index -> SettingContentLayout( title = stringResource(id = index.first), - isBold = false, onClick = index.second, + typography = QuackTypography.Body1, ) } } diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingActivity.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingActivity.kt index 4d196cd26..3161bd410 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingActivity.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingActivity.kt @@ -34,6 +34,7 @@ import team.duckie.app.android.common.android.ui.finishWithAnimation import team.duckie.app.android.common.android.ui.startActivityWithAnimation import team.duckie.app.android.common.compose.ToastWrapper import team.duckie.app.android.common.compose.systemBarPaddings +import team.duckie.app.android.common.compose.ui.BackPressedHeadLine2TopAppBar import team.duckie.app.android.common.compose.ui.DuckieTodoScreen import team.duckie.app.android.common.compose.ui.dialog.DuckieDialog import team.duckie.app.android.common.compose.ui.quack.QuackCrossfade @@ -47,8 +48,6 @@ import team.duckie.app.android.feature.setting.viewmodel.state.SettingState import team.duckie.app.android.navigator.feature.intro.IntroNavigator import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.theme.QuackTheme -import team.duckie.quackquack.ui.component.QuackTopAppBar -import team.duckie.quackquack.ui.icon.QuackIcon import javax.inject.Inject @AndroidEntryPoint @@ -84,13 +83,9 @@ class SettingActivity : BaseActivity() { .background(QuackColor.White.value) .padding(systemBarPaddings), ) { - QuackTopAppBar( - leadingIcon = QuackIcon.ArrowBack, - leadingText = stringResource(id = state.settingType.titleRes), - onLeadingIconClick = { - vm.navigateBack() - }, - ) + BackPressedHeadLine2TopAppBar(title = stringResource(id = state.settingType.titleRes)) { + vm.navigateBack() + } Column( modifier = Modifier .padding( @@ -138,6 +133,16 @@ class SettingActivity : BaseActivity() { state = state, ) + SettingType.ListOfIgnoreUser -> SettingIgnoreUserScreen( + state = state, + vm = vm, + ) + + SettingType.ListOfIgnoreExam -> SettingIgnoreExamScreen( + vm = vm, + state = state, + ) + else -> Unit } } diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingIgnoreExamScreen.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingIgnoreExamScreen.kt new file mode 100644 index 000000000..aafced5f8 --- /dev/null +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingIgnoreExamScreen.kt @@ -0,0 +1,63 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.feature.setting.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import team.duckie.app.android.common.compose.ui.content.ExamIgnoreLayout +import team.duckie.app.android.feature.setting.R +import team.duckie.app.android.feature.setting.viewmodel.SettingViewModel +import team.duckie.app.android.feature.setting.viewmodel.state.SettingState +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText + +@Composable +internal fun SettingIgnoreExamScreen( + vm: SettingViewModel, + state: SettingState, +) { + LaunchedEffect(key1 = Unit) { + vm.getIgnoreExams() + } + + if (state.ignoreExams.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + QuackText( + text = stringResource(id = R.string.setting_ignore_exam_not_found), + typography = QuackTypography.HeadLine1.change( + color = QuackColor.DuckieOrange, + ), + ) + } + } else { + LazyColumn { + items(state.ignoreExams) { item -> + ExamIgnoreLayout( + examId = item.id, + examThumbnailUrl = item.thumbnailUrl, + name = item.title, + onClickTrailingButton = { examId -> + vm.cancelIgnoreExam(examId = examId) + }, + rippleEnabled = false, + ) + } + } + } +} diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingIgnoreUserScreen.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingIgnoreUserScreen.kt new file mode 100644 index 000000000..4abf51365 --- /dev/null +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingIgnoreUserScreen.kt @@ -0,0 +1,65 @@ +/* + * Designed and developed by Duckie Team, 2022 + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE + */ + +package team.duckie.app.android.feature.setting.screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import team.duckie.app.android.common.compose.ui.content.UserIgnoreLayout +import team.duckie.app.android.feature.setting.R +import team.duckie.app.android.feature.setting.viewmodel.SettingViewModel +import team.duckie.app.android.feature.setting.viewmodel.state.SettingState +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText + +@Composable +internal fun SettingIgnoreUserScreen( + vm: SettingViewModel, + state: SettingState, +) { + LaunchedEffect(key1 = Unit) { + vm.getIgnoreUsers() + } + + if (state.ignoreUsers.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + QuackText( + text = stringResource(id = R.string.setting_ignore_user_not_found), + typography = QuackTypography.HeadLine1.change( + color = QuackColor.DuckieOrange, + ), + ) + } + } else { + LazyColumn { + items(state.ignoreUsers) { item -> + UserIgnoreLayout( + userId = item.id, + profileImageIrl = item.profileImageUrl, + nickname = item.nickName, + favoriteTag = item.duckPower?.tag?.name ?: "", + tier = item.duckPower?.tier ?: "", + onClickTrailingButton = { userId -> + vm.cancelIgnoreUser(userId) + }, + rippleEnabled = false, + ) + } + } + } +} diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingInquiryScreen.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingInquiryScreen.kt index d1a5e08bd..229f38ab9 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingInquiryScreen.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingInquiryScreen.kt @@ -22,6 +22,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import team.duckie.app.android.feature.setting.R import team.duckie.app.android.feature.setting.component.SettingContentLayout +import team.duckie.quackquack.material.QuackTypography import team.duckie.quackquack.ui.sugar.QuackSubtitle2 /** @@ -57,7 +58,8 @@ fun SettingInquiryScreen() { SettingContentLayout( title = stringResource(id = item.first), content = item.second, - isBold = false, + typography = QuackTypography.Body1, + horizontalArrangement = Arrangement.Start, ) } } diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingMainPolicyScreen.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingMainPolicyScreen.kt index 73c8cd4f5..6308912c9 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingMainPolicyScreen.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingMainPolicyScreen.kt @@ -20,6 +20,7 @@ import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.feature.setting.R import team.duckie.app.android.feature.setting.component.SettingContentLayout import team.duckie.app.android.feature.setting.constans.SettingType +import team.duckie.quackquack.material.QuackTypography @Composable fun SettingMainPolicyScreen( @@ -36,7 +37,7 @@ fun SettingMainPolicyScreen( items(SettingType.policyPages) { page -> SettingContentLayout( title = stringResource(id = page.titleRes), - isBold = false, + typography = QuackTypography.Body1, ) { navigatePage(page) } @@ -46,7 +47,7 @@ fun SettingMainPolicyScreen( title = stringResource( id = R.string.open_source_license, ), - isBold = false, + typography = QuackTypography.Body1, ) { navigateOssLicense() } diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingMainScreen.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingMainScreen.kt index cff063028..4006ce241 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingMainScreen.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingMainScreen.kt @@ -7,32 +7,68 @@ package team.duckie.app.android.feature.setting.screen +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.rememberToast +import team.duckie.app.android.common.compose.ui.QuackMaxWidthDivider +import team.duckie.app.android.feature.setting.R import team.duckie.app.android.feature.setting.component.SettingContentLayout import team.duckie.app.android.feature.setting.constans.SettingType import team.duckie.app.android.feature.setting.viewmodel.SettingViewModel +import team.duckie.quackquack.material.QuackTypography @Composable internal fun SettingMainScreen( vm: SettingViewModel, version: String, ) { - val settingItems = SettingType.settingPages + val userSettings = SettingType.userSettings + val otherSettings = SettingType.otherSettings + val toast = rememberToast() LazyColumn { - items(settingItems) { item -> + titleSection(title = R.string.user_settings) + items(userSettings) { item -> SettingContentLayout( title = stringResource(id = item.titleRes), + onClick = { vm.navigateStep(item) }, + typography = QuackTypography.Body1, + ) + } + item { + Spacer(modifier = Modifier.height(12.dp)) + QuackMaxWidthDivider() + SettingContentLayout( + modifier = Modifier + .padding(vertical = 12.dp), + title = stringResource(id = R.string.notification), + typography = QuackTypography.Body1, + onClick = { + toast.invoke("개발 중인 기능입니다!") // TODO(limsaehyun) 알림 develop 필요 + }, + ) + QuackMaxWidthDivider() + } + titleSection(title = R.string.others) + items(otherSettings) { item -> + SettingContentLayout( + title = stringResource(id = item.titleRes), + typography = QuackTypography.Body1, trailingText = if (item == SettingType.Version) version else null, - onTrailingTextClick = if (item == SettingType.Version) { - { + onTrailingTextClick = { + if (item == SettingType.Version) { vm.changeDevModeDialogVisible(true) } - } else { - null }, onClick = { when (item) { @@ -45,8 +81,18 @@ internal fun SettingMainScreen( } } }, - isBold = true, ) } } } + +internal fun LazyListScope.titleSection( + @StringRes title: Int, +) { + item { + SettingContentLayout( + title = LocalContext.current.getString(title), + typography = QuackTypography.Title2, + ) + } +} diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingNotificationScreen.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingNotificationScreen.kt index 03f5aa9dc..6dfbf3f00 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingNotificationScreen.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingNotificationScreen.kt @@ -4,6 +4,7 @@ * Licensed under the MIT. * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:Suppress("UnusedPrivateMember") package team.duckie.app.android.feature.setting.screen @@ -21,10 +22,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import team.duckie.app.android.feature.setting.constans.SettingNotificationType import team.duckie.app.android.feature.setting.constans.SettingDesignToken +import team.duckie.app.android.feature.setting.constans.SettingNotificationType import team.duckie.quackquack.ui.QuackText -import team.duckie.quackquack.ui.component.QuackSwitch import team.duckie.quackquack.ui.sugar.QuackBody1 /** @@ -51,6 +51,7 @@ fun SettingNotificationScreen() { } } +@Suppress("unused") @Composable private fun SettingNotificationLayout( title: String, @@ -75,9 +76,10 @@ private fun SettingNotificationLayout( ) } Spacer(modifier = Modifier.weight(1f)) - QuackSwitch( - checked = checked, - onCheckedChange = onCheckedChange, - ) +// QuackSwitch( +// checked = checked, +// onCheckedChange = onCheckedChange, +// ) +// TODO(limsaehyun) [QuackQuack] Switch 작업 필요! } } diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingWithdrawScreen.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingWithdrawScreen.kt index 0ced38060..1dbe58b55 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingWithdrawScreen.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/screen/SettingWithdrawScreen.kt @@ -5,11 +5,15 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalQuackQuackApi::class) + package team.duckie.app.android.feature.setting.screen import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -30,20 +34,23 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.ui.quack.todo.QuackReactionTextArea +import team.duckie.app.android.common.compose.ui.quack.todo.animation.QuackRoundCheckBox +import team.duckie.app.android.common.kotlin.runIf import team.duckie.app.android.feature.setting.R import team.duckie.app.android.feature.setting.constans.Withdraweason import team.duckie.app.android.feature.setting.viewmodel.SettingViewModel import team.duckie.app.android.feature.setting.viewmodel.state.SettingState -import team.duckie.quackquack.animation.QuackAnimatedVisibility +import team.duckie.quackquack.animation.animateQuackColorAsState import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable import team.duckie.quackquack.ui.QuackImage -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType -import team.duckie.quackquack.ui.component.QuackReviewTextArea -import team.duckie.quackquack.ui.component.QuackRoundCheckBox -import team.duckie.quackquack.ui.modifier.quackClickable +import team.duckie.quackquack.ui.QuackText import team.duckie.quackquack.ui.sugar.QuackBody1 import team.duckie.quackquack.ui.sugar.QuackHeadLine2 +import team.duckie.quackquack.ui.sugar.QuackSubtitle +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi @Composable internal fun SettingWithdrawScreen( @@ -92,8 +99,8 @@ internal fun SettingWithdrawScreen( ) } item { - QuackAnimatedVisibility(visible = state.withdrawReasonSelected == Withdraweason.OTHERS) { - QuackReviewTextArea( + AnimatedVisibility(visible = state.withdrawReasonSelected == Withdraweason.OTHERS) { + QuackReactionTextArea( modifier = Modifier .padding(top = 4.dp) .height(140.dp) @@ -101,15 +108,14 @@ internal fun SettingWithdrawScreen( .onFocusChanged { state -> vm.updateWithDrawFocus(state.isFocused) }, - text = state.withdrawUserInputReason, - onTextChanged = { text -> + reaction = state.withdrawUserInputReason, + onReactionChanged = { text -> vm.updateWithdrawUserInputReason(text) }, - focused = state.withdrawIsFocused, - placeholderText = stringResource( - // TODO(limsaehyun): placeholder의 maxline을 설정할 수 있어야 함 + placeHolderText = stringResource( id = R.string.withdraw_others_text_field_hint, ), + visibleCurrentLength = false, ) } } @@ -124,20 +130,49 @@ internal fun SettingWithdrawScreen( .weight(1f) .height(44.dp) - QuackLargeButton( - modifier = buttonModifier, - type = QuackLargeButtonType.Border, - text = stringResource(id = R.string.withdraw_cancel_msg), - onClick = vm::navigateBack, - ) + val buttonEnabled = state.withdrawReasonSelected != Withdraweason.INITIAL + + val primaryButtonColor = + animateQuackColorAsState(targetValue = if (buttonEnabled) QuackColor.DuckieOrange else QuackColor.Gray2) + + Box( + modifier = buttonModifier + .background( + color = QuackColor.White.value, + shape = RoundedCornerShape(8.dp), + ) + .border( + width = 1.dp, + shape = RoundedCornerShape(8.dp), + color = QuackColor.Gray3.value, + ) + .quackClickable( + onClick = vm::navigateBack, + ), + contentAlignment = Alignment.Center, + ) { + QuackSubtitle(text = stringResource(id = R.string.withdraw_cancel_msg)) + } Spacer(space = 8.dp) - QuackLargeButton( - modifier = buttonModifier, - type = QuackLargeButtonType.Fill, - text = stringResource(id = R.string.withdraw), - enabled = state.withdrawReasonSelected != Withdraweason.INITIAL, + Box( + modifier = buttonModifier + .background( + color = primaryButtonColor.value.value, + shape = RoundedCornerShape(8.dp), + ) + .runIf(buttonEnabled) { + quackClickable( + onClick = { vm.changeWithdrawDialogVisible(true) }, + ) + }, + contentAlignment = Alignment.Center, ) { - vm.changeWithdrawDialogVisible(true) + QuackText( + text = stringResource(id = R.string.withdraw), + typography = QuackTypography.Subtitle.change( + color = QuackColor.White, + ), + ) } } } @@ -157,9 +192,11 @@ internal fun SettingCheckBox( Row( modifier = modifier .fillMaxWidth() - .quackClickable { - onClick(reason) - } + .quackClickable( + onClick = { + onClick(reason) + }, + ) .clip(RoundedCornerShape(8.dp)) .background( color = QuackColor.Gray4.value, diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/viewmodel/SettingViewModel.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/viewmodel/SettingViewModel.kt index 776d76ccd..2b24df1c6 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/viewmodel/SettingViewModel.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/viewmodel/SettingViewModel.kt @@ -9,6 +9,7 @@ package team.duckie.app.android.feature.setting.viewmodel import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.syntax.simple.intent import org.orbitmvi.orbit.syntax.simple.postSideEffect @@ -16,7 +17,11 @@ import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container import team.duckie.app.android.common.kotlin.seconds import team.duckie.app.android.domain.auth.usecase.ClearTokenUseCase +import team.duckie.app.android.domain.exam.usecase.CancelExamIgnoreUseCase +import team.duckie.app.android.domain.exam.usecase.GetExamIgnoresUseCase +import team.duckie.app.android.domain.ignore.usecase.CancelUserIgnoreUseCase import team.duckie.app.android.domain.me.usecase.GetIsStageUseCase +import team.duckie.app.android.domain.user.usecase.FetchIgnoreUsersUseCase import team.duckie.app.android.domain.user.usecase.GetMeUseCase import team.duckie.app.android.feature.setting.constans.SettingType import team.duckie.app.android.feature.setting.constans.SettingType.Companion.policyPages @@ -33,6 +38,10 @@ internal class SettingViewModel @Inject constructor( private val getMeUseCase: GetMeUseCase, private val getIsStageUseCase: GetIsStageUseCase, private val clearTokenUseCase: ClearTokenUseCase, + private val fetchIgnoreUsers: FetchIgnoreUsersUseCase, + private val cancelUserIgnoreUseCase: CancelUserIgnoreUseCase, + private val getIgnoreExamsUseCase: GetExamIgnoresUseCase, + private val cancelExamIgnoreUseCase: CancelExamIgnoreUseCase, ) : ContainerHost, ViewModel() { override val container = container(SettingState()) @@ -58,6 +67,48 @@ internal class SettingViewModel @Inject constructor( } } + fun getIgnoreExams() = intent { + getIgnoreExamsUseCase() + .onSuccess { exams -> + reduce { state.copy(ignoreExams = exams.toImmutableList()) } + } + .onFailure { + postSideEffect(SettingSideEffect.ReportError(it)) + } + } + + fun cancelIgnoreExam(examId: Int) = intent { + cancelExamIgnoreUseCase(examId = examId) + .onSuccess { + val ignoreExams = state.ignoreExams.filter { it.id != examId }.toImmutableList() + reduce { state.copy(ignoreExams = ignoreExams) } + } + .onFailure { + postSideEffect(SettingSideEffect.ReportError(it)) + } + } + + fun cancelIgnoreUser(userId: Int) = intent { + cancelUserIgnoreUseCase(targetId = userId) + .onSuccess { + val ignoreUsers = state.ignoreUsers.filter { it.id != userId }.toImmutableList() + reduce { state.copy(ignoreUsers = ignoreUsers) } + } + .onFailure { + postSideEffect(SettingSideEffect.ReportError(it)) + } + } + + fun getIgnoreUsers() = intent { + fetchIgnoreUsers() + .onSuccess { users -> + reduce { state.copy(ignoreUsers = users) } + } + .onFailure { + postSideEffect(SettingSideEffect.ReportError(it)) + } + } + fun updateWithdrawReason(reason: Withdraweason) = intent { reduce { state.copy(withdrawReasonSelected = reason) } } diff --git a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/viewmodel/state/SettingState.kt b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/viewmodel/state/SettingState.kt index 528820cac..92fc5ecc4 100644 --- a/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/viewmodel/state/SettingState.kt +++ b/feature/setting/src/main/kotlin/team/duckie/app/android/feature/setting/viewmodel/state/SettingState.kt @@ -7,10 +7,16 @@ package team.duckie.app.android.feature.setting.viewmodel.state +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import team.duckie.app.android.domain.exam.model.IgnoreExam +import team.duckie.app.android.domain.user.model.IgnoreUser import team.duckie.app.android.domain.user.model.User import team.duckie.app.android.feature.setting.constans.SettingType import team.duckie.app.android.feature.setting.constans.Withdraweason +@Immutable internal data class SettingState( val me: User? = null, val isStage: Boolean = false, @@ -24,4 +30,7 @@ internal data class SettingState( val withdrawReasonSelected: Withdraweason = Withdraweason.INITIAL, val withdrawUserInputReason: String = "", val withdrawIsFocused: Boolean = false, + + val ignoreUsers: ImmutableList = persistentListOf(), + val ignoreExams: ImmutableList = persistentListOf(), ) diff --git a/feature/setting/src/main/res/values/strings.xml b/feature/setting/src/main/res/values/strings.xml index 8af0142f4..0bb093744 100644 --- a/feature/setting/src/main/res/values/strings.xml +++ b/feature/setting/src/main/res/values/strings.xml @@ -22,12 +22,17 @@ 앗, 정말 로그아웃 하시겠어요? 정말 탈퇴하시겠어요? 안 할래요 + 차단유저 목록 + 차단시험 목록 + + 사용자 설정 로그인 계정 이메일 문의처 인스타그램 취소 + 기타 정말 탈퇴하시겠어요?\n%s님과 이별하려니 너무 아쉬워요.. 계정을 삭제하면 개인정보 및 출제한 덕력고사, 관심 등 모든 활동 정보가 삭제돼요. @@ -38,7 +43,6 @@ 자주 사용하지 않는 앱이에요. 잦은 오류가 발생해서 쓸 수가 없어요. 새 계정으로 가입하려구요. - 기타 어떤 점이 불편하셨나요?\n다시 덕키를 방문했을 때, 더 나은 덕키가 될 수 있도록 노력할게요! 조금 더 이용하기 @@ -51,6 +55,9 @@ 명예의 전당 공지사항 + 차단한 유저가 없습니다. + 차단한 덕력고사가 없습니다. + 내가 출제한 덕력고사 및 문제에 대한 알림 내 덕력고사에 다른 유저가 좋아요를 눌렀을 때의 알림 다른 유저가 나를 팔로우했을 때의 알림 diff --git a/feature/solve-problem/build.gradle.kts b/feature/solve-problem/build.gradle.kts index 6d70e9dbd..f00e1e72d 100644 --- a/feature/solve-problem/build.gradle.kts +++ b/feature/solve-problem/build.gradle.kts @@ -26,12 +26,12 @@ dependencies { projects.common.kotlin, projects.common.compose, projects.common.android, + libs.kotlin.collections.immutable, libs.orbit.viewmodel, libs.orbit.compose, libs.ktx.lifecycle.runtime, libs.compose.lifecycle.runtime, libs.compose.ui.material, // needs for Scaffold - libs.quack.ui.components, libs.quack.v2.ui, libs.firebase.crashlytics, libs.exoplayer.core, diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/SolveProblemActivity.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/SolveProblemActivity.kt index 019dbca18..7a06b2a67 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/SolveProblemActivity.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/SolveProblemActivity.kt @@ -35,6 +35,7 @@ import team.duckie.app.android.common.android.ui.finishWithAnimation import team.duckie.app.android.common.compose.moveNextPage import team.duckie.app.android.common.compose.ui.ErrorScreen import team.duckie.app.android.common.compose.ui.quack.QuackCrossfade +import team.duckie.app.android.common.compose.util.addFocusCleaner import team.duckie.app.android.domain.quiz.usecase.SubmitQuizUseCase import team.duckie.app.android.feature.solve.problem.common.LoadingIndicator import team.duckie.app.android.feature.solve.problem.screen.QuizScreen @@ -42,8 +43,8 @@ import team.duckie.app.android.feature.solve.problem.screen.SolveProblemScreen import team.duckie.app.android.feature.solve.problem.viewmodel.SolveProblemViewModel import team.duckie.app.android.feature.solve.problem.viewmodel.sideeffect.SolveProblemSideEffect import team.duckie.app.android.navigator.feature.examresult.ExamResultNavigator -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.theme.QuackTheme import javax.inject.Inject @AndroidEntryPoint @@ -60,7 +61,9 @@ class SolveProblemActivity : BaseActivity() { QuackTheme { val state by viewModel.collectAsState() val progress by viewModel.timerCount.collectAsStateWithLifecycle() - val pagerState = rememberPagerState() + val pagerState = rememberPagerState( + pageCount = { state.totalPage }, + ) LaunchedEffect(viewModel.container.sideEffectFlow) { viewModel.container.sideEffectFlow.collect { sideEffect -> @@ -74,7 +77,8 @@ class SolveProblemActivity : BaseActivity() { QuackCrossfade( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor) + .background(color = QuackColor.White.value) + .addFocusCleaner() .systemBarsPadding() .navigationBarsPadding() .imePadding(), diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/AnswerSection.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/AnswerSection.kt index e7c839837..63d6b3c0e 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/AnswerSection.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/AnswerSection.kt @@ -18,10 +18,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import team.duckie.app.android.common.compose.ui.DuckieGridLayout +import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.domain.exam.model.Answer import team.duckie.app.android.feature.solve.problem.answer.choice.ImageAnswerBox import team.duckie.app.android.feature.solve.problem.answer.choice.TextAnswerBox @@ -30,6 +33,10 @@ import team.duckie.app.android.feature.solve.problem.viewmodel.state.InputAnswer private val HorizontalPadding = PaddingValues(horizontal = 16.dp) +internal object TextFieldMargin { + val Top = 16.dp +} + @Composable internal fun ColumnScope.AnswerSection( pageIndex: Int, @@ -38,11 +45,14 @@ internal fun ColumnScope.AnswerSection( updateInputAnswers: (page: Int, inputAnswer: InputAnswer) -> Unit, requestFocus: Boolean, keyboardController: SoftwareKeyboardController?, + onShortAnswerSizeChanged: (IntSize) -> Unit, ) { when (answer) { is Answer.Choice -> { Column( - modifier = Modifier.padding(paddingValues = HorizontalPadding), + modifier = Modifier + .padding(vertical = 24.dp) + .padding(paddingValues = HorizontalPadding), verticalArrangement = Arrangement.spacedBy(space = 12.dp), ) { answer.choices.forEachIndexed { index, choice -> @@ -91,7 +101,9 @@ internal fun ColumnScope.AnswerSection( } is Answer.Short -> { + Spacer(space = TextFieldMargin.Top) ShortAnswerForm( + modifier = Modifier.onSizeChanged(onShortAnswerSizeChanged), answer = answer.correctAnswer, onTextChanged = { inputText -> updateInputAnswers(pageIndex, InputAnswer(0, inputText)) diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/choice/AnswerBox.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/choice/AnswerBox.kt index ea062c967..640b36aca 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/choice/AnswerBox.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/choice/AnswerBox.kt @@ -7,7 +7,10 @@ package team.duckie.app.android.feature.solve.problem.answer.choice +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -20,17 +23,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import team.duckie.quackquack.ui.animation.QuackAnimatedVisibility -import team.duckie.quackquack.ui.border.QuackBorder -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackSurface -import team.duckie.quackquack.ui.component.internal.QuackText -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.textstyle.QuackTextStyle -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.Outlined +import team.duckie.quackquack.material.icon.quackicon.outlined.Check +import team.duckie.quackquack.material.quackBorder +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackText @Composable internal fun TextAnswerBox( @@ -103,7 +109,7 @@ private fun TextAndCheck( ) { QuackText( text = text, - style = QuackTextStyle.Body1.change( + typography = QuackTypography.Body1.change( color = when (selected) { true -> QuackColor.DuckieOrange else -> QuackColor.Black @@ -111,11 +117,11 @@ private fun TextAndCheck( textAlign = TextAlign.Start, ), ) - QuackAnimatedVisibility(visible = selected) { - QuackImage( - src = QuackIcon.Check, + AnimatedVisibility(visible = selected) { + QuackIcon( + icon = QuackIcon.Outlined.Check, tint = QuackColor.DuckieOrange, - size = DpSize(all = 18.dp), + size = 18.dp, ) } } @@ -128,12 +134,17 @@ private fun GraySurface( onClick: () -> Unit, content: @Composable (BoxScope.() -> Unit), ) { - QuackSurface( - modifier = modifier.fillMaxWidth(), - backgroundColor = QuackColor.Gray4, - border = setBoxBorder(selected = selected), - shape = RoundedCornerShape(size = 8.dp), - onClick = onClick, + val shape = RoundedCornerShape(size = 8.dp) + Box( + modifier = modifier + .fillMaxWidth() + .clip(shape = shape) + .background(color = QuackColor.Gray4.value) + .quackClickable(onClick = onClick) + .quackBorder( + border = setBoxBorder(selected = selected), + shape = shape, + ), content = content, ) } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/shortanswer/ShortAnswerForm.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/shortanswer/ShortAnswerForm.kt index 432e7b893..eeb873195 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/shortanswer/ShortAnswerForm.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/answer/shortanswer/ShortAnswerForm.kt @@ -43,7 +43,7 @@ import androidx.compose.ui.unit.dp import team.duckie.quackquack.material.QuackBorder import team.duckie.quackquack.material.QuackColor import team.duckie.quackquack.material.quackBorder -import team.duckie.quackquack.ui.component.QuackBody1 +import team.duckie.quackquack.ui.sugar.QuackBody1 @OptIn(ExperimentalLayoutApi::class) @Composable diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/BottomBar.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/BottomBar.kt index 62bf17a89..94288b737 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/BottomBar.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/BottomBar.kt @@ -7,6 +7,7 @@ package team.duckie.app.android.feature.solve.problem.common +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -17,16 +18,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import team.duckie.app.android.common.compose.ui.QuackMaxWidthDivider +import team.duckie.app.android.common.compose.ui.quack.todo.QuackSurface import team.duckie.app.android.feature.solve.problem.R -import team.duckie.quackquack.ui.animation.QuackAnimatedContent -import team.duckie.quackquack.ui.border.QuackBorder -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackDivider -import team.duckie.quackquack.ui.component.QuackSurface -import team.duckie.quackquack.ui.component.internal.QuackText -import team.duckie.quackquack.ui.textstyle.QuackTextStyle +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackBorder +import team.duckie.quackquack.ui.QuackText @Composable internal fun ButtonBottomBar( @@ -38,7 +38,7 @@ internal fun ButtonBottomBar( Column( modifier = modifier, ) { - QuackDivider() + QuackMaxWidthDivider() Row( modifier = Modifier .fillMaxWidth() @@ -49,7 +49,10 @@ internal fun ButtonBottomBar( horizontalArrangement = Arrangement.SpaceBetween, ) { Spacer(modifier = Modifier) - QuackAnimatedContent(targetState = isLastPage) { + AnimatedContent( + targetState = isLastPage, + label = "AnimatedContent", + ) { when (it) { false -> MediumButton( text = stringResource(id = R.string.next), @@ -86,7 +89,7 @@ internal fun DoubleButtonBottomBar( Column( modifier = modifier, ) { - QuackDivider() + QuackMaxWidthDivider() Row( modifier = Modifier .fillMaxWidth() @@ -96,7 +99,10 @@ internal fun DoubleButtonBottomBar( ), horizontalArrangement = Arrangement.SpaceBetween, ) { - QuackAnimatedContent(targetState = isFirstPage) { + AnimatedContent( + targetState = isFirstPage, + label = "AnimatedContent", + ) { when (it) { true -> { Spacer(modifier = Modifier) @@ -111,7 +117,10 @@ internal fun DoubleButtonBottomBar( } } - QuackAnimatedContent(targetState = isLastPage) { + AnimatedContent( + targetState = isLastPage, + label = "AnimatedContent", + ) { when (it) { false -> MediumButton( text = stringResource(id = R.string.next), @@ -141,11 +150,13 @@ private fun MediumButton( textColor: QuackColor = textColorFor(enabled), ) { QuackSurface( - modifier = Modifier, - backgroundColor = backgroundColor, - border = border, - shape = RoundedCornerShape(size = 8.dp), + modifier = Modifier.quackBorder( + shape = RoundedCornerShape(8.dp), + border = border, + ), + shape = RoundedCornerShape(8.dp), onClick = onClickFor(enabled, onClick), + backgroundColor = backgroundColor, ) { QuackText( modifier = Modifier.padding( @@ -153,11 +164,9 @@ private fun MediumButton( horizontal = 12.dp, ), text = text, - style = QuackTextStyle.Body1.change( + typography = QuackTypography.Body1.change( color = textColor, - textAlign = TextAlign.Center, ), - singleLine = true, ) } } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/FlexibleSubjectiveQuestionSection.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/FlexibleSubjectiveQuestionSection.kt deleted file mode 100644 index a54d43dd4..000000000 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/FlexibleSubjectiveQuestionSection.kt +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Designed and developed by Duckie Team, 2022 - * - * Licensed under the MIT. - * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE - */ - -@file:OptIn(ExperimentalComposeUiApi::class) - -package team.duckie.app.android.feature.solve.problem.common - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.NonRestartableComposable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.SoftwareKeyboardController -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import team.duckie.app.android.common.compose.ui.Spacer -import team.duckie.app.android.common.kotlin.AllowMagicNumber -import team.duckie.app.android.domain.exam.model.Problem -import team.duckie.app.android.domain.exam.model.Question -import team.duckie.app.android.feature.solve.problem.answer.shortanswer.ShortAnswerForm -import team.duckie.app.android.feature.solve.problem.viewmodel.state.InputAnswer -import team.duckie.quackquack.material.QuackColor -import team.duckie.quackquack.ui.QuackImage -import team.duckie.quackquack.ui.modifier.quackClickable -import team.duckie.quackquack.ui.sugar.QuackHeadLine2 - -@AllowMagicNumber("to get flexible image height") -@NonRestartableComposable -@Composable -private fun getFlexibleImageHeight(): Dp { - val configuration = LocalConfiguration.current - return ((configuration.screenWidthDp / 4) * 3).dp -} - -private object TextFieldMargin { - val Top = 24.dp - val Bottom = 16.dp - val Vertical get() = Top + Bottom -} - -/** - * 키보드 상태에 따라서 이미지의 높이가 유동적으로 변하는 주관식 문제를 위한 섹션 - */ -@Composable -fun FlexibleSubjectiveQuestionSection( - problem: Problem, - pageIndex: Int, - updateInputAnswers: (page: Int, inputAnswer: InputAnswer) -> Unit, - requestFocus: Boolean, - keyboardController: SoftwareKeyboardController?, -) { - val density = LocalDensity.current - val question = problem.question as Question.Image - val flexibleImageHeight = getFlexibleImageHeight() - var textFieldHeight by remember { mutableStateOf(Dp.Unspecified) } - - Column( - modifier = Modifier - .fillMaxSize() - .imePadding() - .quackClickable( - rippleEnabled = false, - ) { - keyboardController?.hide() - }, - ) { - Spacer(space = 16.dp) - QuackHeadLine2( - modifier = Modifier.padding(horizontal = 16.dp), - text = "${pageIndex + 1}. ${question.text}", - ) - Spacer(space = 12.dp) - BoxWithConstraints { - val actualHeight = - if (maxHeight - textFieldHeight - TextFieldMargin.Vertical >= flexibleImageHeight) { - flexibleImageHeight - } else { - maxHeight - textFieldHeight - TextFieldMargin.Vertical - } - - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = actualHeight) - .padding(horizontal = 16.dp) - .background( - color = QuackColor.Black.value, - shape = RoundedCornerShape(16.dp), - ), - ) { - QuackImage( - modifier = Modifier.fillMaxSize(), - src = question.imageUrl, - contentScale = ContentScale.Fit, - ) - } - } - Spacer(space = TextFieldMargin.Top) - ShortAnswerForm( - modifier = Modifier.onSizeChanged { - with(density) { - textFieldHeight = it.height.toDp() - } - }, - answer = problem.correctAnswer ?: "", - onTextChanged = { inputText -> - updateInputAnswers(pageIndex, InputAnswer(0, inputText)) - }, - requestFocus = requestFocus, - keyboardController = keyboardController, - ) - Spacer(space = TextFieldMargin.Bottom) - } -} diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/LoadingIndicator.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/LoadingIndicator.kt index 2daad1fae..93b4d4d09 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/LoadingIndicator.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/LoadingIndicator.kt @@ -13,15 +13,14 @@ import androidx.compose.material.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import team.duckie.quackquack.ui.color.QuackColor +import team.duckie.quackquack.material.QuackColor -// TODO(EvergreenTree97): QuackLoadingIndicator로 통합 필요 @Composable internal fun LoadingIndicator() { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator(color = QuackColor.DuckieOrange.composeColor) + CircularProgressIndicator(color = QuackColor.DuckieOrange.value) } } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/TopBar.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/TopBar.kt index 93bc55b80..af134ace4 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/TopBar.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/common/TopBar.kt @@ -14,20 +14,21 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp -import team.duckie.app.android.common.compose.ui.icon.v1.Clock import team.duckie.app.android.common.compose.ui.LinearProgressBar -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.component.QuackSubtitle2 -import team.duckie.quackquack.ui.component.QuackTopAppBar -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.util.DpSize +import team.duckie.app.android.common.compose.ui.icon.v2.Clock +import team.duckie.app.android.common.compose.ui.quack.todo.QuackTopAppBar +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.QuackIcon +import team.duckie.quackquack.material.icon.quackicon.Outlined +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.ui.QuackIcon +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackSubtitle2 @Composable internal fun CloseAndPageTopBar( @@ -38,7 +39,7 @@ internal fun CloseAndPageTopBar( ) { QuackTopAppBar( modifier = modifier, - leadingIcon = QuackIcon.Close, + leadingIcon = QuackIcon.Outlined.Close, onLeadingIconClick = onCloseClick, trailingContent = { PageInfo( @@ -58,25 +59,25 @@ internal fun TimerTopBar( Column( modifier = modifier .fillMaxWidth() - .padding(horizontal = 16.dp), + .padding(bottom = 16.dp), ) { QuackTopAppBar( - leadingIcon = QuackIcon.Close, + leadingIcon = QuackIcon.Outlined.Close, onLeadingIconClick = onCloseClick, ) Box( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), contentAlignment = Alignment.CenterStart, ) { LinearProgressBar( progress = progress(), ) - QuackImage( - modifier = Modifier - .clip(CircleShape) - .background(QuackColor.White.composeColor), - src = QuackIcon.Clock, - size = DpSize(all = 16.dp), + QuackIcon( + modifier = Modifier.background(QuackColor.White.value), + icon = QuackIcon.Clock, + size = 16.dp, ) } } @@ -92,13 +93,13 @@ private fun PageInfo( horizontalArrangement = Arrangement.spacedBy(space = 2.dp), ) { QuackSubtitle2(text = currentPage.toString()) - QuackSubtitle2( + QuackText( text = " / ", - color = QuackColor.Gray2, + typography = QuackTypography.Subtitle2.change(color = QuackColor.Gray2), ) - QuackSubtitle2( + QuackText( text = totalPage.toString(), - color = QuackColor.Gray2, + typography = QuackTypography.Subtitle2.change(color = QuackColor.Gray2), ) } } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/QuestionSection.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/QuestionSection.kt index e5f423862..2087e6c32 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/QuestionSection.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/QuestionSection.kt @@ -7,29 +7,38 @@ package team.duckie.app.android.feature.solve.problem.question +import androidx.compose.foundation.background import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import team.duckie.app.android.common.compose.GetHeightRatioW328H240 import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.domain.exam.model.Question import team.duckie.app.android.feature.solve.problem.question.audio.AudioPlayer +import team.duckie.app.android.feature.solve.problem.question.image.FlexibleImageBox import team.duckie.app.android.feature.solve.problem.question.image.ImageBox import team.duckie.app.android.feature.solve.problem.question.video.VideoPlayer -import team.duckie.quackquack.ui.component.QuackHeadLine2 +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.ui.sugar.QuackHeadLine2 -private val HorizontalPadding = PaddingValues(horizontal = 16.dp) +private val HorizontalPadding = 16.dp @Composable internal fun ColumnScope.QuestionSection( page: Int, question: Question, isImageChoice: Boolean, + isRequireFlexibleImage: Boolean, + spaceImageToKeyboard: Dp, + onImageLoading: (Boolean) -> Unit, + onImageSuccess: (Boolean) -> Unit, + isImageLoading: Boolean, ) { val modifier = if (isImageChoice) { Modifier @@ -39,22 +48,43 @@ internal fun ColumnScope.QuestionSection( Modifier.weight(GetHeightRatioW328H240) } QuackHeadLine2( + modifier = Modifier.padding(horizontal = HorizontalPadding), text = "${page + 1}. ${question.text}", - padding = HorizontalPadding, ) when (question) { is Question.Text -> {} is Question.Image -> { - Spacer(space = 16.dp) - ImageBox( - modifier = modifier, - url = question.imageUrl, - ) + Spacer(space = 12.dp) + if (isRequireFlexibleImage) { + FlexibleImageBox( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + spaceImageToKeyboard = spaceImageToKeyboard, + url = question.imageUrl, + onImageLoading = onImageLoading, + onImageSuccess = onImageSuccess, + isImageLoading = isImageLoading, + ) + } else { + ImageBox( + modifier = modifier + .padding(horizontal = 16.dp) + .background( + color = QuackColor.Gray4.value, + shape = RoundedCornerShape(8.dp), + ), + url = question.imageUrl, + onImageLoading = onImageLoading, + onImageSuccess = onImageSuccess, + isImageLoading = isImageLoading, + ) + } } is Question.Audio -> { AudioPlayer( - modifier = Modifier.padding(paddingValues = HorizontalPadding), + modifier = Modifier.padding(horizontal = HorizontalPadding), url = question.audioUrl, ) } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/audio/Controller.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/audio/Controller.kt index 534ed224a..9370d1b57 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/audio/Controller.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/audio/Controller.kt @@ -5,29 +5,25 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalQuackQuackApi::class) + package team.duckie.app.android.feature.solve.problem.question.audio import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp import coil.ImageLoader import coil.compose.AsyncImage import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import team.duckie.app.android.common.compose.ui.quack.QuackCrossfade -import team.duckie.quackquack.ui.border.QuackBorder -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackSurface -import team.duckie.quackquack.ui.component.internal.QuackText -import team.duckie.quackquack.ui.textstyle.QuackTextStyle +import team.duckie.quackquack.ui.QuackButton +import team.duckie.quackquack.ui.QuackButtonStyle +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi @Composable internal fun AudioController( @@ -87,23 +83,10 @@ private fun LargeButton( text: String, onClick: () -> Unit, ) { - QuackSurface( - modifier = modifier.sizeIn( - minWidth = 82.dp, - minHeight = 40.dp, - ), - backgroundColor = QuackColor.White, - border = QuackBorder( - color = QuackColor.Gray3, - ), - shape = RoundedCornerShape(size = 8.dp), + QuackButton( + modifier = modifier, + text = text, + style = QuackButtonStyle.SecondaryRoundSmall, onClick = onClick, - ) { - QuackText( - modifier = Modifier.padding(all = 10.dp), - text = text, - style = QuackTextStyle.Subtitle, - singleLine = true, - ) - } + ) } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/image/ImageBox.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/image/ImageBox.kt index f70859489..50de3510d 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/image/ImageBox.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/image/ImageBox.kt @@ -7,37 +7,117 @@ package team.duckie.app.android.feature.solve.problem.question.image +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.feature.solve.problem.R import team.duckie.quackquack.material.QuackColor -import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText + +@Suppress("MagicNumber") +@NonRestartableComposable +@Composable +private fun getFlexibleImageHeight(): Dp { + val configuration = LocalConfiguration.current + return ((configuration.screenWidthDp / 4) * 3).dp +} + +@Composable +internal fun FlexibleImageBox( + modifier: Modifier = Modifier, + spaceImageToKeyboard: Dp, + url: String, + onImageLoading: (Boolean) -> Unit, + onImageSuccess: (Boolean) -> Unit, + isImageLoading: Boolean, +) { + val flexibleImageHeight = getFlexibleImageHeight() + BoxWithConstraints(modifier = modifier) { + val actualHeight = + if (maxHeight - spaceImageToKeyboard >= flexibleImageHeight) { + flexibleImageHeight + } else { + maxHeight - spaceImageToKeyboard + } + + ImageBox( + modifier = Modifier + .fillMaxWidth() + .height(height = actualHeight) + .background( + color = QuackColor.Gray4.value, + shape = RoundedCornerShape(8.dp), + ), + url = url, + onImageLoading = onImageLoading, + onImageSuccess = onImageSuccess, + isImageLoading = isImageLoading, + ) + } +} @Composable internal fun ImageBox( modifier: Modifier = Modifier, url: String, + onImageLoading: (Boolean) -> Unit, + onImageSuccess: (Boolean) -> Unit, + isImageLoading: Boolean, ) { - Box( - modifier = modifier - .padding(horizontal = 16.dp) - .background( - color = QuackColor.Black.value, - shape = RoundedCornerShape(8.dp), - ), - contentAlignment = Alignment.Center, - ) { - QuackImage( - modifier = Modifier.fillMaxSize(), - src = url, - contentScale = ContentScale.Fit, + Box(modifier = modifier) { + AsyncImage( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .fillMaxSize(), + onLoading = { + onImageLoading(true) + }, + onSuccess = { + onImageSuccess(false) + }, + model = url, + contentDescription = "", ) + AnimatedVisibility( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)) + .background(QuackColor.Black.value.copy(alpha = 0.5f)), + visible = isImageLoading, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + QuackText( + text = stringResource(id = R.string.loading_image), + typography = QuackTypography.Body1.change( + color = QuackColor.White, + ), + ) + Spacer(space = 12.dp) + CircularProgressIndicator(color = QuackColor.DuckieOrange.value) + } + } } } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/video/Controller.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/video/Controller.kt index 45bad3b16..df398e859 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/video/Controller.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/video/Controller.kt @@ -7,13 +7,13 @@ package team.duckie.app.android.feature.solve.problem.question.video +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable @@ -31,11 +31,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import team.duckie.app.android.feature.solve.problem.R -import team.duckie.quackquack.ui.animation.QuackAnimatedVisibility -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody3 -import team.duckie.quackquack.ui.component.QuackImage -import team.duckie.quackquack.ui.modifier.quackClickable +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.QuackText import java.util.Locale import java.util.concurrent.TimeUnit @@ -51,7 +51,7 @@ internal fun VideoController( onTimeChanged: (Float) -> Unit, ) { var sliderHeight by remember { mutableStateOf(0) } - QuackAnimatedVisibility( + AnimatedVisibility( modifier = modifier, visible = isVisible(), ) { @@ -63,22 +63,23 @@ internal fun VideoController( ) Column( modifier = Modifier - .align(alignment = Alignment.BottomCenter) - .offset { - IntOffset( - x = 0, - y = sliderHeight / 2, - ) - }, + .align(alignment = Alignment.BottomCenter) + .offset { + IntOffset( + x = 0, + y = sliderHeight / 2, + ) + }, ) { - QuackBody3( - modifier = Modifier.padding(start = 12.dp), + QuackText( text = stringResource( id = R.string.current_between_total, currentTime().formatMinSec(), totalTime().formatMinSec(), ), - color = QuackColor.Gray3, + typography = QuackTypography.Body3.change( + color = QuackColor.Gray3, + ), ) VideoSlider( modifier = Modifier @@ -112,7 +113,7 @@ internal fun InteractionButton( modifier = modifier .fillMaxSize() .alpha(alpha = 0.2f) - .background(color = QuackColor.Black.composeColor), + .background(color = QuackColor.Black.value), ) QuackImage( modifier = modifier, diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/video/Slider.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/video/Slider.kt index 97d17a96e..be9a9a2ac 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/video/Slider.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/question/video/Slider.kt @@ -14,20 +14,20 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import team.duckie.app.android.common.compose.rememberNoRippleInteractionSource -import team.duckie.quackquack.ui.color.QuackColor +import team.duckie.quackquack.material.QuackColor @Composable internal fun primarySliderColors() = SliderDefaults.colors( - thumbColor = QuackColor.DuckieOrange.composeColor, - activeTrackColor = QuackColor.DuckieOrange.composeColor, + thumbColor = QuackColor.DuckieOrange.value, + activeTrackColor = QuackColor.DuckieOrange.value, inactiveTrackColor = Color.Transparent, ) @Composable internal fun bufferSliderColors() = SliderDefaults.colors( disabledThumbColor = Color.Transparent, - disabledActiveTrackColor = QuackColor.Gray2.composeColor.copy(alpha = 0.5f), - disabledInactiveTrackColor = QuackColor.Gray3.composeColor, + disabledActiveTrackColor = QuackColor.Gray2.value.copy(alpha = 0.5f), + disabledInactiveTrackColor = QuackColor.Gray3.value, ) @Composable diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/screen/QuizScreen.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/screen/QuizScreen.kt index 1be257b65..ad4281387 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/screen/QuizScreen.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/screen/QuizScreen.kt @@ -7,8 +7,6 @@ @file:OptIn( ExperimentalFoundationApi::class, - ExperimentalComposeUiApi::class, - ExperimentalFoundationApi::class, ) package team.duckie.app.android.feature.solve.problem.screen @@ -29,17 +27,19 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import team.duckie.app.android.common.compose.isCurrentPage +import team.duckie.app.android.common.compose.isTargetPage import team.duckie.app.android.common.compose.ui.Spacer import team.duckie.app.android.common.compose.ui.dialog.DuckieDialog import team.duckie.app.android.common.kotlin.exception.duckieResponseFieldNpe @@ -47,8 +47,8 @@ import team.duckie.app.android.domain.exam.model.Answer import team.duckie.app.android.domain.exam.model.Problem.Companion.isSubjective import team.duckie.app.android.feature.solve.problem.R import team.duckie.app.android.feature.solve.problem.answer.AnswerSection +import team.duckie.app.android.feature.solve.problem.answer.TextFieldMargin import team.duckie.app.android.feature.solve.problem.common.ButtonBottomBar -import team.duckie.app.android.feature.solve.problem.common.FlexibleSubjectiveQuestionSection import team.duckie.app.android.feature.solve.problem.common.TimerTopBar import team.duckie.app.android.feature.solve.problem.common.verticalScrollModifierAsCondition import team.duckie.app.android.feature.solve.problem.question.QuestionSection @@ -86,10 +86,6 @@ internal fun QuizScreen( ) } - LaunchedEffect(pagerState.targetPage) { - startTimer(state.time.toFloat()) - } - LaunchedEffect(timeOver) { if (timeOver) { onNextPage( @@ -132,6 +128,7 @@ internal fun QuizScreen( updateInputAnswers = { index, answer -> inputAnswers[index] = answer }, + startTimer = startTimer, ) ButtonBottomBar( modifier = Modifier @@ -174,70 +171,89 @@ private fun ContentSection( state: SolveProblemState, inputAnswers: ImmutableList, updateInputAnswers: (page: Int, inputAnswer: InputAnswer) -> Unit, + startTimer: (Float) -> Unit, ) { val keyboardController = LocalSoftwareKeyboardController.current + var textFieldHeight by remember { mutableStateOf(Dp.Unspecified) } + val density = LocalDensity.current + val isImageLoading = remember { + mutableStateListOf( + elements = Array( + size = state.quizProblems.size, + init = { true }, + ), + ) + } HorizontalPager( modifier = modifier, - pageCount = state.totalPage, state = pagerState, userScrollEnabled = false, + beyondBoundsPageCount = 3, ) { pageIndex -> val problem = state.quizProblems[pageIndex] - val requestFocus by remember(key1 = pagerState.currentPage) { + val isCurrentPage by remember(pagerState.currentPage) { derivedStateOf { pagerState.isCurrentPage(pageIndex) } } - LaunchedEffect(key1 = requestFocus) { + LaunchedEffect(pagerState.targetPage, isImageLoading[pageIndex]) { + if (isImageLoading[pageIndex].not() && pagerState.isTargetPage(pageIndex)) { + startTimer(state.time.toFloat()) + } + } + + LaunchedEffect(key1 = isCurrentPage) { if (!problem.isSubjective()) { keyboardController?.hide() } } val isImageChoice = problem.answer?.isImageChoice == true + val isRequireFlexibleImage = problem.isSubjective() && problem.question.isImage() - when { - // for keyboard flexible image height - problem.isSubjective() && problem.question.isImage() -> FlexibleSubjectiveQuestionSection( - problem = problem, + Column( + modifier = Modifier + .verticalScrollModifierAsCondition(isImageChoice) + .fillMaxSize(), + ) { + QuestionSection( + page = pageIndex, + question = problem.question, + isImageChoice = isImageChoice, + isRequireFlexibleImage = isRequireFlexibleImage, + spaceImageToKeyboard = textFieldHeight + TextFieldMargin.Top, + onImageLoading = { + isImageLoading[pageIndex] = it + }, + onImageSuccess = { + isImageLoading[pageIndex] = it + }, + isImageLoading = isImageLoading[pageIndex], + ) + val answer = problem.answer + AnswerSection( pageIndex = pageIndex, + answer = when (answer) { + is Answer.Short -> Answer.Short( + problem.correctAnswer + ?: duckieResponseFieldNpe("null 이 되면 안됩니다."), + ) + + is Answer.Choice, is Answer.ImageChoice -> answer + else -> duckieResponseFieldNpe("해당 분기로 빠질 수 없는 AnswerType 입니다.") + }, + inputAnswers = inputAnswers, updateInputAnswers = updateInputAnswers, - requestFocus = requestFocus, + requestFocus = isCurrentPage, keyboardController = keyboardController, + onShortAnswerSizeChanged = { + textFieldHeight = with(density) { + it.height.toDp() + } + }, ) - - else -> Column( - modifier = Modifier - .verticalScrollModifierAsCondition(isImageChoice) - .fillMaxSize(), - ) { - Spacer(space = 16.dp) - QuestionSection( - page = pageIndex, - question = problem.question, - isImageChoice = isImageChoice, - ) - Spacer(space = 24.dp) - val answer = problem.answer - AnswerSection( - pageIndex = pageIndex, - answer = when (answer) { - is Answer.Short -> Answer.Short( - problem.correctAnswer - ?: duckieResponseFieldNpe("null 이 되면 안됩니다."), - ) - - is Answer.Choice, is Answer.ImageChoice -> answer - else -> duckieResponseFieldNpe("해당 분기로 빠질 수 없는 AnswerType 입니다.") - }, - inputAnswers = inputAnswers, - updateInputAnswers = updateInputAnswers, - requestFocus = requestFocus, - keyboardController = keyboardController, - ) - Spacer(space = 16.dp) - } + Spacer(space = 16.dp) } } } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/screen/SolveProblemScreen.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/screen/SolveProblemScreen.kt index 4ba491ab7..a023989a0 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/screen/SolveProblemScreen.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/screen/SolveProblemScreen.kt @@ -13,6 +13,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState @@ -24,13 +25,16 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -46,19 +50,19 @@ import team.duckie.app.android.domain.exam.model.Answer import team.duckie.app.android.domain.exam.model.Problem.Companion.isSubjective import team.duckie.app.android.feature.solve.problem.R import team.duckie.app.android.feature.solve.problem.answer.AnswerSection +import team.duckie.app.android.feature.solve.problem.answer.TextFieldMargin import team.duckie.app.android.feature.solve.problem.common.CloseAndPageTopBar import team.duckie.app.android.feature.solve.problem.common.DoubleButtonBottomBar -import team.duckie.app.android.feature.solve.problem.common.FlexibleSubjectiveQuestionSection import team.duckie.app.android.feature.solve.problem.common.verticalScrollModifierAsCondition import team.duckie.app.android.feature.solve.problem.question.QuestionSection import team.duckie.app.android.feature.solve.problem.viewmodel.state.InputAnswer import team.duckie.app.android.feature.solve.problem.viewmodel.state.SolveProblemState +import team.duckie.quackquack.material.quackClickable private const val SolveProblemTopAppBarLayoutId = "SolveProblemTopAppBar" private const val SolveProblemContentLayoutId = "SolveProblemContent" private const val SolveProblemBottomBarLayoutId = "SolveProblemBottomBar" -// 6번호 @Composable internal fun SolveProblemScreen( state: SolveProblemState, @@ -66,8 +70,6 @@ internal fun SolveProblemScreen( finishExam: (List) -> Unit, pagerState: PagerState, ) { - val totalPage = remember { state.totalPage } - val coroutineScope = rememberCoroutineScope() var examExitDialogVisible by remember { mutableStateOf(false) } var examSubmitDialogVisible by remember { mutableStateOf(false) } @@ -116,13 +118,14 @@ internal fun SolveProblemScreen( CloseAndPageTopBar( modifier = Modifier .layoutId(SolveProblemTopAppBarLayoutId) + .fillMaxWidth() .padding(start = 12.dp) .padding(end = 16.dp), onCloseClick = { examExitDialogVisible = true }, currentPage = pagerState.currentPage + 1, - totalPage = totalPage, + totalPage = state.totalPage, ) ContentSection( modifier = Modifier.layoutId(SolveProblemContentLayoutId), @@ -136,7 +139,7 @@ internal fun SolveProblemScreen( DoubleButtonBottomBar( modifier = Modifier.layoutId(SolveProblemBottomBarLayoutId), isFirstPage = pagerState.currentPage == 0, - isLastPage = pagerState.currentPage == totalPage - 1, + isLastPage = pagerState.currentPage == state.totalPage - 1, onLeftButtonClick = { coroutineScope.launch { pagerState.movePrevPage() @@ -144,7 +147,7 @@ internal fun SolveProblemScreen( }, onRightButtonClick = { coroutineScope.launch { - val maximumPage = totalPage - 1 + val maximumPage = state.totalPage - 1 if (pagerState.currentPage == maximumPage) { examSubmitDialogVisible = true } else { @@ -171,10 +174,11 @@ private fun ContentSection( updateInputAnswers: (Int, InputAnswer) -> Unit, ) { val keyboardController = LocalSoftwareKeyboardController.current + var textFieldHeight by remember { mutableStateOf(Dp.Unspecified) } + val density = LocalDensity.current HorizontalPager( modifier = modifier, - pageCount = state.totalPage, state = pagerState, ) { pageIndex -> val problem = state.problems[pageIndex].problem @@ -185,6 +189,8 @@ private fun ContentSection( } } + var isImageLoading by rememberSaveable { mutableStateOf(true) } + LaunchedEffect(key1 = requestFocus) { if (!problem.isSubjective()) { keyboardController?.hide() @@ -192,48 +198,50 @@ private fun ContentSection( } val isImageChoice = problem.answer?.isImageChoice == true - - when { - // for keyboard flexible image height - problem.isSubjective() && problem.question.isImage() -> FlexibleSubjectiveQuestionSection( - problem = problem, + val isRequireFlexibleImage = problem.isSubjective() && problem.question.isImage() + Column( + modifier = Modifier + .verticalScrollModifierAsCondition(isImageChoice) + .fillMaxSize() + .quackClickable( + rippleEnabled = false, + ) { + keyboardController?.hide() + }, + ) { + Spacer(space = 16.dp) + QuestionSection( + page = pageIndex, + question = problem.question, + isRequireFlexibleImage = isRequireFlexibleImage, + spaceImageToKeyboard = textFieldHeight + TextFieldMargin.Top, + onImageLoading = { isImageLoading = it }, + onImageSuccess = { isImageLoading = it }, + isImageLoading = isImageLoading, + isImageChoice = isImageChoice, + ) + val answer = problem.answer + AnswerSection( pageIndex = pageIndex, + answer = when (answer) { + is Answer.Short -> Answer.Short( + problem.correctAnswer + ?: duckieResponseFieldNpe("null 이 되면 안됩니다."), + ) + + is Answer.Choice, is Answer.ImageChoice -> answer + else -> duckieResponseFieldNpe("해당 분기로 빠질 수 없는 AnswerType 입니다.") + }, + inputAnswers = inputAnswers, updateInputAnswers = updateInputAnswers, requestFocus = requestFocus, keyboardController = keyboardController, + onShortAnswerSizeChanged = { + textFieldHeight = with(density) { + it.height.toDp() + } + }, ) - - else -> Column( - modifier = Modifier - .verticalScrollModifierAsCondition(isImageChoice) - .fillMaxSize(), - ) { - Spacer(space = 16.dp) - QuestionSection( - page = pageIndex, - question = problem.question, - isImageChoice = isImageChoice, - ) - Spacer(space = 24.dp) - val answer = problem.answer - AnswerSection( - pageIndex = pageIndex, - answer = when (answer) { - is Answer.Short -> Answer.Short( - problem.correctAnswer - ?: duckieResponseFieldNpe("null 이 되면 안됩니다."), - ) - - is Answer.Choice, is Answer.ImageChoice -> answer - else -> duckieResponseFieldNpe("해당 분기로 빠질 수 없는 AnswerType 입니다.") - }, - inputAnswers = inputAnswers, - updateInputAnswers = updateInputAnswers, - requestFocus = requestFocus, - keyboardController = keyboardController, - ) - Spacer(space = 16.dp) - } } } } diff --git a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/viewmodel/SolveProblemViewModel.kt b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/viewmodel/SolveProblemViewModel.kt index e26e5ef4f..c20a0d0d6 100644 --- a/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/viewmodel/SolveProblemViewModel.kt +++ b/feature/solve-problem/src/main/kotlin/team/duckie/app/android/feature/solve/problem/viewmodel/SolveProblemViewModel.kt @@ -56,7 +56,7 @@ internal class SolveProblemViewModel @Inject constructor( problemTimer.start(time) } - fun stopTimer() { + private fun stopTimer() { problemTimer.stop() } @@ -110,7 +110,9 @@ internal class SolveProblemViewModel @Inject constructor( reduce { state.copy(isProblemsLoading = true, isError = false) } getQuizUseCase(examId = examId).onSuccess { quizResult -> - val quizProblems = quizResult.exam.problems + val quizProblems = quizResult.exam.problems.also { + problemTimer.setTotalTime(quizResult.exam.timer?.toFloat() ?: 0f) + } if (quizProblems == null) { stopExam() } else { @@ -140,7 +142,6 @@ internal class SolveProblemViewModel @Inject constructor( ) = intent { val correctAnswer = state.quizProblems[pageIndex].correctAnswer ?: throw DuckieClientLogicProblemException(code = CORRECT_ANSWER_IS_NULL) - state.quizProblems[pageIndex].answer if (correctAnswer.replace(" ", "").lowercase() != inputAnswer.answer.replace(" ", "") .lowercase() ) { diff --git a/feature/solve-problem/src/main/res/values/strings.xml b/feature/solve-problem/src/main/res/values/strings.xml index 7c5f975cd..9b95b83f7 100644 --- a/feature/solve-problem/src/main/res/values/strings.xml +++ b/feature/solve-problem/src/main/res/values/strings.xml @@ -19,4 +19,5 @@ 답안을 제출하시겠어요? 아직 풀지 않은 문제가 있는지 확인해주세요. 띄어쓰기 포함 %s자 + 이미지를 불러오고 있어요 :) diff --git a/feature/start-exam/build.gradle.kts b/feature/start-exam/build.gradle.kts index d4690c190..81a6fecd3 100644 --- a/feature/start-exam/build.gradle.kts +++ b/feature/start-exam/build.gradle.kts @@ -28,7 +28,7 @@ dependencies { projects.common.compose, libs.orbit.viewmodel, libs.orbit.compose, - libs.quack.ui.components, + libs.quack.v2.ui, libs.compose.lifecycle.runtime, libs.compose.ui.material, // needs for CircularProgressIndicator libs.firebase.crashlytics, diff --git a/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/StartExamActivity.kt b/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/StartExamActivity.kt index 9ad65647f..812ac7adc 100644 --- a/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/StartExamActivity.kt +++ b/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/StartExamActivity.kt @@ -21,11 +21,12 @@ import org.orbitmvi.orbit.viewmodel.observe import team.duckie.app.android.common.android.ui.BaseActivity import team.duckie.app.android.common.android.ui.const.Extras import team.duckie.app.android.common.android.ui.finishWithAnimation +import team.duckie.app.android.common.compose.util.addFocusCleaner import team.duckie.app.android.feature.start.exam.viewmodel.StartExamSideEffect import team.duckie.app.android.feature.start.exam.viewmodel.StartExamViewModel import team.duckie.app.android.navigator.feature.solveproblem.SolveProblemNavigator -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.theme.QuackTheme import javax.inject.Inject @AndroidEntryPoint @@ -43,7 +44,8 @@ class StartExamActivity : BaseActivity() { StartExamScreen( Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor) + .addFocusCleaner() + .background(color = QuackColor.White.value) .systemBarsPadding(), ) } diff --git a/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/StartExamScreen.kt b/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/StartExamScreen.kt index 6e21a1432..1e23b42c6 100644 --- a/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/StartExamScreen.kt +++ b/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/StartExamScreen.kt @@ -15,13 +15,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import org.orbitmvi.orbit.compose.collectAsState +import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.feature.start.exam.screen.exam.StartExamInputScreen import team.duckie.app.android.feature.start.exam.screen.quiz.StartQuizInputScreen import team.duckie.app.android.feature.start.exam.viewmodel.StartExamState import team.duckie.app.android.feature.start.exam.viewmodel.StartExamViewModel -import team.duckie.app.android.common.compose.activityViewModel -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackTitle1 +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.ui.sugar.QuackTitle1 @Composable internal fun StartExamScreen( @@ -57,7 +57,7 @@ private fun StartExamLoadingScreen(modifier: Modifier, viewModel: StartExamViewM ) { // TODO(riflockle7): 추후 DuckieCircularProgressIndicator.kt 와 합치거나 꽥꽥 컴포넌트로 필요 CircularProgressIndicator( - color = QuackColor.DuckieOrange.composeColor, + color = QuackColor.DuckieOrange.value, ) } } diff --git a/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/exam/StartExamInputScreen.kt b/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/exam/StartExamInputScreen.kt index c07118a10..07afd8d11 100644 --- a/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/exam/StartExamInputScreen.kt +++ b/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/exam/StartExamInputScreen.kt @@ -5,6 +5,8 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalQuackQuackApi::class) + package team.duckie.app.android.feature.start.exam.screen.exam import androidx.compose.foundation.background @@ -25,17 +27,16 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import team.duckie.app.android.common.compose.ui.BackPressedTopAppBar import team.duckie.app.android.common.compose.ui.ImeSpacer +import team.duckie.app.android.common.compose.ui.temp.TempFlexiblePrimaryLargeButton import team.duckie.app.android.feature.start.exam.R import team.duckie.app.android.feature.start.exam.screen.StartExamScreen import team.duckie.app.android.feature.start.exam.viewmodel.StartExamState import team.duckie.app.android.feature.start.exam.viewmodel.StartExamViewModel -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackGrayscaleTextField -import team.duckie.quackquack.ui.component.QuackHeadLine1 -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType -import team.duckie.quackquack.ui.component.internal.QuackText -import team.duckie.quackquack.ui.textstyle.QuackTextStyle +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackText +import team.duckie.quackquack.ui.sugar.QuackHeadLine1 +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi /** * 시험 시작 입력 화면 @@ -53,7 +54,7 @@ internal fun StartExamInputScreen(modifier: Modifier, viewModel: StartExamViewMo state.certifyingStatementInputText } - Column(modifier = modifier) { + Column(modifier = modifier.fillMaxWidth()) { // 상단 탭바 BackPressedTopAppBar(onBackPressed = viewModel::finishStartExam) @@ -83,17 +84,17 @@ internal fun StartExamInputScreen(modifier: Modifier, viewModel: StartExamViewMo Spacer(modifier = Modifier.weight(1f)) // 시험시작 버튼 - QuackLargeButton( - modifier = Modifier.padding( - vertical = 12.dp, - horizontal = 16.dp, - ), - type = QuackLargeButtonType.Fill, + TempFlexiblePrimaryLargeButton( + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = 12.dp, + horizontal = 16.dp, + ), text = stringResource(id = R.string.start_exam_start_button), - enabled = viewModel.startExamValidate(), onClick = viewModel::startSolveProblem, + enabled = viewModel.startExamValidate(), ) - ImeSpacer() } } @@ -117,14 +118,14 @@ internal fun StartExamTextField( BasicTextField( modifier = modifier .fillMaxWidth() - .background(color = QuackColor.Gray4.composeColor) + .background(color = QuackColor.Gray4.value) .padding( vertical = 17.dp, horizontal = 20.dp, ), value = text, onValueChange = onTextChanged, - textStyle = QuackTextStyle.Body1.asComposeStyle(), + textStyle = QuackTypography.Body1.asComposeStyle(), keyboardOptions = KeyboardOptions(imeAction = imeAction), keyboardActions = keyboardActions, singleLine = true, @@ -135,7 +136,7 @@ internal fun StartExamTextField( if (alwaysPlaceholderVisible || text.isEmpty()) { QuackText( text = placeholderText, - style = QuackTextStyle.Body1.change(color = QuackColor.Gray2), + typography = QuackTypography.Body1.change(color = QuackColor.Gray2), ) } } diff --git a/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/quiz/StartQuizInputScreen.kt b/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/quiz/StartQuizInputScreen.kt index 22be5763c..8d6864261 100644 --- a/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/quiz/StartQuizInputScreen.kt +++ b/feature/start-exam/src/main/java/team/duckie/app/android/feature/start/exam/screen/quiz/StartQuizInputScreen.kt @@ -5,6 +5,8 @@ * Please see full license: https://github.com/duckie-team/duckie-android/blob/develop/LICENSE */ +@file:OptIn(ExperimentalDesignToken::class, ExperimentalQuackQuackApi::class) + package team.duckie.app.android.feature.start.exam.screen.quiz import androidx.compose.foundation.background @@ -14,32 +16,37 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import team.duckie.app.android.common.compose.ui.BackPressedTopAppBar import team.duckie.app.android.common.compose.ui.ImeSpacer import team.duckie.app.android.common.compose.ui.Spacer +import team.duckie.app.android.common.compose.ui.temp.TempFlexiblePrimaryLargeButton import team.duckie.app.android.feature.start.exam.R +import team.duckie.app.android.feature.start.exam.screen.exam.StartExamTextField import team.duckie.app.android.feature.start.exam.viewmodel.StartExamState import team.duckie.app.android.feature.start.exam.viewmodel.StartExamViewModel -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackBody2 -import team.duckie.quackquack.ui.component.QuackGrayscaleTextField -import team.duckie.quackquack.ui.component.QuackHeadLine1 -import team.duckie.quackquack.ui.component.QuackLargeButton -import team.duckie.quackquack.ui.component.QuackLargeButtonType -import team.duckie.quackquack.ui.component.QuackTitle2 -import team.duckie.quackquack.ui.component.internal.QuackText -import team.duckie.quackquack.ui.textstyle.QuackTextStyle +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.sugar.QuackBody2 +import team.duckie.quackquack.ui.sugar.QuackHeadLine1 +import team.duckie.quackquack.ui.sugar.QuackTitle2 +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi @Composable internal fun StartQuizInputScreen(modifier: Modifier, viewModel: StartExamViewModel) { @@ -49,8 +56,9 @@ internal fun StartQuizInputScreen(modifier: Modifier, viewModel: StartExamViewMo val certifyingStatementText: String = remember(state.certifyingStatementInputText) { state.certifyingStatementInputText } + val keyboard = LocalSoftwareKeyboardController.current - Column(modifier = modifier) { + Column(modifier = modifier.fillMaxWidth()) { BackPressedTopAppBar(onBackPressed = viewModel::finishStartExam) Column( modifier = Modifier @@ -62,7 +70,7 @@ internal fun StartQuizInputScreen(modifier: Modifier, viewModel: StartExamViewMo modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) - .background(color = QuackColor.Gray4.composeColor) + .background(color = QuackColor.Gray4.value) .padding(all = 12.dp), limitTime = state.timer, ) @@ -75,23 +83,31 @@ internal fun StartQuizInputScreen(modifier: Modifier, viewModel: StartExamViewMo modifier = Modifier.padding(top = 4.dp), text = state.requirementQuestion, // TODO(EvergreenTree97) 추후 도전 조건 response 생기면 변경 ) - QuackGrayscaleTextField( - modifier = Modifier.padding(top = 14.dp), + StartExamTextField( + modifier = Modifier + .padding(top = 14.dp) + .clip(RoundedCornerShape(8.dp)), text = certifyingStatementText, - onTextChanged = viewModel::inputCertifyingStatement, + alwaysPlaceholderVisible = false, placeholderText = "ex) ${state.requirementPlaceholder}", + onTextChanged = viewModel::inputCertifyingStatement, + keyboardActions = KeyboardActions { + keyboard?.hide() + viewModel.startSolveProblem() + }, ) } Spacer(modifier = Modifier.weight(1f)) - QuackLargeButton( - modifier = Modifier.padding( - vertical = 12.dp, - horizontal = 16.dp, - ), - type = QuackLargeButtonType.Fill, + TempFlexiblePrimaryLargeButton( + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = 12.dp, + horizontal = 16.dp, + ), text = stringResource(id = R.string.start_exam_quiz_start_button), - enabled = certifyingStatementText.isNotEmpty(), onClick = viewModel::startSolveProblem, + enabled = certifyingStatementText.isNotEmpty(), ) ImeSpacer() } @@ -109,27 +125,49 @@ private fun InfoBox( QuackTitle2(text = stringResource(id = R.string.start_exam_information_before_quiz_title)) Spacer(space = 4.dp) QuackBody2(text = stringResource(id = R.string.start_exam_information_before_quiz_line1)) - QuackText( - annotatedText = buildAnnotatedString { + Text( + style = QuackTypography.Body2.asComposeStyle().copy( + lineBreak = LineBreak.Simple, + ), + text = buildAnnotatedString { append(stringResource(id = R.string.start_exam_information_before_quiz_line2_prefix)) withStyle( - SpanStyle( - color = QuackColor.Black.composeColor, + style = SpanStyle( + color = Color.Black, fontWeight = FontWeight.Bold, ), ) { append( stringResource( - id = R.string.start_exam_information_before_quiz_line2_infix, + id = R.string.start_exam_information_before_quiz_line2_highlight, limitTime.toString(), ), ) } append( - stringResource(id = R.string.start_exam_information_before_quiz_line2_postfix), + stringResource( + id = R.string.start_exam_information_before_quiz_line2_infix, + limitTime.toString(), + ), ) + append(stringResource(id = R.string.start_exam_information_before_quiz_line2_postfix)) + }, + ) + Text( + style = QuackTypography.Body2.asComposeStyle().copy( + lineBreak = LineBreak.Simple, + ), + text = buildAnnotatedString { + append(stringResource(id = R.string.start_exam_information_before_quiz_line3_prefix)) + withStyle( + style = SpanStyle( + color = Color.Black, + fontWeight = FontWeight.Bold, + ), + ) { + append(stringResource(id = R.string.start_exam_information_before_quiz_line3_highlight)) + } }, - style = QuackTextStyle.Body2, ) } } diff --git a/feature/start-exam/src/main/res/values/strings.xml b/feature/start-exam/src/main/res/values/strings.xml index 1cdf0adc0..7bdc06afc 100644 --- a/feature/start-exam/src/main/res/values/strings.xml +++ b/feature/start-exam/src/main/res/values/strings.xml @@ -6,8 +6,11 @@ [ 퀴즈 시작 전 안내사항 ] ※ 문제를 틀릴 경우 바로 도전이 종료됩니다. ※ 문제당  - 약 %s초의 제한시간이 있습니다.  + 이 있습니다.  + 약 %s초의 제한시간 문제를 잘 읽고 시간 내에 물음에 답해주세요. + ※ 띄어쓰기는 저희가 할게요. 대소문자도 구분할 필요 없어요!  + 정확한 답만 입력해주세요! <도전 조건> 덕퀴즈 도전 diff --git a/feature/tag-edit/build.gradle.kts b/feature/tag-edit/build.gradle.kts index 7bf1bc5ae..22eceb406 100644 --- a/feature/tag-edit/build.gradle.kts +++ b/feature/tag-edit/build.gradle.kts @@ -28,7 +28,8 @@ dependencies { projects.common.compose, libs.orbit.viewmodel, libs.orbit.compose, - libs.quack.ui.components, + libs.kotlin.collections.immutable, + libs.quack.v2.ui, libs.compose.lifecycle.runtime, libs.compose.ui.material, // needs for CircularProgressIndicator libs.firebase.crashlytics, diff --git a/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/TagEditActivity.kt b/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/TagEditActivity.kt index d6bee34e4..7c0279520 100644 --- a/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/TagEditActivity.kt +++ b/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/TagEditActivity.kt @@ -16,13 +16,13 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.ui.Modifier import dagger.hilt.android.AndroidEntryPoint import org.orbitmvi.orbit.viewmodel.observe +import team.duckie.app.android.common.android.exception.handling.reporter.reportToCrashlyticsIfNeeded +import team.duckie.app.android.common.android.ui.BaseActivity import team.duckie.app.android.feature.tag.edit.screen.TagEditScreen import team.duckie.app.android.feature.tag.edit.viewmodel.TagEditSideEffect import team.duckie.app.android.feature.tag.edit.viewmodel.TagEditViewModel -import team.duckie.app.android.common.android.exception.handling.reporter.reportToCrashlyticsIfNeeded -import team.duckie.app.android.common.android.ui.BaseActivity -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.theme.QuackTheme @AndroidEntryPoint class TagEditActivity : BaseActivity() { @@ -36,7 +36,7 @@ class TagEditActivity : BaseActivity() { TagEditScreen( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor) + .background(color = QuackColor.White.value) .systemBarsPadding(), ) } diff --git a/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/screen/TagEditScreen.kt b/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/screen/TagEditScreen.kt index 92dd3cd7c..f365d2dd0 100644 --- a/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/screen/TagEditScreen.kt +++ b/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/screen/TagEditScreen.kt @@ -10,6 +10,7 @@ package team.duckie.app.android.feature.tag.edit.screen import android.app.Activity +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -26,7 +27,9 @@ import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState +import team.duckie.app.android.common.compose.HideKeyboardWhenBottomSheetHidden import team.duckie.app.android.common.compose.activityViewModel +import team.duckie.app.android.common.compose.ui.BackPressedHeadLine2TopAppBar import team.duckie.app.android.common.compose.ui.ErrorScreen import team.duckie.app.android.common.compose.ui.FavoriteTagSection import team.duckie.app.android.common.compose.ui.LoadingScreen @@ -35,11 +38,12 @@ import team.duckie.app.android.domain.tag.model.Tag import team.duckie.app.android.feature.tag.edit.R import team.duckie.app.android.feature.tag.edit.viewmodel.TagEditState import team.duckie.app.android.feature.tag.edit.viewmodel.TagEditViewModel -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackSubtitle -import team.duckie.quackquack.ui.component.QuackTopAppBar -import team.duckie.quackquack.ui.icon.QuackIcon -import team.duckie.quackquack.ui.modifier.quackClickable +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.icon.quackicon.OutlinedGroup +import team.duckie.quackquack.material.icon.quackicon.outlined.Close +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.ui.QuackText @Composable internal fun TagEditScreen( @@ -59,7 +63,6 @@ internal fun TagEditScreen( onTrailingClick = vm::onTrailingClick, addNewTags = vm::addNewTags, requestAddTag = vm::requestNewTag, - onTagClick = vm::onTrailingClick, ) is TagEditState.Error -> ErrorScreen( @@ -77,7 +80,6 @@ fun TagEditSuccessScreen( onTrailingClick: (Int) -> Unit, addNewTags: (List) -> Unit, requestAddTag: suspend (String) -> Tag?, - onTagClick: (Int) -> Unit, ) { val activity = LocalContext.current as Activity val coroutineScope = rememberCoroutineScope() @@ -86,6 +88,16 @@ fun TagEditSuccessScreen( skipHalfExpanded = true, ) + BackHandler { + if (sheetState.isVisible) { + coroutineScope.launch { sheetState.hide() } + } else { + activity.finish() + } + } + + HideKeyboardWhenBottomSheetHidden(sheetState) + DuckieTagAddBottomSheet( sheetState = sheetState, onDismissRequest = { newAddedTags, clearAction -> @@ -99,24 +111,17 @@ fun TagEditSuccessScreen( content = { Column(modifier = modifier) { // 상단 탭바 - QuackTopAppBar( - leadingIcon = QuackIcon.ArrowBack, - leadingText = stringResource(R.string.title), - onLeadingIconClick = activity::finish, + BackPressedHeadLine2TopAppBar( + title = stringResource(R.string.title), + onBackPressed = activity::finish, trailingContent = { - QuackSubtitle( + QuackText( modifier = Modifier - .then(Modifier) // prevent Modifier.Companion - .quackClickable( - rippleEnabled = false, - onClick = onEditFinishClick, - ) - .padding( - vertical = 4.dp, - horizontal = 16.dp, - ), + .quackClickable(onClick = onEditFinishClick), text = stringResource(R.string.edit_finish), - color = QuackColor.DuckieOrange, + typography = QuackTypography.Subtitle.change( + color = QuackColor.DuckieOrange, + ), singleLine = true, ) }, @@ -128,12 +133,11 @@ fun TagEditSuccessScreen( title = stringResource(id = R.string.my_favorite_tag), horizontalPadding = PaddingValues(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), - trailingIcon = QuackIcon.Close, + trailingIcon = OutlinedGroup.Close, onTrailingClick = onTrailingClick, tags = state.myTags.map { it.name }.toPersistentList(), emptySection = {}, - singleLine = false, - onTagClick = onTagClick, + onTagClick = {}, addButtonTitle = stringResource(id = R.string.tag_edit_add_favorite_tag), onAddTagClick = { coroutineScope.launch { sheetState.show() } diff --git a/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/viewmodel/TagEditViewModel.kt b/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/viewmodel/TagEditViewModel.kt index 7d3441ff1..6a214a7e2 100644 --- a/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/viewmodel/TagEditViewModel.kt +++ b/feature/tag-edit/src/main/kotlin/team/duckie/app/android/feature/tag/edit/viewmodel/TagEditViewModel.kt @@ -12,12 +12,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toPersistentList import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.syntax.simple.intent import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container +import team.duckie.app.android.common.kotlin.copy import team.duckie.app.android.domain.tag.model.Tag import team.duckie.app.android.domain.tag.usecase.TagCreateUseCase import team.duckie.app.android.domain.user.model.User @@ -56,13 +56,14 @@ internal class TagEditViewModel @Inject constructor( /** 각 태그 항목의 x 버튼 클릭 시 동작 */ fun onTrailingClick(index: Int) = intent { - val newTags = myTags.toMutableList().apply { removeAt(index) }.toPersistentList() + require(state is TagEditState.Success) + val newTags = (state as TagEditState.Success).myTags.copy { removeAt(index) } reduce { TagEditState.Success(myTags = newTags) } } /** 바텀 시트에서 오른쪽 방향 화살표를 눌러 태그 추가를 완료한다. */ fun addNewTags(newTag: List) { - val newTags = myTags.toMutableList().apply { addAll(newTag) }.toPersistentList() + val newTags = myTags.copy { addAll(newTag) } intent { reduce { TagEditState.Success(myTags = newTags) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 38242ff3d..88761d828 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,7 +50,7 @@ androidx-annotation = "1.6.0" # compose compose-core = "1.4.2" -compose-foundation = "1.4.0-beta01" # use alpha for HorizontalPager +compose-foundation = "1.6.0-alpha04" # use alpha for HorizontalPager compose-material = "1.4.2" compose-runtime = "1.5.0-alpha03" # use alpha for SnapshotStateList#toList compose-lifecycle = "2.6.1" # use alpha for StateFlow#collectAsStateWithLifecycle @@ -114,7 +114,8 @@ quack-lint-compose = "1.0.2" # TODO(sungbin): quack-lint-writing # quack v2 -quack-v2-ui = "2.0.0-alpha07" +quack-v2-ui = "2.0.0-alpha11" +quack-v2-ui-plugin-interceptor-textfield ="2.0.0-alpha01" # test test-strikt = "0.34.1" @@ -258,6 +259,7 @@ quack-lint-quack = { module = "team.duckie.quack:quack-lint-quack", version.ref quack-lint-compose = { module = "team.duckie.quack:quack-lint-compose", version.ref = "quack-lint-compose" } quack-v2-ui = { module = "team.duckie.quackquack.ui:ui", version.ref = "quack-v2-ui" } +quack-v2-ui-plugin-interceptor-textfield = { module = "team.duckie.quackquack.ui:ui-plugin-interceptor-textfield", version.ref = "quack-v2-ui-plugin-interceptor-textfield"} test-strikt = { module = "io.strikt:strikt-core", version.ref = "test-strikt" } test-junit-core = { module = "junit:junit", version.ref = "test-junit-core" } diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index 02e4989f8..257daa54d 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -33,7 +33,7 @@ dependencies { libs.firebase.dynamic.links, libs.orbit.viewmodel, libs.androidx.splash, - libs.quack.ui.components, + libs.quack.v2.ui, libs.orbit.compose, ) } diff --git a/presentation/src/main/kotlin/team/duckie/app/android/presentation/IntroActivity.kt b/presentation/src/main/kotlin/team/duckie/app/android/presentation/IntroActivity.kt index 9dd9f6d01..47755fd73 100644 --- a/presentation/src/main/kotlin/team/duckie/app/android/presentation/IntroActivity.kt +++ b/presentation/src/main/kotlin/team/duckie/app/android/presentation/IntroActivity.kt @@ -32,20 +32,20 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.orbitmvi.orbit.viewmodel.observe import team.duckie.app.android.common.android.deeplink.DynamicLinkHelper -import team.duckie.app.android.domain.user.model.UserStatus +import team.duckie.app.android.common.android.exception.handling.reporter.reportToCrashlyticsIfNeeded +import team.duckie.app.android.common.android.ui.BaseActivity +import team.duckie.app.android.common.android.ui.changeActivityWithAnimation +import team.duckie.app.android.common.android.ui.const.Extras +import team.duckie.app.android.common.kotlin.seconds import team.duckie.app.android.core.datastore.PreferenceKey import team.duckie.app.android.core.datastore.dataStore +import team.duckie.app.android.domain.user.model.UserStatus import team.duckie.app.android.feature.home.screen.MainActivity import team.duckie.app.android.feature.onboard.OnboardActivity import team.duckie.app.android.presentation.screen.IntroScreen import team.duckie.app.android.presentation.viewmodel.IntroViewModel import team.duckie.app.android.presentation.viewmodel.sideeffect.IntroSideEffect -import team.duckie.app.android.common.android.exception.handling.reporter.reportToCrashlyticsIfNeeded -import team.duckie.app.android.common.kotlin.seconds -import team.duckie.app.android.common.android.ui.BaseActivity -import team.duckie.app.android.common.android.ui.changeActivityWithAnimation -import team.duckie.app.android.common.android.ui.const.Extras -import team.duckie.quackquack.ui.theme.QuackTheme +import team.duckie.quackquack.material.theme.QuackTheme private val SplashScreenExitAnimationDurationMillis = 0.2.seconds private val SplashScreenFinishDurationMillis = 1.5.seconds diff --git a/presentation/src/main/kotlin/team/duckie/app/android/presentation/screen/IntroScreen.kt b/presentation/src/main/kotlin/team/duckie/app/android/presentation/screen/IntroScreen.kt index 76da2906c..3f1f8118b 100644 --- a/presentation/src/main/kotlin/team/duckie/app/android/presentation/screen/IntroScreen.kt +++ b/presentation/src/main/kotlin/team/duckie/app/android/presentation/screen/IntroScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -23,16 +24,16 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import team.duckie.app.android.presentation.R -import team.duckie.app.android.presentation.viewmodel.IntroViewModel +import org.orbitmvi.orbit.compose.collectAsState import team.duckie.app.android.common.android.intent.goToMarket import team.duckie.app.android.common.compose.activityViewModel import team.duckie.app.android.common.compose.systemBarPaddings -import team.duckie.quackquack.ui.color.QuackColor -import team.duckie.quackquack.ui.component.QuackHeadLine1 -import team.duckie.quackquack.ui.component.QuackImage -import org.orbitmvi.orbit.compose.collectAsState import team.duckie.app.android.common.compose.ui.dialog.DuckieDialog +import team.duckie.app.android.presentation.R +import team.duckie.app.android.presentation.viewmodel.IntroViewModel +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.ui.QuackImage +import team.duckie.quackquack.ui.sugar.QuackHeadLine1 @Composable internal fun IntroScreen( @@ -43,7 +44,7 @@ internal fun IntroScreen( Column( modifier = Modifier .fillMaxSize() - .background(color = QuackColor.White.composeColor) + .background(color = QuackColor.White.value) .padding(systemBarPaddings) .padding( top = 78.dp, @@ -60,21 +61,26 @@ internal fun IntroScreen( verticalArrangement = Arrangement.spacedBy(8.dp), ) { QuackImage( - src = team.duckie.quackquack.ui.R.drawable.quack_duckie_text_logo, - size = DpSize( - width = 110.dp, - height = 32.dp, + modifier = Modifier.size( + size = DpSize( + width = 110.dp, + height = 32.dp, + ), ), + src = R.drawable.duckie_text_logo, ) QuackHeadLine1(text = stringResource(R.string.intro_slogan)) } QuackImage( - modifier = Modifier.offset(x = 125.dp), + modifier = Modifier + .size( + size = DpSize( + width = 276.dp, + height = 255.dp, + ), + ) + .offset(x = 125.dp), src = R.drawable.img_duckie_intro, - size = DpSize( - width = 276.dp, - height = 255.dp, - ), ) } diff --git a/presentation/src/main/res/drawable/duckie_text_logo.xml b/presentation/src/main/res/drawable/duckie_text_logo.xml new file mode 100644 index 000000000..3b570b212 --- /dev/null +++ b/presentation/src/main/res/drawable/duckie_text_logo.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 48c59ab18..d474bc720 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -51,7 +51,7 @@ include( ":feature:home", ":feature:start-exam", ":feature:solve-problem", - ":feature:create-problem", + ":feature:create-exam", ":feature:detail", ":feature:exam-result", ":feature:profile",