From 091cd19d1826412fbcd0d28ef2b41bd9b08ee1b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EA=B7=BC=ED=98=95?= Date: Fri, 11 Oct 2024 03:34:41 +0900 Subject: [PATCH] feat: search filter enhancement (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 문제 검색 도움말 * feat: 문제 검색 필터 살짝 낮춤 * fix: desc 오타 수정 * feat: showSolvedProblem으로 수정 * feat: 문제 검색시 태그 on/off * feat: 내가 푼 문제 보지 않기 * feat: 랜덤 문제 검색 * v2.2.0+79 --- lib/features/root/screen/root_screen.dart | 1 + lib/features/search/bloc/search_bloc.dart | 199 +++++++++++---- lib/features/search/bloc/search_event.dart | 32 +-- lib/features/search/bloc/search_state.dart | 19 +- lib/features/search/screen/search_screen.dart | 232 ++++++++++++++++-- .../bloc/search_filter_state.dart | 5 +- .../screen/search_filter_screen.dart | 54 +++- .../src/shared_preferences_repository.dart | 3 - pubspec.yaml | 2 +- 9 files changed, 448 insertions(+), 99 deletions(-) diff --git a/lib/features/root/screen/root_screen.dart b/lib/features/root/screen/root_screen.dart index 96949f8d..fa01d4ca 100644 --- a/lib/features/root/screen/root_screen.dart +++ b/lib/features/root/screen/root_screen.dart @@ -42,6 +42,7 @@ class RootScreen extends StatelessWidget { BlocProvider( create: (context) => SearchBloc( searchRepository: SearchRepository(), + sharedPreferencesRepository: SharedPreferencesRepository(), ), ), BlocProvider( diff --git a/lib/features/search/bloc/search_bloc.dart b/lib/features/search/bloc/search_bloc.dart index 4d2a34fa..995a85be 100644 --- a/lib/features/search/bloc/search_bloc.dart +++ b/lib/features/search/bloc/search_bloc.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:meta/meta.dart'; import 'package:my_solved/features/search_filter/bloc/search_filter_bloc.dart'; import 'package:search_repository/search_repository.dart'; +import 'package:shared_preferences_repository/shared_preferences_repository.dart'; import 'package:solved_api/solved_api.dart'; part 'search_event.dart'; @@ -10,56 +11,168 @@ part 'search_state.dart'; class SearchBloc extends Bloc { final SearchRepository searchRepository; + final SharedPreferencesRepository sharedPreferencesRepository; - SearchBloc({required this.searchRepository}) + SearchBloc( + {required this.searchRepository, + required this.sharedPreferencesRepository}) : super( SearchState( sort: SearchSortMethod.id, direction: SearchDirection.asc, - isNotShowSolvedProblem: true, + showSolvedProblem: true, ), ) { - on( - (event, emit) => emit(state.copyWith( - status: SearchStatus.initial, - text: event.text, - )), - ); - on((event, emit) async { - if (event.text.isNotEmpty) { - emit(state.copyWith(status: SearchStatus.loading)); - - try { - final problems = await searchRepository.getProblems( - event.text, - null, - state.sort.value, - state.direction.value, - ); - final users = await searchRepository.getUsers(event.text, null); - final tags = await searchRepository.getTags(event.text, null); - - emit(state.copyWith( - status: SearchStatus.success, - problems: problems, - users: users, - tags: tags, - )); - } catch (e) { - emit(state.copyWith(status: SearchStatus.failure)); - } - } - }); - on( - (event, emit) => emit(state.copyWith(currentIndex: event.index)), - ); - on( - (event, emit) => emit(state.copyWith(sort: event.sort))); - on( - (event, emit) => emit(state.copyWith(direction: event.direction)), - ); - on( - (event, emit) => emit(state.copyWith(isNotShowSolvedProblem: event.isOn)), + on(_onInit); + on(_searchTextFieldOnChanged); + on(_searchTextFieldOnSummited); + on(_searchSegmentedControlTapped); + on(_searchFilterSortMethodSelected); + on(_searchFilterDirectionSelected); + on( + _searchFilterShowSolvedProblemChanged); + on(_searchFilterShowProblemTagChanged); + on(_searchFilterRandomRerolled); + } + + Future _onInit(SearchInit event, Emitter emit) async { + emit(state.copyWith(status: SearchStatus.loading)); + + try { + final handle = await sharedPreferencesRepository.requestHandle(); + final query = + '${state.text} ${state.showSolvedProblem ? '-s@$handle' : ''}'; + final problems = await searchRepository.getProblems( + query, null, state.sort.value, state.direction.value); + final users = await searchRepository.getUsers(state.text, null); + final tags = await searchRepository.getTags(state.text, null); + + emit(state.copyWith( + status: SearchStatus.success, + problems: problems, + users: users, + tags: tags, + )); + } catch (e) { + emit(state.copyWith(status: SearchStatus.failure)); + } + } + + Future _searchTextFieldOnChanged( + SearchTextFieldOnChanged event, + Emitter emit, + ) async { + emit(state.copyWith( + status: SearchStatus.initial, + text: event.text, + )); + } + + Future _searchTextFieldOnSummited( + SearchTextFieldOnSummited event, Emitter emit) async { + if (event.text.isEmpty) { + emit(state.copyWith(status: SearchStatus.initial)); + return; + } + + emit(state.copyWith(status: SearchStatus.loading)); + + try { + final handle = await sharedPreferencesRepository.requestHandle(); + final query = + '${state.text} ${state.showSolvedProblem ? '-s@$handle' : ''}'; + final problems = await searchRepository.getProblems( + query, null, state.sort.value, state.direction.value); + final users = await searchRepository.getUsers(event.text, null); + final tags = await searchRepository.getTags(event.text, null); + + emit(state.copyWith( + status: SearchStatus.success, + problems: problems, + users: users, + tags: tags, + )); + } catch (e) { + emit(state.copyWith(status: SearchStatus.failure)); + } + } + + Future _searchSegmentedControlTapped( + SearchSegmentedControlTapped event, + Emitter emit, + ) async { + emit(state.copyWith(currentIndex: event.index)); + } + + Future _searchFilterSortMethodSelected( + SearchFilterSortMethodSelected event, + Emitter emit, + ) async { + final handle = await sharedPreferencesRepository.requestHandle(); + final query = + '${state.text} ${state.showSolvedProblem ? '-s@$handle' : ''}'; + final problems = await searchRepository.getProblems( + query, null, event.sort.value, state.direction.value); + + emit(state.copyWith( + problems: problems, sort: event.sort, status: SearchStatus.success)); + } + + Future _searchFilterDirectionSelected( + SearchFilterDirectionSelected event, + Emitter emit, + ) async { + final handle = await sharedPreferencesRepository.requestHandle(); + final query = + '${state.text} ${state.showSolvedProblem ? '-s@$handle' : ''}'; + final problems = await searchRepository.getProblems( + query, null, state.sort.value, event.direction.value); + + emit(state.copyWith( + problems: problems, + direction: event.direction, + status: SearchStatus.success)); + } + + Future _searchFilterShowSolvedProblemChanged( + SearchFilterShowSolvedProblemChanged event, + Emitter emit, + ) async { + final handle = await sharedPreferencesRepository.requestHandle(); + final query = '${state.text} ${event.isOn ? '-s@$handle' : ''}'; + final problems = await searchRepository.getProblems( + query, null, state.sort.value, state.direction.value); + + emit(state.copyWith( + problems: problems, + showSolvedProblem: event.isOn, + status: SearchStatus.success)); + } + + Future _searchFilterShowProblemTagChanged( + SearchFilterShowProblemTagChanged event, + Emitter emit, + ) async { + emit(state.copyWith( + showProblemTag: event.isOn, status: SearchStatus.success)); + } + + Future _searchFilterRandomRerolled( + SearchFilterRandomRerolled event, + Emitter emit, + ) async { + final handle = await sharedPreferencesRepository.requestHandle(); + final query = + '${state.text} ${state.showSolvedProblem ? '-s@$handle' : ''}'; + final problems = await searchRepository.getProblems( + query, + null, + 'random', + state.direction.value, ); + emit(state.copyWith( + sort: SearchSortMethod.random, + problems: problems, + status: SearchStatus.success)); } } diff --git a/lib/features/search/bloc/search_event.dart b/lib/features/search/bloc/search_event.dart index 32af0624..82914396 100644 --- a/lib/features/search/bloc/search_event.dart +++ b/lib/features/search/bloc/search_event.dart @@ -1,58 +1,52 @@ part of 'search_bloc.dart'; @immutable -abstract class SearchEvent extends Equatable {} +abstract class SearchEvent {} + +class SearchInit extends SearchEvent {} class SearchTextFieldOnChanged extends SearchEvent { final String text; SearchTextFieldOnChanged({required this.text}); - - @override - List get props => [text]; } class SearchTextFieldOnSummited extends SearchEvent { final String text; SearchTextFieldOnSummited({required this.text}); - - @override - List get props => [text]; } class SearchSegmentedControlTapped extends SearchEvent { final int index; SearchSegmentedControlTapped({required this.index}); - - @override - List get props => [index]; } class SearchFilterSortMethodSelected extends SearchEvent { final SearchSortMethod sort; SearchFilterSortMethodSelected({required this.sort}); - - @override - List get props => [sort]; } class SearchFilterDirectionSelected extends SearchEvent { final SearchDirection direction; SearchFilterDirectionSelected({required this.direction}); +} + +class SearchFilterShowSolvedProblemChanged extends SearchEvent { + final bool isOn; - @override - List get props => [direction]; + SearchFilterShowSolvedProblemChanged({required this.isOn}); } -class SearchFilterIsNotShowSolvedProblemChanged extends SearchEvent { +class SearchFilterShowProblemTagChanged extends SearchEvent { final bool isOn; - SearchFilterIsNotShowSolvedProblemChanged({required this.isOn}); + SearchFilterShowProblemTagChanged({required this.isOn}); +} - @override - List get props => [isOn]; +class SearchFilterRandomRerolled extends SearchEvent { + SearchFilterRandomRerolled(); } diff --git a/lib/features/search/bloc/search_state.dart b/lib/features/search/bloc/search_state.dart index acf88ff2..5fb64369 100644 --- a/lib/features/search/bloc/search_state.dart +++ b/lib/features/search/bloc/search_state.dart @@ -4,8 +4,11 @@ enum SearchStatus { initial, loading, success, failure } extension SearchStatusX on SearchStatus { bool get isInitial => this == SearchStatus.initial; + bool get isLoading => this == SearchStatus.loading; + bool get isSuccess => this == SearchStatus.success; + bool get isFailure => this == SearchStatus.failure; } @@ -13,7 +16,8 @@ class SearchState extends Equatable { final SearchStatus status; final SearchSortMethod sort; final SearchDirection direction; - final bool isNotShowSolvedProblem; + final bool showSolvedProblem; + final bool showProblemTag; final String text; final int currentIndex; final SearchObject? problems; @@ -24,7 +28,8 @@ class SearchState extends Equatable { this.status = SearchStatus.initial, required this.sort, required this.direction, - required this.isNotShowSolvedProblem, + this.showSolvedProblem = false, + this.showProblemTag = false, this.text = "", this.currentIndex = 0, this.problems, @@ -36,7 +41,8 @@ class SearchState extends Equatable { SearchStatus? status, SearchSortMethod? sort, SearchDirection? direction, - bool? isNotShowSolvedProblem, + bool? showSolvedProblem, + bool? showProblemTag, String? text, int? currentIndex, SearchObject? problems, @@ -47,8 +53,8 @@ class SearchState extends Equatable { status: status ?? this.status, sort: sort ?? this.sort, direction: direction ?? this.direction, - isNotShowSolvedProblem: - isNotShowSolvedProblem ?? this.isNotShowSolvedProblem, + showSolvedProblem: showSolvedProblem ?? this.showSolvedProblem, + showProblemTag: showProblemTag ?? this.showProblemTag, text: text ?? this.text, currentIndex: currentIndex ?? this.currentIndex, problems: problems ?? this.problems, @@ -62,7 +68,8 @@ class SearchState extends Equatable { status, sort, direction, - isNotShowSolvedProblem, + showSolvedProblem, + showProblemTag, text, currentIndex, problems, diff --git a/lib/features/search/screen/search_screen.dart b/lib/features/search/screen/search_screen.dart index 9f77a104..3027f46c 100644 --- a/lib/features/search/screen/search_screen.dart +++ b/lib/features/search/screen/search_screen.dart @@ -82,14 +82,8 @@ class _SearchViewState extends State { onPressed: () { showModalBottomSheet( context: context, - isDismissible: true, - isScrollControlled: true, - builder: (contest) => SizedBox( - height: - MediaQuery.of(context).size.height * 0.9, - child: SearchFilterScreen( - searchBLoc: context.read(), - ), + builder: (contest) => SearchFilterScreen( + searchBLoc: context.read(), ), ); }, @@ -130,7 +124,176 @@ class _SearchViewState extends State { } }, builder: (context, state) { - if (state.status.isLoading) { + if (state.status.isInitial) { + return Column( + children: [ + SizedBox(height: 16), + Text('문제 고급 검색', style: MySolvedTextStyle.title5), + Text('이 기능은 문제 검색에만 적용됩니다.', + style: MySolvedTextStyle.caption1), + SizedBox(height: 16), + DataTable( + horizontalMargin: 16, + columnSpacing: 16, + headingTextStyle: TextStyle( + color: MySolvedColor.font, + fontFamily: MySolvedFont.pretendard, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + dataTextStyle: TextStyle( + color: MySolvedColor.font, + fontFamily: MySolvedFont.pretendard, + fontWeight: FontWeight.w400, + fontSize: 10, + ), + headingRowHeight: 32, + columns: [ + DataColumn(label: Text("연산자")), + DataColumn(label: Text("예시")), + ], + rows: [ + _dataRow( + operator: '""', + function: "정확히 일치", + example: '"A+B - 2"', + description: '문제 이름에 "A+B - 2"가 포함되는 문제들', + ), + _dataRow( + operator: '()', + function: "우선순위 지정자", + example: 'A+B (2 | 3)', + description: + '문제 이름에 "A+B"가 포함되고 "2" 또는 "3"이 포함되는 문제들', + ), + _dataRow( + operator: '-, !, ~', + function: "포함하지 않음\n('not' 연산)", + example: '*s..g -@\$me', + description: + 'Silver 이상 Gold 이하인 문제들 중 내가 풀지 않은 문제들', + ), + _dataRow( + operator: "&", + function: "모두 포함\n('and' 연산)", + example: '#dp #math', + description: "다이나믹 프로그래밍과 수학 두 태그 모두를 갖는 문제들", + ), + _dataRow( + operator: "|", + function: "하나 이상 포함\n('or' 연산)", + example: '#greedy | #ad_hoc', + description: "그리디 알고리즘 또는 애드 혹 태그를 갖는 문제들", + ), + _dataRow( + operator: "*, tier:", + function: "난이도", + example: '*g4..g1', + description: "난이도가 Gold IV 이상 Gold I 이하인 문제들", + ), + _dataRow( + operator: "id:", + function: "문제 번호", + example: '*id:1000..2000', + description: "문제 번호가 1000 이상 2000 이하인 문제들", + ), + _dataRow( + operator: "s#, solved:", + function: "푼 사람 수", + example: 's#1000..', + description: "1,000명 이상이 푼 문제들"), + _dataRow( + operator: "#, tag:", + function: "태그", + example: '#dp', + description: "다이나믹 프로그래밍 태그를 갖는 문제들", + ), + _dataRow( + operator: "/, from:", + function: "문제 출처", + example: '/ucpc2022', + description: "UCPC 2022 본선 문제들", + ), + _dataRow( + operator: "t#, average_try:, µ#", + function: "평균 시도 횟수", + example: 't#1..2', + description: "평균 시도 횟수가 1회 이상 2회 이하인 문제들", + ), + _dataRow( + operator: "%, lang:", + function: "언어", + example: '%ko', + description: "한국어로 작성된 문제들", + ), + _dataRow( + operator: "@, solved_by:, s@", + function: "유저가 푼 문제", + example: '@shiftpsh', + description: "shiftpsh가 푼 문제들", + ), + _dataRow( + operator: "t@, tried_by:", + function: "유저가 시도한 문제", + example: 't@shiftpsh', + description: "shiftpsh가 시도한 문제들", + ), + _dataRow( + operator: "v@, voted_by:, c@, contributed_by:", + function: "유저가 기여한 문제", + example: 'v@shiftpsh', + description: "shiftpsh가 기여한 문제들", + ), + _dataRow( + operator: "c/, in_class:", + function: "CLASS 단계 안의 문제", + example: 'c/1', + description: "CLASS 1 단계의 문제들", + ), + _dataRow( + operator: "e/, in_class_essentials:", + function: "CLASS 단계 안의 에센셜 문제", + example: 'e/1', + description: "CLASS 1 에센셜 문제들", + ), + _dataRow( + operator: "s?, standard:", + function: "난이도 표준 문제", + example: 's?true', + description: "난이도 표준 문제들", + ), + _dataRow( + operator: "p?, sprout: sp?", + function: "새싹 난이도 문제", + example: 'p?true', + description: "새싹 난이도 문제들", + ), + _dataRow( + operator: "o?, solvable", + function: "풀 수 있는 문제", + example: "o?true", + description: "풀 수 있는 문제들"), + _dataRow( + operator: "v?, votable:, c?, contributable:", + function: "기여할 수 있는 문제", + example: "v?true", + description: "기여할 수 있는 문제들"), + _dataRow( + operator: "w?, warning:", + function: "문제해결 경고", + example: "w?true", + description: "문제해결 경고가 있는 문제들", + ), + _dataRow( + operator: "v#, voted:, c#, contributed:", + function: "기여한 사람 수", + example: "v#100..", + description: "100명 이상이 기여한 문제들", + ), + ]), + ], + ); + } else if (state.status.isLoading) { return Center( child: CircularProgressIndicator(color: MySolvedColor.main), ); @@ -197,20 +360,21 @@ class _SearchViewState extends State { style: MySolvedTextStyle.body1, ), SizedBox(height: 8), - Wrap( - children: List.generate( - state.problems!.items[index].tags - .length, - (subIndex) => Text( - "#${state.problems!.items[index].tags[subIndex].displayNames[0]["name"]} ", - style: MySolvedTextStyle.caption1 - .copyWith( - color: - MySolvedColor.secondaryFont, + if (state.showProblemTag) + Wrap( + children: List.generate( + state.problems!.items[index].tags + .length, + (subIndex) => Text( + "#${state.problems!.items[index].tags[subIndex].displayNames[0]["name"]} ", + style: MySolvedTextStyle.caption1 + .copyWith( + color: + MySolvedColor.secondaryFont, + ), ), ), ), - ), ], ), ), @@ -341,3 +505,31 @@ class SearchHeaderDelegate extends SliverPersistentHeaderDelegate { return false; } } + +DataRow _dataRow({ + required String operator, + required String function, + required String example, + required String description, +}) { + return DataRow( + cells: [ + DataCell(Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(operator), + Text(function, style: MySolvedTextStyle.caption2), + ], + )), + DataCell(Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(example), + Text(description, style: MySolvedTextStyle.caption2), + ], + )), + ], + ); +} diff --git a/lib/features/search_filter/bloc/search_filter_state.dart b/lib/features/search_filter/bloc/search_filter_state.dart index 76b7abbd..d3817a79 100644 --- a/lib/features/search_filter/bloc/search_filter_state.dart +++ b/lib/features/search_filter/bloc/search_filter_state.dart @@ -5,7 +5,8 @@ enum SearchSortMethod { level("level", "레벨"), title("title", "제목"), solved("solved", "푼 사람 수"), - averageTry("average_try", "평균 시도"); + averageTry("average_try", "평균 시도"), + random("random", "랜덤"); const SearchSortMethod(this.value, this.displayName); @@ -23,7 +24,7 @@ enum SearchSortMethod { enum SearchDirection { asc("asc", "오름차순"), - desc("decs", "내림차순"); + desc("desc", "내림차순"); const SearchDirection(this.value, this.displayName); diff --git a/lib/features/search_filter/screen/search_filter_screen.dart b/lib/features/search_filter/screen/search_filter_screen.dart index 028807e2..454297f9 100644 --- a/lib/features/search_filter/screen/search_filter_screen.dart +++ b/lib/features/search_filter/screen/search_filter_screen.dart @@ -127,7 +127,7 @@ class _SearchFilterViewState extends State { ), ], ), - SizedBox(height: 16), + SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -136,10 +136,9 @@ class _SearchFilterViewState extends State { bloc: widget.searchBLoc, builder: (context, state) { return Switch( - value: state.isNotShowSolvedProblem, + value: state.showSolvedProblem, onChanged: (isOn) => widget.searchBLoc.add( - SearchFilterIsNotShowSolvedProblemChanged( - isOn: isOn)), + SearchFilterShowSolvedProblemChanged(isOn: isOn)), activeColor: MySolvedColor.background, activeTrackColor: MySolvedColor.main, inactiveThumbColor: MySolvedColor.background, @@ -158,7 +157,52 @@ class _SearchFilterViewState extends State { ), ], ), - SizedBox(height: 24), + SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("문제 태그 보지 않기", style: MySolvedTextStyle.body2), + BlocBuilder( + bloc: widget.searchBLoc, + builder: (context, state) { + return Switch( + value: !state.showProblemTag, + onChanged: (isOn) => widget.searchBLoc.add( + SearchFilterShowProblemTagChanged(isOn: !isOn)), + activeColor: MySolvedColor.background, + activeTrackColor: MySolvedColor.main, + inactiveThumbColor: MySolvedColor.background, + inactiveTrackColor: + MySolvedColor.disabledButtonBackground, + trackOutlineColor: MaterialStateProperty.resolveWith( + (states) { + if (states.contains(MaterialState.selected)) { + return null; + } + return MySolvedColor.disabledButtonBackground; + }, + ), + ); + }, + ), + ], + ), + SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("랜덤", style: MySolvedTextStyle.body2), + IconButton( + onPressed: () { + widget.searchBLoc.add(SearchFilterRandomRerolled()); + }, + icon: Icon( + Icons.casino, + color: MySolvedColor.disabledButtonForeground, + )) + ], + ), + SizedBox(height: 16), ], ), ), diff --git a/packages/repositories/shared_preferences_repository/lib/src/shared_preferences_repository.dart b/packages/repositories/shared_preferences_repository/lib/src/shared_preferences_repository.dart index d95e0d39..8832c7b9 100644 --- a/packages/repositories/shared_preferences_repository/lib/src/shared_preferences_repository.dart +++ b/packages/repositories/shared_preferences_repository/lib/src/shared_preferences_repository.dart @@ -15,17 +15,14 @@ class SharedPreferencesRepository { final String _isOnIllustBackgroundKey = "is_on_illust_background"; Future requestHandle() async { - await Future.delayed(const Duration(seconds: 1)); return await _sharedPreferencesApiClient.getString(key: _handleKey); } Future login({required String handle}) async { - await Future.delayed(const Duration(seconds: 1)); await _sharedPreferencesApiClient.setString(key: _handleKey, value: handle); } Future logout() async { - await Future.delayed(const Duration(seconds: 1)); await _sharedPreferencesApiClient.removeByKey(key: _handleKey); } diff --git a/pubspec.yaml b/pubspec.yaml index 430c8915..91c6f69b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: For solving problems in the world of programming; base on solved.ac publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: 2.1.0+78 +version: 2.2.0+79 environment: sdk: ">=2.18.2 <3.0.0"