diff --git a/integration_test/helpers/e2e_app.dart b/integration_test/helpers/e2e_app.dart index fc44e035..8e2736f6 100644 --- a/integration_test/helpers/e2e_app.dart +++ b/integration_test/helpers/e2e_app.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ono/main.dart' as app; +import 'package:ono/Util/AppErrorReporter.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// 앱을 처음 설치한 상태로 띄우고 [body] 를 돌린다. @@ -30,6 +31,8 @@ Future runFreshApp( await prefs.clear(); final testOnError = FlutterError.onError; + // 테스트의 실패와 시드 데이터가 Sentry 와 Discord 에 운영 이슈처럼 쌓였다. + AppErrorReporter.enabled = false; unawaited(app.main()); try { await body(); diff --git a/lib/Model/Problem/ProblemModel.dart b/lib/Model/Problem/ProblemModel.dart index 208a73f8..7380a962 100644 --- a/lib/Model/Problem/ProblemModel.dart +++ b/lib/Model/Problem/ProblemModel.dart @@ -11,6 +11,12 @@ class ProblemModel { final DateTime? solvedAt; final DateTime? lastSolvedAt; final DateTime? createdAt; + + /// 화면에 적을 푼 날짜. + /// + /// 서버는 푼 날짜 없이도 문제를 저장한다. 그런 문제에서 `solvedAt!` 을 쓰면 + /// 상세 화면이 통째로 멈췄다. 없으면 적은 날짜로, 그것도 없으면 오늘로 둔다. + DateTime get displaySolvedAt => solvedAt ?? createdAt ?? DateTime.now(); final DateTime? updateAt; final int solveCount; diff --git a/lib/Module/Dialog/ThemeDialog.dart b/lib/Module/Dialog/ThemeDialog.dart index 68f0bbc8..30a2755e 100644 --- a/lib/Module/Dialog/ThemeDialog.dart +++ b/lib/Module/Dialog/ThemeDialog.dart @@ -276,6 +276,7 @@ class _ThemeDialogState extends State { mainAxisSize: MainAxisSize.min, children: [ _buildPreview(userInfo, duration), + const Divider(height: 1, color: AppColors.border), _buildLaneHeader(userInfo), Flexible(child: _buildTracks(userInfo, duration)), _buildFooter(themeProvider, duration), @@ -359,14 +360,13 @@ class _ThemeDialogState extends State { AppSpacing.xl, AppSpacing.lg, ), - decoration: BoxDecoration( - // 그라데이션을 깔지 않는다. 색 24개가 주인공인 화면에서 배경까지 - // 번지면 무엇을 고르는 중인지가 흐려진다. - color: Color.alphaBlend( - accent.withValues(alpha: 0.10), - AppColors.surface, - ), - borderRadius: const BorderRadius.only( + decoration: const BoxDecoration( + // 배경은 칠하지 않는다. 고른 색이나 미션 칸 색을 옅게 깔았더니 + // 누를 때마다 판 전체가 분홍, 보라, 초록, 파랑으로 바뀌어 색 24개가 + // 주인공인 화면에서 무엇을 고르는 중인지가 흐려졌다. 아래 격자와는 + // 선 하나로만 나눈다. + color: AppColors.surface, + borderRadius: BorderRadius.only( topLeft: Radius.circular(AppRadius.xlarge), topRight: Radius.circular(AppRadius.xlarge), ), @@ -897,7 +897,7 @@ class _ThemeCell extends StatelessWidget { peak: 1.12, child: isUnlocked ? _buildUnlockedSwatch(color) - : _buildLockedSwatch(color, colors, requiredLevel), + : _buildLockedSwatch(color, requiredLevel), ), ), ), @@ -950,12 +950,11 @@ class _ThemeCell extends StatelessWidget { } /// 잠긴 색. 필요한 레벨이 칸 안에 적혀 있다. - Widget _buildLockedSwatch( - Color color, - MissionKindColors colors, - int requiredLevel, - ) { + Widget _buildLockedSwatch(Color color, int requiredLevel) { final highlight = isNextGoal || isInspected; + // 테두리는 미션 칸의 색이 아니라 그 테마색을 조금 진하게 쓴다. 칸 색을 + // 쓰면 출석은 빨강, 오답노트는 보라처럼 테마와 상관없는 색이 둘러졌다. + final edge = ThemeHandler.darkenColor(color, amount: 0.15); return AnimatedContainer( duration: duration, @@ -967,9 +966,9 @@ class _ThemeCell extends StatelessWidget { color: _lockedTint(color, highlight ? 0.34 : 0.20), border: Border.all( color: isInspected - ? colors.accent + ? edge : (isNextGoal - ? colors.accent.withValues(alpha: 0.55) + ? edge.withValues(alpha: 0.55) : Colors.transparent), width: isInspected ? 2 : 1.5, ), @@ -983,7 +982,7 @@ class _ThemeCell extends StatelessWidget { text: 'Lv.$requiredLevel', fontSize: 9, fontWeight: FontWeight.w700, - color: highlight ? colors.accent : AppColors.textTertiary, + color: highlight ? edge : AppColors.textTertiary, maxLines: 1, ), ), diff --git a/lib/Module/Emoji/MoodPicker.dart b/lib/Module/Emoji/MoodPicker.dart new file mode 100644 index 00000000..a4bad889 --- /dev/null +++ b/lib/Module/Emoji/MoodPicker.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; + +import '../Design/AppColors.dart'; +import '../Design/AppRadius.dart'; +import '../Motion/AppHaptic.dart'; +import '../Motion/AppMotion.dart'; +import '../Motion/PressableScale.dart'; +import 'OnoEmoji.dart'; +import 'OnoEmojiCatalog.dart'; +import 'OnoEmojiImage.dart'; +import 'OnoEmojiPicker.dart'; + +/// 복습을 마치고 기분을 고르는 가로 줄. +/// +/// 복습 세트 완료 화면과 문제 하나의 복습 인증 화면이 같이 쓴다. 두 곳에 +/// 같은 코드가 따로 있어서 한쪽만 고치면 다른 쪽에 같은 결함이 남았다. +/// +/// 추천 칸 뒤에 `...` 칸이 있고, 누르면 전체 이모지에서 고른다. 거기서 +/// 추천에 없는 것을 고르면 어느 칸도 골라진 모양이 되지 않아서 다이얼로그를 +/// 닫은 뒤 무엇을 골랐는지 알 수 없었다. 그런 것은 줄 맨 앞에 칸을 하나 더 +/// 세워 골라 둔 모양으로 보여 주고, 줄도 처음으로 되돌린다. `...` 칸은 +/// 줄 맨 끝이라 폰에서는 화면 밖이기 때문이다. +class MoodPickerRow extends StatefulWidget { + /// 줄에 먼저 세울 이모지. 여기 없는 것은 `...` 에서 고른다. + static const List recommendedKeys = [ + 'success_checkmark', + 'got_100_score', + 'fired_up_sparkle_eyes', + 'happy_tears', + 'frustrated_studying', + 'dizzy_spiral_eyes2', + 'sleeping_blanket', + ]; + + final String? selectedKey; + + /// 고른 것이 바뀌면 불린다. 고른 칸을 다시 누르면 해제되어 null 이 온다. + final ValueChanged onChanged; + + final Color color; + + const MoodPickerRow({ + super.key, + required this.selectedKey, + required this.onChanged, + required this.color, + }); + + @override + State createState() => _MoodPickerRowState(); +} + +class _MoodPickerRowState extends State { + final ScrollController _scrollController = ScrollController(); + + /// `...` 에서 고른, 추천에 없는 이모지. 해제해도 칸은 남겨 둔다. 칸이 + /// 사라지면 누른 자리 아래로 옆 칸이 밀려 들어와 헷갈린다. + String? _extraKey; + + @override + void initState() { + super.initState(); + final selected = widget.selectedKey; + if (selected != null && !MoodPickerRow.recommendedKeys.contains(selected)) { + _extraKey = selected; + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + List get _keys => [ + if (_extraKey != null) _extraKey!, + ...MoodPickerRow.recommendedKeys, + ]; + + void _openPicker() { + OnoEmojiPicker.show( + context, + selectedKey: widget.selectedKey, + onSelected: _onPicked, + ); + } + + void _onPicked(OnoEmoji emoji) { + if (!mounted) return; + if (!MoodPickerRow.recommendedKeys.contains(emoji.key)) { + setState(() => _extraKey = emoji.key); + } + widget.onChanged(emoji.key); + if (_scrollController.hasClients) { + _scrollController.animateTo( + 0, + duration: AppMotion.normal, + curve: AppMotion.standard, + ); + } + } + + @override + Widget build(BuildContext context) { + final keys = _keys; + + return SizedBox( + height: 82, + child: ListView.separated( + controller: _scrollController, + scrollDirection: Axis.horizontal, + itemCount: keys.length + 1, + separatorBuilder: (_, __) => const SizedBox(width: 10), + itemBuilder: (context, index) { + if (index == keys.length) return _buildMoreButton(); + + final emoji = OnoEmojiCatalog.byKey(keys[index]); + if (emoji == null) return const SizedBox.shrink(); + return _buildMoodTile(emoji); + }, + ), + ); + } + + Widget _buildMoodTile(OnoEmoji emoji) { + final isSelected = widget.selectedKey == emoji.key; + + return PressableScale( + haptic: HapticLevel.selection, + // 고른 것을 다시 누르면 해제된다. 안 고르고 넘어가는 것도 그대로 + // 되어야 해서 되돌릴 길을 열어 둔다. + onTap: () => widget.onChanged(isSelected ? null : emoji.key), + child: Container( + width: 70, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: isSelected + ? widget.color.withValues(alpha: 0.1) + : Colors.grey[50], + borderRadius: BorderRadius.circular(AppRadius.medium), + border: Border.all( + color: isSelected ? widget.color : AppColors.border, + ), + ), + child: Column( + children: [ + OnoEmojiImage(emoji: emoji, size: 54), + const SizedBox(height: 4), + Container( + width: 5, + height: 5, + decoration: BoxDecoration( + color: isSelected ? widget.color : Colors.transparent, + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ); + } + + Widget _buildMoreButton() { + return PressableScale( + onTap: _openPicker, + child: Container( + width: 70, + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(AppRadius.medium), + border: Border.all(color: AppColors.border), + ), + child: Icon(Icons.more_horiz, color: widget.color), + ), + ); + } +} + +/// 지금 고른 기분을 제목 줄 오른쪽에 보여 주는 그림. +/// +/// 칸 줄은 가로로 넘어가서 고른 칸이 화면 밖에 있을 수 있다. 어디서 +/// 골랐든 제목 옆 한 곳에서 보인다. 이름은 적지 않고 그림만 둔다. +/// 스크린 리더는 이름을 읽는다. 고른 것이 없으면 자리를 차지하지 않는다. +class SelectedMoodChip extends StatelessWidget { + final String? selectedKey; + final Color color; + + const SelectedMoodChip({ + super.key, + required this.selectedKey, + required this.color, + }); + + @override + Widget build(BuildContext context) { + final key = selectedKey; + final emoji = key == null ? null : OnoEmojiCatalog.byKey(key); + + return AnimatedSwitcher( + duration: AppMotion.fast, + child: emoji == null + ? const SizedBox.shrink() + : Semantics( + key: ValueKey(emoji.key), + label: '고른 기분: ${emoji.label}', + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: OnoEmojiImage(emoji: emoji, size: 30), + ), + ), + ); + } +} diff --git a/lib/Module/Motion/HandwritingReveal.dart b/lib/Module/Motion/HandwritingReveal.dart index 93a1495b..5bdbe0e9 100644 --- a/lib/Module/Motion/HandwritingReveal.dart +++ b/lib/Module/Motion/HandwritingReveal.dart @@ -182,7 +182,10 @@ class _HandwritingRevealState extends State alignment: Alignment.centerLeft, clipBehavior: Clip.none, children: [ - _mask(stroke.progress, text), + // 다 쓰고 나면 가리개를 걷어 낸다. 남겨 두면 흐린 끝자락이 + // 문구 오른쪽 끝에 그대로 덮여 있어서 마지막 글자(닫는 따옴표)만 + // 흐릿하게 보였다. + if (stroke.progress >= 1) text else _mask(stroke.progress, text), if (widget.underline) Positioned.fill( child: CustomPaint( @@ -253,8 +256,11 @@ class _HandwritingRevealState extends State return ShaderMask( blendMode: BlendMode.dstIn, shaderCallback: (bounds) { - final edge = progress.clamp(0.0, 1.0); - final soft = (edge - _softEdge).clamp(0.0, 1.0); + // 흐린 끝자락까지 상자 밖으로 밀어내야 다 썼을 때 마지막 글자가 + // 온전히 드러난다. progress 를 그대로 쓰면 1 이 되어도 오른쪽 끝 + // [_softEdge] 만큼이 흐린 채로 남았다. + final edge = (progress * (1 + _softEdge)).clamp(0.0, 1.0); + final soft = (progress * (1 + _softEdge) - _softEdge).clamp(0.0, 1.0); return LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, diff --git a/lib/Provider/PracticeNoteProvider.dart b/lib/Provider/PracticeNoteProvider.dart index e6a121f1..bb3efcdc 100644 --- a/lib/Provider/PracticeNoteProvider.dart +++ b/lib/Provider/PracticeNoteProvider.dart @@ -106,10 +106,19 @@ class ProblemPracticeProvider with ChangeNotifier { notifyListeners(); } + /// 가장 최근에 시작한 [moveToPractice] 의 번호. + /// + /// 복습 세트를 열어 두고 불러오는 사이에 다른 세트를 열면 두 불러오기가 + /// 겹친다. 늦게 끝난 쪽이 먼저 연 세트라면 그 결과로 덮지 않는다. + int _moveGeneration = 0; + Future moveToPractice(int practiceId) async { + final generation = ++_moveGeneration; final targetPractice = await getPracticeNote(practiceId); - currentProblems.clear(); + // 다 모은 뒤에 한 번에 바꾼다. 공유 목록에 바로 넣으면 겹친 불러오기가 + // 서로의 문제를 섞어 넣는다. + final loadedProblems = []; // 복습 세트의 각 문제를 서버에서 조회 (지연 로딩 대응) for (var problemId in targetPractice.problemIdList) { @@ -124,7 +133,7 @@ class ProblemPracticeProvider with ChangeNotifier { await problemsProvider.fetchProblem(problemId); problemModel = await problemsProvider.getProblem(problemId); } - currentProblems.add(problemModel); + loadedProblems.add(problemModel); } catch (e, stackTrace) { debugPrint('Error loading problem $problemId: $e'); debugPrint('Stack trace: $stackTrace'); @@ -138,8 +147,11 @@ class ProblemPracticeProvider with ChangeNotifier { } } + if (generation != _moveGeneration) return; + debugPrint( - 'Moved to practice: $practiceId, loaded ${currentProblems.length}/${targetPractice.problemIdList.length} problems'); + 'Moved to practice: $practiceId, loaded ${loadedProblems.length}/${targetPractice.problemIdList.length} problems'); + currentProblems = loadedProblems; currentPracticeNote = targetPractice; notifyListeners(); } diff --git a/lib/Provider/StudyRoomProvider.dart b/lib/Provider/StudyRoomProvider.dart index 6ba148df..a8fc57d0 100644 --- a/lib/Provider/StudyRoomProvider.dart +++ b/lib/Provider/StudyRoomProvider.dart @@ -11,6 +11,7 @@ import '../Model/StudyRoom/StudyRoomModel.dart'; import '../Model/StudyRoom/WeeklyReportModel.dart'; import '../Module/Emoji/OnoEmojiCatalog.dart'; import '../Service/Api/StudyRoom/StudyRoomService.dart'; +import '../Util/AppErrorReporter.dart'; class StudyRoomProvider extends ChangeNotifier { final StudyRoomService _service; @@ -247,6 +248,21 @@ class StudyRoomProvider extends ChangeNotifier { } } + /// 반응을 누른 곳은 결과를 기다리지 않는다. 여기서 받지 않으면 예외가 + /// 끝까지 올라가 fatal 로 보고됐다. 사용자에게는 HttpService 가 스낵바로 + /// 이미 알렸고, 반응은 누르기 전 그대로 남는다. + Future _reportReactionFailure( + Object error, + StackTrace stackTrace, + ) { + return AppErrorReporter.report( + error, + stackTrace, + source: 'study_room_reaction_toggle', + severity: AppErrorSeverity.warning, + ); + } + String _normalizeEmojiKey(String emoji) { return OnoEmojiCatalog.byKey(emoji)?.key ?? emoji; } @@ -258,11 +274,16 @@ class StudyRoomProvider extends ChangeNotifier { final roomId = selectedRoom?.roomId ?? _activeRoomId; if (roomId == null) return; - feed.reactions = await _service.toggleFeedReaction( - roomId: roomId, - feedId: feedId, - emoji: emojiKey, - ); + try { + feed.reactions = await _service.toggleFeedReaction( + roomId: roomId, + feedId: feedId, + emoji: emojiKey, + ); + } catch (error, stackTrace) { + await _reportReactionFailure(error, stackTrace); + return; + } FirebaseAnalytics.instance.logEvent(name: 'feed_reaction_toggled'); notifyListeners(); } @@ -379,11 +400,16 @@ class StudyRoomProvider extends ChangeNotifier { final emojiKey = _normalizeEmojiKey(emoji); final roomId = selectedRoom?.roomId ?? _activeRoomId; if (roomId == null) return; - shared.reactions = await _service.toggleSharedProblemReaction( - roomId: roomId, - sharedProblemId: sharedProblemId, - emoji: emojiKey, - ); + try { + shared.reactions = await _service.toggleSharedProblemReaction( + roomId: roomId, + sharedProblemId: sharedProblemId, + emoji: emojiKey, + ); + } catch (error, stackTrace) { + await _reportReactionFailure(error, stackTrace); + return; + } FirebaseAnalytics.instance.logEvent(name: 'problem_reaction_toggled'); notifyListeners(); } diff --git a/lib/Screen/Achievement/Widget/AchievementCard.dart b/lib/Screen/Achievement/Widget/AchievementCard.dart index 69587f29..ce982b26 100644 --- a/lib/Screen/Achievement/Widget/AchievementCard.dart +++ b/lib/Screen/Achievement/Widget/AchievementCard.dart @@ -194,6 +194,6 @@ class AchievementCard extends StatelessWidget { } // 0 아니면 1인 훈장. 눈금을 세울 것이 없어서 한 번이면 된다고 적는다. - return '한 번만 해내면 받아요'; + return '1번만 해내면 받아요'; } } diff --git a/lib/Screen/Character/Widget/AbilityGuideSheet.dart b/lib/Screen/Character/Widget/AbilityGuideSheet.dart index ba1a593c..ae4eca0c 100644 --- a/lib/Screen/Character/Widget/AbilityGuideSheet.dart +++ b/lib/Screen/Character/Widget/AbilityGuideSheet.dart @@ -28,28 +28,28 @@ class AbilityGuideSheet extends StatelessWidget { /// 알 수 없다. 한도까지 같이 적는다. static const Map _guides = { MissionKind.attendance: _AbilityGuide( - how: '하루에 한 번 앱을 열면', + how: '하루에 1번 앱을 열면', point: 15, - limit: '하루 한 번까지', - hint: '오늘 들어온 것만으로 쌓여요. 몇 번을 열어도 하루치는 한 번이에요.', + limit: '하루 1번까지', + hint: '오늘 들어온 것만으로 쌓여요. 몇 번을 열어도 하루치는 1번이에요.', ), MissionKind.noteWrite: _AbilityGuide( - how: '오답노트를 한 개 등록하면', + how: '오답노트를 1개 등록하면', point: 10, - limit: '하루 세 개까지', - hint: '한 번에 여러 개를 올려도 하루에 세 개까지만 쌓여요.', + limit: '하루 3개까지', + hint: '한 번에 여러 개를 올려도 하루에 3개까지만 쌓여요.', ), MissionKind.problemPractice: _AbilityGuide( - how: '문제를 한 개 복습하면', + how: '문제를 1개 복습하면', point: 5, - limit: '문제마다 한 번까지', - hint: '같은 문제를 다시 풀어도 처음 한 번만 쌓여요. 새 문제를 풀면 또 쌓여요.', + limit: '문제마다 1번까지', + hint: '같은 문제를 다시 풀어도 처음 1번만 쌓여요. 새 문제를 풀면 또 쌓여요.', ), MissionKind.notePractice: _AbilityGuide( - how: '복습 세트를 한 개 끝내면', + how: '복습 세트를 1개 끝내면', point: 15, - limit: '세트마다 한 번까지', - hint: '같은 세트를 다시 끝내도 처음 한 번만 쌓여요.', + limit: '세트마다 1번까지', + hint: '같은 세트를 다시 끝내도 처음 1번만 쌓여요.', ), MissionKind.etc: _AbilityGuide( how: '앱을 쓰면', diff --git a/lib/Screen/Cosmetic/CosmeticCombinationPreviewScreen.dart b/lib/Screen/Cosmetic/CosmeticCombinationPreviewScreen.dart index e0106010..9e5742b1 100644 --- a/lib/Screen/Cosmetic/CosmeticCombinationPreviewScreen.dart +++ b/lib/Screen/Cosmetic/CosmeticCombinationPreviewScreen.dart @@ -181,7 +181,7 @@ class CosmeticCombinationPreviewScreen extends StatelessWidget { _ComboSection _levelSection(CosmeticProvider cosmetic) { return _ComboSection( title: '레벨별 기본 차림', - description: '다섯 레벨을 같이 올릴 때 개구리가 어떻게 달라지는지', + description: '능력치 5개의 레벨을 같이 올릴 때 개구리가 어떻게 달라지는지', combos: [ for (var level = 1; level <= cosmetic.maxTotalStudyLevel; level++) _Combo( diff --git a/lib/Screen/Cosmetic/Widget/CosmeticAbilityStyle.dart b/lib/Screen/Cosmetic/Widget/CosmeticAbilityStyle.dart index 315c1f47..a0050704 100644 --- a/lib/Screen/Cosmetic/Widget/CosmeticAbilityStyle.dart +++ b/lib/Screen/Cosmetic/Widget/CosmeticAbilityStyle.dart @@ -44,21 +44,18 @@ abstract final class CosmeticAbilityStyle { static String requirementOf(CosmeticItemModel item) => cosmeticRequirementLabel(item); - /// 지금 내가 그 능력치에서 몇 레벨이고 몇 레벨이 남았는지. + /// 이 아이템을 얻기까지 몇 레벨이 남았는지. /// - /// 필요 레벨만 적어 두면 그것이 코앞인지 한참 남았는지 알 수 없다. 지금 - /// 레벨을 옆에 붙이면 `한 레벨만 더` 가 생긴다. 한 칸 차이일 때 그 말을 - /// 그대로 쓰는 이유다. + /// 필요 레벨만 적어 두면 그것이 코앞인지 한참 남았는지 알 수 없어서 남은 + /// 거리를 적는다. 지금 레벨은 적지 않는다. 바로 윗줄의 `문제 복습 Lv.4` 와 + /// 나란히 `지금 문제 복습 Lv.4` 가 붙으면 어느 쪽이 필요 레벨인지 헷갈렸다. static String progressOf( CosmeticItemModel item, CosmeticAbilityLevels levels, ) { - final label = labelOf(item.requiredAbility); - final current = levels.levelOf(item.requiredAbility); final remaining = item.remainingLevelsAt(levels); - if (remaining <= 0) return '지금 $label Lv.$current · 열려 있어요'; - if (remaining == 1) return '지금 $label Lv.$current · 한 레벨만 더!'; - return '지금 $label Lv.$current · $remaining 레벨 남았어요'; + if (remaining <= 0) return '지금 바로 얻을 수 있어요'; + return '획득까지 $remaining레벨 남았어요'; } } diff --git a/lib/Screen/Cosmetic/Widget/CosmeticNextUnlockCard.dart b/lib/Screen/Cosmetic/Widget/CosmeticNextUnlockCard.dart index 6f6093c3..4dc96050 100644 --- a/lib/Screen/Cosmetic/Widget/CosmeticNextUnlockCard.dart +++ b/lib/Screen/Cosmetic/Widget/CosmeticNextUnlockCard.dart @@ -71,7 +71,7 @@ CosmeticItemModel? cosmeticLastUnlockedOf(List items) { /// /// 1. 이 카드가 무엇인지 · 자리별 진행도 /// 2. 다음에 열릴 것의 이름 · 무엇을 얼마나 올려야 하는지 (`출석 Lv.14`) -/// 3. **지금 내가 그 능력치에서 몇 레벨인지** (`지금 출석 Lv.13 · 한 레벨만 더!`) +/// 3. **얻기까지 몇 레벨이 남았는지** (`획득까지 1레벨 남았어요`) /// /// 셋째 줄이 없으면 `출석 Lv.14` 가 코앞인지 한참 남았는지 알 수 없다. 격자 /// 칸은 좁아서 조건까지밖에 못 적는데, 이 카드는 자리가 있어서 거리까지 적는다. diff --git a/lib/Screen/Folder/DirectoryScreen.dart b/lib/Screen/Folder/DirectoryScreen.dart index 4e6da885..2884fb7e 100644 --- a/lib/Screen/Folder/DirectoryScreen.dart +++ b/lib/Screen/Folder/DirectoryScreen.dart @@ -505,15 +505,28 @@ class _DirectoryScreenState extends State { await fetchFoldersAndProblems(); await missionProvider?.fetchMissions(); }, + // 좌우 여백은 목록 안쪽에 둔다. 목록은 제 영역 밖을 잘라내서, + // 바깥에 여백을 두면 폴더에 끌어다 댈 때 커지는 강조 테두리의 + // 양옆이 잘렸다. child: Padding( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.symmetric(vertical: _pagePadding), child: Column( children: [ - // 미션 조회에 실패했거나 미션이 없으면 카드가 스스로 숨는다. - if (widget.folderId == null) const TodayMissionCard(), - if (widget.folderId == null && reviewDueProvider.dueCount > 0) - _buildReviewDueBadge( - context, reviewDueProvider, themeProvider), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: _pagePadding), + child: Column( + children: [ + // 미션 조회에 실패했거나 미션이 없으면 카드가 스스로 + // 숨는다. + if (widget.folderId == null) const TodayMissionCard(), + if (widget.folderId == null && + reviewDueProvider.dueCount > 0) + _buildReviewDueBadge( + context, reviewDueProvider, themeProvider), + ], + ), + ), _buildFolderAndProblemGrid(themeProvider), ], ), @@ -603,6 +616,9 @@ class _DirectoryScreenState extends State { /// 목록에서 하나씩 들어오게 할 항목 수. 첫 화면에 보이는 만큼이다. static const int _staggeredItemLimit = 8; + /// 홈 화면 둘레의 여백. + static const double _pagePadding = 20; + Widget _buildQuickCreateFab(ThemeHandler themeProvider) { return Column( key: widget.tutorialTargets?.directoryCreateFabKey, @@ -1265,8 +1281,8 @@ class _DirectoryScreenState extends State { itemCount: 5, itemHeight: 96, spacing: 16, - padding: - EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: EdgeInsets.symmetric( + horizontal: _pagePadding + 16, vertical: 8), ); } @@ -1274,6 +1290,8 @@ class _DirectoryScreenState extends State { if (currentSubfolders.isEmpty && currentProblems.isEmpty) { return SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), + padding: + const EdgeInsets.symmetric(horizontal: _pagePadding), child: SizedBox( height: MediaQuery.of(context).size.height * 0.7, child: Center( @@ -1316,6 +1334,8 @@ class _DirectoryScreenState extends State { return ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), + padding: + const EdgeInsets.symmetric(horizontal: _pagePadding), itemCount: totalItems + (isLoadingMore || hasMore ? 1 : 0), itemBuilder: (context, index) { // 로딩 인디케이터 표시 @@ -1348,7 +1368,11 @@ class _DirectoryScreenState extends State { }, ), ), - if (_isSelectionMode) _buildBottomActionButtons(themeProvider), + if (_isSelectionMode) + Padding( + padding: const EdgeInsets.symmetric(horizontal: _pagePadding), + child: _buildBottomActionButtons(themeProvider), + ), ], )); } @@ -1389,15 +1413,14 @@ class _DirectoryScreenState extends State { }, child: LongPressDraggable( data: folder, - feedback: Material( - child: SizedBox( - width: 50, - height: 70, - child: ClayIcon( - NoteIconHandler.getNoteIcon(index), // 헬퍼 클래스로 아이콘 설정 - width: 50, - height: 50, - ), + dragAnchorStrategy: pointerDragAnchorStrategy, + feedback: _DragFeedbackCard( + color: themeProvider.primaryColor, + title: folder.folderName.isNotEmpty ? folder.folderName : '제목 없음', + leading: ClayIcon( + NoteIconHandler.getNoteIcon(index), + width: 34, + height: 34, ), ), childWhenDragging: Opacity( @@ -1606,16 +1629,17 @@ class _DirectoryScreenState extends State { }, child: LongPressDraggable( data: problem, - feedback: Material( - child: SizedBox( - width: 50, - height: 70, - child: ClipRRect( - borderRadius: BorderRadius.circular(AppRadius.small), - child: DisplayImage( - imagePath: imageUrl, - fit: BoxFit.cover, - ), + dragAnchorStrategy: pointerDragAnchorStrategy, + feedback: _DragFeedbackCard( + color: themeProvider.primaryColor, + title: problem.reference?.isNotEmpty == true + ? problem.reference! + : '제목 없음', + leading: ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.small), + child: DisplayImage( + imagePath: imageUrl, + fit: BoxFit.cover, ), ), ), @@ -2227,6 +2251,8 @@ class _DirectoryScreenState extends State { }, child: Container( padding: const EdgeInsets.all(14), + constraints: + const BoxConstraints(minHeight: TodayMissionCard.minHeight), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(AppRadius.large), @@ -2258,30 +2284,10 @@ class _DirectoryScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - const StandardText( - text: '추천 복습 문제', - fontSize: 14, - color: AppColors.textPrimary, - ), - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 7, vertical: 2), - decoration: BoxDecoration( - color: themeProvider.primaryColor, - borderRadius: - BorderRadius.circular(AppRadius.medium), - ), - child: StandardText( - text: '${reviewDueProvider.dueCount}개', - fontSize: 10, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - ], + const StandardText( + text: '추천 복습 문제', + fontSize: 14, + color: AppColors.textPrimary, ), if (overdueCount > 0) ...[ const SizedBox(height: 2), @@ -2294,6 +2300,23 @@ class _DirectoryScreenState extends State { ], ), ), + // 개수는 누르면 가는 곳 바로 앞에 둔다. 위 오늘의 미션 카드의 + // 받기 태그와 같은 자리다. + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: themeProvider.primaryColor, + borderRadius: BorderRadius.circular(AppRadius.medium), + ), + child: StandardText( + text: '${reviewDueProvider.dueCount}개', + fontSize: 10, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(width: 8), Icon(Icons.chevron_right, size: 20, color: Colors.grey[400]), ], ), @@ -2303,6 +2326,77 @@ class _DirectoryScreenState extends State { } } +/// 공책이나 오답노트를 꾹 눌러 끌 때 손가락에 붙어 다니는 카드. +/// +/// 전에는 기본 Material 의 흰 네모 위에 아이콘이나 사진만 50x70 으로 떠서, +/// 무엇을 들고 있는지도 잘 안 보이고 모서리가 각져 화면에서 동떨어져 보였다. +/// 목록 칸과 같은 둥근 카드에 그림과 이름을 담고, 손에 든 것처럼 살짝 +/// 기울여 그림자를 띄운다. 손가락에 가리지 않게 손끝 위쪽에 둔다. +class _DragFeedbackCard extends StatelessWidget { + final Widget leading; + final String title; + final Color color; + + const _DragFeedbackCard({ + required this.leading, + required this.title, + required this.color, + }); + + static const double _width = 200; + static const double _height = 60; + + @override + Widget build(BuildContext context) { + // pointerDragAnchorStrategy 는 손끝을 카드의 왼쪽 위에 둔다. 카드를 + // 가운데로 옮기고 손끝보다 조금 위로 띄운다. + return Transform.translate( + offset: const Offset(-_width / 2, -_height - 20), + child: Transform.rotate( + angle: -0.05, + child: Material( + color: Colors.transparent, + child: Container( + width: _width, + height: _height, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppRadius.large), + border: + Border.all(color: color.withValues(alpha: 0.5), width: 1.5), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.25), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Row( + children: [ + SizedBox(width: 40, height: 40, child: Center(child: leading)), + const SizedBox(width: 10), + Expanded( + child: StandardText( + text: title, + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon(Icons.open_with_rounded, size: 18, color: color), + ], + ), + ), + ), + ), + ); + } +} + /// 끌고 온 것을 여기에 놓을 수 있다는 것을 알린다. /// /// 손가락 아래에 무엇이 놓일지 보이지 않으면 어디에 떨어뜨려야 할지 알 수 diff --git a/lib/Screen/Mission/TodayMissionCard.dart b/lib/Screen/Mission/TodayMissionCard.dart index 14a53d4d..88094556 100644 --- a/lib/Screen/Mission/TodayMissionCard.dart +++ b/lib/Screen/Mission/TodayMissionCard.dart @@ -26,6 +26,13 @@ import 'MissionScreen.dart'; /// 미션 조회에 실패했거나 미션이 하나도 없으면 **배너를 통째로 숨긴다.** /// 백엔드에 아직 미션 API 가 없을 때 홈에 오류가 남지 않아야 한다. class TodayMissionCard extends StatelessWidget { + /// 이 카드와 추천 복습 배너가 함께 쓰는 최소 높이. + /// + /// 추천 복습 배너는 밀린 문제가 있으면 두 줄이 되어 이 카드보다 8 쯤 + /// 높았다. 두 줄일 때의 높이를 둘 다의 바닥으로 삼아 나란히 같은 크기로 + /// 보이게 한다. 글자를 키운 기기에서는 각자 더 커질 수 있다. + static const double minHeight = 76; + const TodayMissionCard({super.key}); @override @@ -45,7 +52,7 @@ class TodayMissionCard extends StatelessWidget { return Padding( // 아래에 추천 복습 배너가 이어 붙는다. 둘이 세로로 쌓이면 홈 위쪽이 - // 무거워지므로 사이를 좁히고 이 카드 자체도 납작하게 둔다. + // 무거워지므로 사이를 좁힌다. padding: const EdgeInsets.only(bottom: AppSpacing.sm), child: PressableScale( onTap: () { @@ -55,10 +62,10 @@ class TodayMissionCard extends StatelessWidget { ); }, child: Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm + 2, - ), + // 바로 아래 추천 복습 배너와 같은 크기로 보이도록 안쪽 여백과 + // 최소 높이를 맞춘다. + padding: const EdgeInsets.all(14), + constraints: const BoxConstraints(minHeight: minHeight), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(AppRadius.large), @@ -112,20 +119,9 @@ class TodayMissionCard extends StatelessWidget { fontSize: 12, color: AppColors.textTertiary, ), - if (unclaimed > 0) ...[ - const SizedBox(width: AppSpacing.sm), - MissionTag( - text: '받기 $unclaimed', - color: themeProvider.primaryColor, - leading: MissionRewardToken( - size: MissionTag.fontSize * 1.1, - color: themeProvider.primaryColor, - ), - ), - ], ], ), - const SizedBox(height: AppSpacing.xs), + const SizedBox(height: AppSpacing.sm), AnimatedLinearGauge( value: ratio.toDouble(), color: themeProvider.primaryColor, @@ -136,6 +132,19 @@ class TodayMissionCard extends StatelessWidget { ], ), ), + // 받을 것은 누르면 가는 곳 바로 앞에 둔다. 아래 추천 복습 + // 배너의 개수 배지와 같은 자리다. + if (unclaimed > 0) ...[ + const SizedBox(width: AppSpacing.sm), + MissionTag( + text: '받기 $unclaimed', + color: themeProvider.primaryColor, + leading: MissionRewardToken( + size: MissionTag.fontSize * 1.1, + color: themeProvider.primaryColor, + ), + ), + ], const SizedBox(width: AppSpacing.sm), Icon(Icons.chevron_right, size: 20, color: Colors.grey[400]), ], diff --git a/lib/Screen/PracticeNote/PracticeCompletionScreen.dart b/lib/Screen/PracticeNote/PracticeCompletionScreen.dart index d02bf861..a27594e6 100644 --- a/lib/Screen/PracticeNote/PracticeCompletionScreen.dart +++ b/lib/Screen/PracticeNote/PracticeCompletionScreen.dart @@ -7,17 +7,13 @@ import 'package:provider/provider.dart'; import '../../Provider/CosmeticProvider.dart'; import '../User/Widget/FrogCharacter.dart'; import '../User/Widget/FrogMotion.dart'; -import '../../Module/Emoji/OnoEmojiCatalog.dart'; -import '../../Module/Emoji/OnoEmojiImage.dart'; -import '../../Module/Emoji/OnoEmojiPicker.dart'; import '../../Module/Text/mobile_font_size.dart'; +import '../../Module/Emoji/MoodPicker.dart'; import '../../Module/Text/StandardText.dart'; import '../../Module/Theme/ThemeHandler.dart'; import '../../Provider/MissionProvider.dart'; import '../../Provider/PracticeNoteProvider.dart'; -import '../../Module/Motion/AppHaptic.dart'; import '../../Module/Motion/SuccessCheck.dart'; -import '../../Module/Motion/PressableScale.dart'; import '../../Module/Motion/AnimatedCountText.dart'; import '../../Module/Motion/AppMotion.dart'; import '../../Module/Motion/AppearTransition.dart'; @@ -43,18 +39,15 @@ class PracticeCompletionScreen extends StatefulWidget { } class _PracticeCompletionScreenState extends State { - static const List _recommendedMoodKeys = [ - 'success_checkmark', - 'got_100_score', - 'fired_up_sparkle_eyes', - 'happy_tears', - 'frustrated_studying', - 'dizzy_spiral_eyes2', - 'sleeping_blanket', - ]; - String? _selectedMoodKey; + /// 완료를 저장하는 중이거나 이미 저장했는지. + /// + /// 완료 요청은 보낼 때마다 복습 횟수를 하나씩 올린다. 응답을 기다리는 동안 + /// 확인을 또 누르면 횟수가 그만큼 쌓였다. 저장에 성공하면 화면이 닫힐 + /// 때까지 다시 켜지 않는다. + bool _submitting = false; + @override Widget build(BuildContext context) { final themeProvider = Provider.of(context); @@ -206,96 +199,29 @@ class _PracticeCompletionScreenState extends State { children: [ const Divider(), const SizedBox(height: 12), - StandardText( - text: '이번 복습 어땠나요?', - fontSize: MobileFontSize.reduced(context, 16), - fontWeight: FontWeight.bold, - color: AppColors.textPrimary, + Row( + children: [ + Expanded( + child: StandardText( + text: '이번 복습 어땠나요?', + fontSize: MobileFontSize.reduced(context, 16), + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + SelectedMoodChip( + selectedKey: _selectedMoodKey, + color: themeProvider.primaryColor, + ), + ], ), const SizedBox(height: 12), - SizedBox( - height: 82, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _recommendedMoodKeys.length + 1, - separatorBuilder: (_, __) => const SizedBox(width: 10), - itemBuilder: (context, index) { - if (index == _recommendedMoodKeys.length) { - return _buildMoreMoodButton(themeProvider); - } - - final emojiKey = _recommendedMoodKeys[index]; - final emoji = OnoEmojiCatalog.byKey(emojiKey); - if (emoji == null) return const SizedBox.shrink(); - - final isSelected = _selectedMoodKey == emojiKey; - return PressableScale( - haptic: HapticLevel.selection, - onTap: () { - setState(() { - _selectedMoodKey = isSelected ? null : emojiKey; - }); - }, - child: Container( - width: 70, - padding: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: isSelected - ? themeProvider.primaryColor.withValues(alpha: 0.1) - : Colors.grey[50], - borderRadius: BorderRadius.circular(AppRadius.medium), - border: Border.all( - color: isSelected - ? themeProvider.primaryColor - : Colors.grey[200]!, - ), - ), - child: Column( - children: [ - OnoEmojiImage(emoji: emoji, size: 54), - const SizedBox(height: 4), - Container( - width: 5, - height: 5, - decoration: BoxDecoration( - color: isSelected - ? themeProvider.primaryColor - : Colors.transparent, - shape: BoxShape.circle, - ), - ), - ], - ), - ), - ); - }, - ), - ), - ], - ); - } - - Widget _buildMoreMoodButton(ThemeHandler themeProvider) { - return PressableScale( - onTap: () { - OnoEmojiPicker.show( - context, + MoodPickerRow( selectedKey: _selectedMoodKey, - onSelected: (emoji) => setState(() => _selectedMoodKey = emoji.key), - ); - }, - child: Container( - width: 70, - decoration: BoxDecoration( - color: Colors.grey[50], - borderRadius: BorderRadius.circular(AppRadius.medium), - border: Border.all(color: AppColors.border), - ), - child: Icon( - Icons.more_horiz, color: themeProvider.primaryColor, + onChanged: (key) => setState(() => _selectedMoodKey = key), ), - ), + ], ); } @@ -308,50 +234,66 @@ class _PracticeCompletionScreenState extends State { width: double.infinity, height: 50, child: ElevatedButton( - onPressed: () async { - final navigator = Navigator.of(context); - final missionProvider = - Provider.of(context, listen: false); - try { - await practiceProvider.addPracticeCount( - widget.practiceId, - moodEmojiKey: _selectedMoodKey, - ); - } catch (_) { - if (!mounted) return; - AppToast.error('복습 완료를 저장하지 못했어요.'); - return; - } - if (!mounted) return; - FirebaseAnalytics.instance - .logEvent(name: 'practice_session_completed'); + onPressed: _submitting + ? null + : () async { + final navigator = Navigator.of(context); + final missionProvider = + Provider.of(context, listen: false); + setState(() => _submitting = true); + try { + await practiceProvider.addPracticeCount( + widget.practiceId, + moodEmojiKey: _selectedMoodKey, + ); + } catch (_) { + if (!mounted) return; + setState(() => _submitting = false); + AppToast.error('복습 완료를 저장하지 못했어요.'); + return; + } + if (!mounted) return; + FirebaseAnalytics.instance + .logEvent(name: 'practice_session_completed'); - // 1차에서는 행동 응답에 미션 진행도가 실려 오지 않는다. 세트를 - // 끝낸 뒤 다시 조회해야 미션이 바로 반영된다. - unawaited(missionProvider.fetchMissions()); - // 2번 pop: PracticeCompletionScreen -> PracticeDetailScreen -> PracticeThumbnailScreen - // 두 번째 pop에서 true를 반환하여 썸네일 업데이트 신호 전달 - if (navigator.canPop()) { - navigator.pop(); // PracticeCompletionScreen 닫기 - } - if (navigator.canPop()) { - navigator.pop(true); // PracticeDetailScreen 닫으면서 true 반환 - } - AppToast.success('복습을 완료했습니다!'); - }, + // 1차에서는 행동 응답에 미션 진행도가 실려 오지 않는다. 세트를 + // 끝낸 뒤 다시 조회해야 미션이 바로 반영된다. + unawaited(missionProvider.fetchMissions()); + // 2번 pop: PracticeCompletionScreen -> PracticeDetailScreen -> PracticeThumbnailScreen + // 두 번째 pop에서 true를 반환하여 썸네일 업데이트 신호 전달 + if (navigator.canPop()) { + navigator.pop(); // PracticeCompletionScreen 닫기 + } + if (navigator.canPop()) { + navigator.pop(true); // PracticeDetailScreen 닫으면서 true 반환 + } + AppToast.success('복습을 완료했습니다!'); + }, style: ElevatedButton.styleFrom( backgroundColor: themeProvider.primaryColor, + // 저장하는 동안에도 흐려지지 않게 같은 색을 쓴다. 대신 글자 + // 자리에 도는 표시를 둔다. + disabledBackgroundColor: themeProvider.primaryColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.large), ), elevation: 0, ), - child: const StandardText( - text: "확인", - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + child: _submitting + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: Colors.white, + ), + ) + : const StandardText( + text: "확인", + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), ), ), ); diff --git a/lib/Screen/PracticeNote/PracticeDetailLoader.dart b/lib/Screen/PracticeNote/PracticeDetailLoader.dart new file mode 100644 index 00000000..203267db --- /dev/null +++ b/lib/Screen/PracticeNote/PracticeDetailLoader.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../Model/PracticeNote/PracticeNoteDetailModel.dart'; +import '../../Module/Design/AppColors.dart'; +import '../../Module/Design/AppRadius.dart'; +import '../../Module/Motion/Skeleton.dart'; +import '../../Module/Text/StandardText.dart'; +import '../../Module/Theme/ThemeHandler.dart'; +import '../../Provider/PracticeNoteProvider.dart'; +import 'PracticeDetailScreen.dart'; + +/// 복습 세트 화면을 먼저 열고, 그 안에서 세트를 불러온다. +/// +/// 전에는 목록에서 누르면 세트와 문제를 다 불러온 뒤에야 화면이 넘어갔다. +/// 그동안 아무것도 바뀌지 않아서 다른 세트를 또 누를 수 있었고, 그러면 두 +/// 화면이 겹쳐 쌓였다. 화면이 바로 넘어가면 목록은 그 뒤로 가려진다. +/// +/// 복습을 마치고 `true` 로 닫히는 것은 [PracticeDetailScreen] 이 이 경로를 +/// 그대로 닫으므로 부른 쪽이 똑같이 받는다. +class PracticeDetailLoader extends StatefulWidget { + final int practiceId; + + /// 불러오는 동안 앱바에 적을 세트 이름. 목록에서 이미 알고 있다. + final String? title; + + const PracticeDetailLoader({ + super.key, + required this.practiceId, + this.title, + }); + + @override + State createState() => _PracticeDetailLoaderState(); +} + +class _PracticeDetailLoaderState extends State { + PracticeNoteDetailModel? _practice; + bool _failed = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final provider = + Provider.of(context, listen: false); + if (_failed) setState(() => _failed = false); + + try { + await provider.fetchPracticeNote(widget.practiceId); + await provider.moveToPractice(widget.practiceId); + } catch (error) { + debugPrint('Failed to open practice ${widget.practiceId}: $error'); + if (mounted) setState(() => _failed = true); + return; + } + if (!mounted) return; + + final practice = provider.currentPracticeNote; + setState(() { + if (practice != null && practice.practiceId == widget.practiceId) { + _practice = practice; + } else { + _failed = true; + } + }); + } + + @override + Widget build(BuildContext context) { + final practice = _practice; + if (practice != null) return PracticeDetailScreen(practice: practice); + + final themeProvider = Provider.of(context); + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + centerTitle: true, + title: StandardText( + text: widget.title ?? '', + fontSize: 18, + color: themeProvider.primaryColor, + ), + ), + body: _failed ? _buildFailed(themeProvider) : _buildLoading(), + ); + } + + Widget _buildLoading() { + return const Column( + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: SkeletonBox(height: 44), + ), + Divider(), + Expanded( + child: SkeletonList( + itemCount: 4, + itemHeight: 96, + spacing: 16, + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + ], + ); + } + + Widget _buildFailed(ThemeHandler themeProvider) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const StandardText( + text: '복습 세트를 불러오지 못했어요.\n잠시 후 다시 시도해 주세요.', + fontSize: 15, + color: AppColors.textPrimary, + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: _load, + style: ElevatedButton.styleFrom( + backgroundColor: themeProvider.primaryColor, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.medium), + ), + ), + child: const StandardText( + text: '다시 불러오기', + fontSize: 14, + color: Colors.white, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/Screen/PracticeNote/PracticeDetailScreen.dart b/lib/Screen/PracticeNote/PracticeDetailScreen.dart index 6909cc48..72ff50b6 100644 --- a/lib/Screen/PracticeNote/PracticeDetailScreen.dart +++ b/lib/Screen/PracticeNote/PracticeDetailScreen.dart @@ -7,8 +7,6 @@ import '../User/Widget/FrogCharacter.dart'; import '../../Model/PracticeNote/PracticeNoteDetailModel.dart'; import '../../Model/Problem/ProblemModel.dart'; import '../../Module/Dialog/SnackBarDialog.dart'; -import '../../Module/Emoji/OnoEmojiCatalog.dart'; -import '../../Module/Emoji/OnoEmojiImage.dart'; import '../../Module/Problem/ProblemThumbnailCard.dart'; import '../../Module/Text/mobile_font_size.dart'; import '../../Module/Text/StandardText.dart'; @@ -267,77 +265,11 @@ class PracticeDetailScreen extends StatelessWidget { ), ], ), - if (practice.lastSessionMoodEmojiKey != null) - _buildLastMoodRow(themeProvider), ], ), ); } - /// 지난 복습을 끝내고 고른 기분이다. - /// - /// 전에는 글자 한 줄과 이모지를 가운데에 나란히 두기만 해서, 위의 통계와 - /// 경계가 없어 어디에 속한 것인지 읽히지 않았다. 옅게 칠한 칸으로 묶고 - /// 이모지를 동그란 자리에 앉혔다. 그림만으로는 무엇을 고른 것인지 알기 - /// 어려워서 이모지의 이름도 같이 쓴다. - /// - /// 서버가 이 앱이 모르는 키를 보내면 [OnoEmojiCatalog.byKey] 가 null 을 - /// 주는데, 그때는 칸 자체를 그리지 않는다. 이름도 그림도 없이 빈 자리만 - /// 남기 때문이다. - Widget _buildLastMoodRow(ThemeHandler themeProvider) { - final emoji = OnoEmojiCatalog.byKey(practice.lastSessionMoodEmojiKey!); - if (emoji == null) return const SizedBox.shrink(); - - return Padding( - padding: const EdgeInsets.only(top: 14), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - decoration: BoxDecoration( - color: themeProvider.primaryColor.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(AppRadius.medium), - border: Border.all( - color: themeProvider.primaryColor.withValues(alpha: 0.18), - ), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - ), - child: OnoEmojiImage(emoji: emoji, size: 30), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - const StandardText( - text: '지난 복습 소감', - fontSize: 12, - color: AppColors.textTertiary, - height: 1.2, - ), - const SizedBox(height: 4), - StandardText( - text: emoji.label, - fontSize: 15, - color: themeProvider.primaryColor, - height: 1.2, - ), - ], - ), - ), - ], - ), - ), - ); - } - Widget _buildPracticeTile( String title, String value, ThemeHandler themeProvider) { return Expanded( diff --git a/lib/Screen/PracticeNote/PracticeNavigationButtons.dart b/lib/Screen/PracticeNote/PracticeNavigationButtons.dart index c25826e9..450b75a5 100644 --- a/lib/Screen/PracticeNote/PracticeNavigationButtons.dart +++ b/lib/Screen/PracticeNote/PracticeNavigationButtons.dart @@ -189,7 +189,14 @@ class _PracticeNavigationButtonsState extends State { ); } + /// 완료 화면으로 넘어가는 중인지. 두 번 눌리면 완료 화면이 이 화면이 + /// 아니라 먼저 뜬 완료 화면을 갈아 끼워서, 닫을 때 한 화면이 남았다. + bool _openingCompletion = false; + void _showCompletionScreen() { + if (_openingCompletion) return; + _openingCompletion = true; + final practiceId = widget.practiceProvider.currentPracticeNote!.practiceId; final totalProblems = widget.practiceProvider.currentProblems.length; final matchingPractices = widget.practiceProvider.practices diff --git a/lib/Screen/PracticeNote/PracticeThumbnailScreen.dart b/lib/Screen/PracticeNote/PracticeThumbnailScreen.dart index 67c31275..86075706 100644 --- a/lib/Screen/PracticeNote/PracticeThumbnailScreen.dart +++ b/lib/Screen/PracticeNote/PracticeThumbnailScreen.dart @@ -5,6 +5,8 @@ import 'package:provider/provider.dart'; import '../../Provider/CosmeticProvider.dart'; import '../User/Widget/FrogCharacter.dart'; import '../../Model/PracticeNote/PracticeNoteThumbnailModel.dart'; +import '../../Module/Emoji/OnoEmojiCatalog.dart'; +import '../../Module/Emoji/OnoEmojiImage.dart'; import '../../Module/Text/StandardText.dart'; import '../../Module/Text/mobile_font_size.dart'; import '../../Module/Theme/ClayIcon.dart'; @@ -12,7 +14,7 @@ import '../../Module/Theme/ThemeHandler.dart'; import '../../Provider/PracticeNoteProvider.dart'; import '../../Util/AppSnackBar.dart'; import '../Tutorial/TutorialTargets.dart'; -import 'PracticeDetailScreen.dart'; +import 'PracticeDetailLoader.dart'; import 'PracticeProblemSelectionScreen.dart'; import '../../Module/Motion/AppMotion.dart'; import '../../Module/Motion/AppearTransition.dart'; @@ -618,7 +620,7 @@ class _ProblemPracticeScreen extends State { : _selectedPracticeIds.add(practice.practiceId); }); } else { - _navigateToPracticeDetail(practice.practiceId); + _navigateToPracticeDetail(practice); } }, child: Padding( @@ -641,24 +643,35 @@ class _ProblemPracticeScreen extends State { ); } - void _navigateToPracticeDetail(int practiceId) async { - final practiceProvider = - Provider.of(context, listen: false); + /// 복습 세트 화면이 열려 있는 동안 켜 둔다. 화면이 넘어가는 애니메이션 + /// 사이에 한 번 더 눌려도 두 번 쌓이지 않게 한다. + bool _openingPractice = false; - // 로딩 다이얼로그를 띄우지 않는다. 떴다 사라진 다음 화면이 넘어가서 - // 한 번 눌렀는데 두 번 바뀌는 것처럼 보였다. 누른 항목이 줄어드는 것으로 - // 눌린 것은 이미 알 수 있고, 열린 화면은 자기 자리를 잡으며 나타난다. - await practiceProvider.fetchPracticeNote(practiceId); - await practiceProvider.moveToPractice(practiceId); - if (!mounted) return; + void _navigateToPracticeDetail(PracticeNoteThumbnails practice) async { + if (_openingPractice) return; + _openingPractice = true; - final result = await Navigator.push( - context, - TossPageRoute( - builder: (context) => PracticeDetailScreen( - practice: practiceProvider.currentPracticeNote!), - ), - ); + final practiceProvider = + Provider.of(context, listen: false); + final practiceId = practice.practiceId; + + // 불러오기를 기다리지 않고 화면부터 넘긴다. 세트는 넘어간 화면 안에서 + // 불러온다. 기다리는 동안 목록이 그대로 있으면 다른 세트를 또 눌러 두 + // 화면이 겹쳐 쌓였다. + final bool? result; + try { + result = await Navigator.push( + context, + TossPageRoute( + builder: (context) => PracticeDetailLoader( + practiceId: practiceId, + title: practice.practiceTitle, + ), + ), + ); + } finally { + _openingPractice = false; + } // 복습을 완료한 경우(result == true)에만 해당 썸네일 업데이트 if (result == true) { @@ -725,23 +738,53 @@ class _ProblemPracticeScreen extends State { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 5), - StandardText( - text: - '마지막 복습 날짜: ${formatDateTime(practice.lastSolvedAt) ?? '복습 기록 없음'}', - fontSize: 11, - color: Colors.grey, + Row( + children: [ + Flexible( + child: StandardText( + text: + '마지막 복습 날짜: ${formatDateTime(practice.lastSolvedAt) ?? '복습 기록 없음'}', + fontSize: 11, + color: Colors.grey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ..._buildLastMood(practice), + ], ), ], ), ); } + /// 지난 복습을 끝내고 고른 기분. 마지막 복습 날짜 바로 뒤에 붙는다. + /// + /// 상세 화면 위쪽에 따로 칸을 두었더니 통계와 문제 목록 사이에 끼어 + /// 자리만 차지했다. 목록으로 옮기면서도 줄을 하나 더 쓰면 카드가 길어져서, + /// 날짜 줄 끝에 그림만 둔다. 복습을 마치며 직접 고른 그림이라 이름 없이도 + /// 알아본다. 이름은 길게 누르면 뜨고 스크린 리더도 읽는다. + /// 서버가 이 앱이 모르는 키를 보내면 [OnoEmojiCatalog.byKey] 가 null 을 + /// 주는데, 그때는 그리지 않는다. + List _buildLastMood(PracticeNoteThumbnails practice) { + final key = practice.lastSessionMoodEmojiKey; + final emoji = key == null ? null : OnoEmojiCatalog.byKey(key); + if (emoji == null) return const []; + + return [ + const SizedBox(width: 4), + Tooltip( + message: '지난 소감: ${emoji.label}', + child: OnoEmojiImage(emoji: emoji, size: 18), + ), + ]; + } + Widget _buildPracticeMeta( PracticeNoteThumbnails practice, ThemeHandler themeProvider) { const frogIconBoxWidth = 68.0; - // 지난 복습 소감 이모지는 여기 두지 않는다. 목록에서는 몇 회 복습했는지만 - // 보면 되고, 소감은 상세 화면에서 본다. + // 지난 복습 소감은 왼쪽 날짜 아래에 둔다. 여기는 몇 회 복습했는지만 본다. return Semantics( label: practice.practiceCount >= 3 ? '복습 완료' diff --git a/lib/Screen/ProblemDetail/ProblemDetailScreen.dart b/lib/Screen/ProblemDetail/ProblemDetailScreen.dart index fc75967e..6e1e60f3 100644 --- a/lib/Screen/ProblemDetail/ProblemDetailScreen.dart +++ b/lib/Screen/ProblemDetail/ProblemDetailScreen.dart @@ -224,11 +224,9 @@ class _ProblemDetailScreenState extends State { next.analysis?.problemType; }, builder: (context, problemModel, child) { - if (_isProblemDeleted) { - return Expanded( - child: - Container()); // Problem has been deleted, so show nothing or a message - } + // 이 builder 는 이미 Expanded 안에 있다. 여기서 Expanded 를 또 + // 두르면 ParentDataWidget 오류가 난다. + if (_isProblemDeleted) return const SizedBox.shrink(); if (problemModel == null) { // 초기 로딩 시에만 Future로 가져오기 return FutureBuilder( diff --git a/lib/Screen/ProblemDetail/ProblemDetailScreenWidget.dart b/lib/Screen/ProblemDetail/ProblemDetailScreenWidget.dart index 20c5f5c2..32e44422 100644 --- a/lib/Screen/ProblemDetail/ProblemDetailScreenWidget.dart +++ b/lib/Screen/ProblemDetail/ProblemDetailScreenWidget.dart @@ -23,7 +23,7 @@ class ProblemDetailScreenWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ verticalSpacer(ctx, .03), - buildDateRow(problem.solvedAt!, theme.primaryColor), + buildDateRow(problem.displaySolvedAt, theme.primaryColor), /* verticalSpacer(ctx, .03), buildReferenceRow(problem.reference, theme.primaryColor), diff --git a/lib/Screen/ProblemDetail/ProblemDetailTemplate.dart b/lib/Screen/ProblemDetail/ProblemDetailTemplate.dart index 9e119e84..6a858600 100644 --- a/lib/Screen/ProblemDetail/ProblemDetailTemplate.dart +++ b/lib/Screen/ProblemDetail/ProblemDetailTemplate.dart @@ -345,7 +345,7 @@ class _ProblemDetailTemplateState extends State const Spacer(), UnderlinedText( text: DateFormat('yyyy년 M월 d일') - .format(widget.problemModel.solvedAt!), + .format(widget.problemModel.displaySolvedAt), fontSize: 16, ), ], diff --git a/lib/Screen/ProblemDetail/ProblemDetailTemplateV2.dart b/lib/Screen/ProblemDetail/ProblemDetailTemplateV2.dart index 4e567527..1b70a975 100644 --- a/lib/Screen/ProblemDetail/ProblemDetailTemplateV2.dart +++ b/lib/Screen/ProblemDetail/ProblemDetailTemplateV2.dart @@ -143,7 +143,7 @@ class _ProblemDetailTemplateV2State extends State Icon(Icons.calendar_month, color: themeProvider.primaryColor, size: 18), const SizedBox(width: 8), - buildDateRow(widget.problemModel.solvedAt!, + buildDateRow(widget.problemModel.displaySolvedAt, themeProvider.primaryColor), ], ), diff --git a/lib/Screen/ProblemSolve/ProblemSolveRegisterTemplate.dart b/lib/Screen/ProblemSolve/ProblemSolveRegisterTemplate.dart index 8fdeb5bd..7e166913 100644 --- a/lib/Screen/ProblemSolve/ProblemSolveRegisterTemplate.dart +++ b/lib/Screen/ProblemSolve/ProblemSolveRegisterTemplate.dart @@ -7,11 +7,9 @@ import 'package:provider/provider.dart'; import '../../Model/Problem/AnswerStatus.dart'; import '../../Model/Problem/ImprovementType.dart'; -import '../../Module/Emoji/OnoEmojiCatalog.dart'; -import '../../Module/Emoji/OnoEmojiImage.dart'; -import '../../Module/Emoji/OnoEmojiPicker.dart'; import '../../Module/Image/ImagePickerHandler.dart'; import '../../Module/Text/mobile_font_size.dart'; +import '../../Module/Emoji/MoodPicker.dart'; import '../../Module/Text/StandardText.dart'; import '../../Module/Theme/ThemeHandler.dart'; import '../../Provider/ProblemsProvider.dart'; @@ -52,18 +50,6 @@ class ProblemSolveRegisterTemplateState /// 이 회차의 기분 이모지 키. 안 고르고 넘어가도 된다. String? _selectedMoodKey; - /// 목록에 먼저 보여줄 이모지다. 복습 세트 완료 화면과 같은 것을 쓴다. - /// 여기 없는 것은 `더보기` 에서 전체 목록으로 고른다. - static const List _recommendedMoodKeys = [ - 'success_checkmark', - 'got_100_score', - 'fired_up_sparkle_eyes', - 'happy_tears', - 'frustrated_studying', - 'dizzy_spiral_eyes2', - 'sleeping_blanket', - ]; - // 개선 체크리스트 (ImprovementType enum 사용) final Map _improvements = { ImprovementType.NO_REPEAT_MISTAKE: false, @@ -475,32 +461,42 @@ class ProblemSolveRegisterTemplateState ), ), const SizedBox(width: AppSpacing.sm), - // 좁은 폰에서 조절 칩 넷이 한 줄에 다 안 들어가 오른쪽으로 - // 넘쳤다. 남는 자리를 칩 묶음이 가져가고, 모자라면 아랫줄로 - // 내린다. - Expanded( - child: Wrap( - alignment: WrapAlignment.end, - spacing: 4, - runSpacing: 6, - children: [ - _buildAdjustChip('-1분', themeProvider, () { - setState(() { - _timeSpentSeconds -= 60; - if (_timeSpentSeconds < 0) _timeSpentSeconds = 0; - }); - }), - _buildAdjustChip('+1분', themeProvider, - () => setState(() => _timeSpentSeconds += 60)), - _buildAdjustChip('-10초', themeProvider, () { - setState(() { - _timeSpentSeconds -= 10; - if (_timeSpentSeconds < 0) _timeSpentSeconds = 0; - }); - }), - _buildAdjustChip('+10초', themeProvider, - () => setState(() => _timeSpentSeconds += 10)), - ], + // 칩 넷은 항상 한 줄에 둔다. 전에는 글자와 칩 묶음이 폭을 반씩 + // 나눠 가져서, 글자는 자리가 남는데 칩은 반쪽에 안 들어가 폰에서 + // 아랫줄로 넘어갔다. 칩 묶음이 제 폭을 먼저 쓰고 글자가 나머지를 + // 쓴다. 그래도 모자라는 아주 좁은 폰에서는 줄을 바꾸지 않고 + // 칩을 조금 줄인다. + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.6, + ), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildAdjustChip('-1분', themeProvider, () { + setState(() { + _timeSpentSeconds -= 60; + if (_timeSpentSeconds < 0) _timeSpentSeconds = 0; + }); + }), + const SizedBox(width: 6), + _buildAdjustChip('+1분', themeProvider, + () => setState(() => _timeSpentSeconds += 60)), + const SizedBox(width: 6), + _buildAdjustChip('-10초', themeProvider, () { + setState(() { + _timeSpentSeconds -= 10; + if (_timeSpentSeconds < 0) _timeSpentSeconds = 0; + }); + }), + const SizedBox(width: 6), + _buildAdjustChip('+10초', themeProvider, + () => setState(() => _timeSpentSeconds += 10)), + ], + ), ), ), ], @@ -766,6 +762,7 @@ class ProblemSolveRegisterTemplateState required IconData icon, required String title, required ThemeHandler themeProvider, + Widget? trailing, }) { return Row( children: [ @@ -792,6 +789,7 @@ class ProblemSolveRegisterTemplateState color: AppColors.textPrimary, ), ), + if (trailing != null) trailing, ], ); } @@ -832,92 +830,22 @@ class ProblemSolveRegisterTemplateState icon: Icons.mood, title: '이번 복습 어땠나요?', themeProvider: themeProvider, + trailing: SelectedMoodChip( + selectedKey: _selectedMoodKey, + color: themeProvider.primaryColor, + ), ), const SizedBox(height: 12), - SizedBox( - height: 82, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _recommendedMoodKeys.length + 1, - separatorBuilder: (_, __) => const SizedBox(width: 10), - itemBuilder: (context, index) { - if (index == _recommendedMoodKeys.length) { - return _buildMoreMoodButton(themeProvider); - } - - final emojiKey = _recommendedMoodKeys[index]; - final emoji = OnoEmojiCatalog.byKey(emojiKey); - if (emoji == null) return const SizedBox.shrink(); - - final isSelected = _selectedMoodKey == emojiKey; - return PressableScale( - haptic: HapticLevel.selection, - // 고른 것을 다시 누르면 해제된다. 안 고르고 넘어가는 것도 - // 그대로 되어야 해서 되돌릴 길을 열어 둔다. - onTap: () => setState( - () => _selectedMoodKey = isSelected ? null : emojiKey, - ), - child: Container( - width: 70, - padding: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: isSelected - ? themeProvider.primaryColor.withValues(alpha: 0.1) - : Colors.grey[50], - borderRadius: BorderRadius.circular(AppRadius.medium), - border: Border.all( - color: isSelected - ? themeProvider.primaryColor - : AppColors.border, - ), - ), - child: Column( - children: [ - OnoEmojiImage(emoji: emoji, size: 54), - const SizedBox(height: 4), - Container( - width: 5, - height: 5, - decoration: BoxDecoration( - color: isSelected - ? themeProvider.primaryColor - : Colors.transparent, - shape: BoxShape.circle, - ), - ), - ], - ), - ), - ); - }, - ), + MoodPickerRow( + selectedKey: _selectedMoodKey, + color: themeProvider.primaryColor, + onChanged: (key) => setState(() => _selectedMoodKey = key), ), ], ), ); } - Widget _buildMoreMoodButton(ThemeHandler themeProvider) { - return PressableScale( - onTap: () { - OnoEmojiPicker.show( - context, - selectedKey: _selectedMoodKey, - onSelected: (emoji) => setState(() => _selectedMoodKey = emoji.key), - ); - }, - child: Container( - width: 70, - decoration: BoxDecoration( - color: Colors.grey[50], - borderRadius: BorderRadius.circular(AppRadius.medium), - border: Border.all(color: AppColors.border), - ), - child: Icon(Icons.more_horiz, color: themeProvider.primaryColor), - ), - ); - } - // API 연동 시 사용할 데이터 수집 메서드 Map getReviewData() { return { diff --git a/lib/Screen/StudyRoom/StudyRoomListScreen.dart b/lib/Screen/StudyRoom/StudyRoomListScreen.dart index aa917f39..805be3d5 100644 --- a/lib/Screen/StudyRoom/StudyRoomListScreen.dart +++ b/lib/Screen/StudyRoom/StudyRoomListScreen.dart @@ -8,6 +8,7 @@ import '../../Module/Theme/ThemeHandler.dart'; import '../../Module/User/ProfileAvatar.dart'; import '../../Provider/StudyRoomProvider.dart'; import '../../Provider/UserProvider.dart'; +import '../../Util/AppErrorReporter.dart'; import '../Tutorial/TutorialTargets.dart'; import 'StudyRoomCreateScreen.dart'; import 'StudyRoomDetailScreen.dart'; @@ -39,12 +40,29 @@ class _StudyRoomListScreenState extends State { provider.updateCurrentUserId( Provider.of(context, listen: false).userInfoModel?.userId, ); - provider.fetchMyRooms(); + _loadRooms(provider); }); } Future _refresh() async { - await Provider.of(context, listen: false).fetchMyRooms(); + await _loadRooms(Provider.of(context, listen: false)); + } + + /// 목록을 불러온다. 실패해도 던지지 않는다. + /// + /// 실패는 HttpService 가 이미 스낵바로 알린다. 여기서 받지 않으면 예외가 + /// 끝까지 올라가 앱이 죽은 것처럼 fatal 로 보고됐다. + Future _loadRooms(StudyRoomProvider provider) async { + try { + await provider.fetchMyRooms(); + } catch (error, stackTrace) { + await AppErrorReporter.report( + error, + stackTrace, + source: 'study_room_fetch', + severity: AppErrorSeverity.warning, + ); + } } void _openCreate() { diff --git a/lib/Screen/User/Widget/ProfileEditCard.dart b/lib/Screen/User/Widget/ProfileEditCard.dart index 5c0d647d..1f8f60bb 100644 --- a/lib/Screen/User/Widget/ProfileEditCard.dart +++ b/lib/Screen/User/Widget/ProfileEditCard.dart @@ -233,14 +233,6 @@ class _ProfileEditCardState extends State { overflow: TextOverflow.ellipsis, maxLines: 1, ), - const SizedBox(height: 3), - const StandardText( - text: '사진과 이름을 바꿀 수 있어요', - fontSize: 12, - color: AppColors.textTertiary, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), ], ), ), diff --git a/lib/Service/SocialLogin/GoogleAuthService.dart b/lib/Service/SocialLogin/GoogleAuthService.dart index 37950248..e2286ec6 100644 --- a/lib/Service/SocialLogin/GoogleAuthService.dart +++ b/lib/Service/SocialLogin/GoogleAuthService.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:google_sign_in/google_sign_in.dart'; import 'package:http/http.dart' as http; import 'package:ono/Model/User/UserRegisterModel.dart'; @@ -11,7 +12,15 @@ class GoogleAuthService { final GoogleSignIn _googleSignIn = GoogleSignIn(); Future signInWithGoogle(BuildContext context) async { - final googleSignInAccount = await _googleSignIn.signIn(); + final GoogleSignInAccount? googleSignInAccount; + try { + googleSignInAccount = await _googleSignIn.signIn(); + } on PlatformException catch (error) { + // 12501 은 사용자가 창을 닫은 것이고 12502 는 이미 다른 로그인 창이 떠 + // 있는 것이다. 둘 다 결함이 아니라서 취소로 돌려 조용히 끝낸다. + if (_isCanceled(error)) return null; + rethrow; + } if (googleSignInAccount != null) { String? email = googleSignInAccount.email; String? name = googleSignInAccount.displayName; @@ -24,6 +33,12 @@ class GoogleAuthService { } } + static bool _isCanceled(PlatformException error) { + if (error.code == GoogleSignIn.kSignInCanceledError) return true; + final message = error.message ?? ''; + return message.contains('12501') || message.contains('12502'); + } + Future logoutGoogleSignIn() async { _googleSignIn.signOut(); } diff --git a/lib/Util/AppErrorReporter.dart b/lib/Util/AppErrorReporter.dart index 666ac6c7..3840fb1c 100644 --- a/lib/Util/AppErrorReporter.dart +++ b/lib/Util/AppErrorReporter.dart @@ -18,6 +18,13 @@ class AppErrorReporter { @visibleForTesting static DiscordAlertSender discordAlertSender = sendDiscordAlert; + /// 끄면 Sentry 와 Discord 어디로도 보내지 않는다. + /// + /// E2E 는 진짜 `main()` 을 띄워서 테스트의 실패와 일부러 만든 데이터가 + /// 운영 이슈처럼 Sentry 에 쌓였다. E2E 가 앱을 띄우기 전에 끈다. + /// 앱 코드에서는 바꾸지 않는다. + static bool enabled = true; + /// 테스트가 바꿔 끼운 전송기를 원래대로 되돌린다. tearDown 에서 부른다. @visibleForTesting static void resetDiscordAlertSender() { @@ -30,7 +37,9 @@ class AppErrorReporter { String source = 'app', AppErrorSeverity severity = AppErrorSeverity.error, bool sendToDiscord = true, + bool sendToSentry = true, }) async { + if (!enabled) return; await _ensureDotenvLoaded(); // 사용자 단말의 일시적인 통신 문제는 앱 결함이 아니므로 warning 으로 낮춘다. @@ -40,6 +49,24 @@ class AppErrorReporter { debugPrint( '[AppErrorReporter][${effectiveSeverity.name}] $source\nError: $error\n$stackTrace'); + // FlutterError.onError 와 PlatformDispatcher.onError 는 SentryFlutter 가 + // 제 통합으로 감싸 한 번 더 보낸다. 그 두 곳은 여기서 보내지 않아야 같은 + // 에러가 두 건씩 쌓이지 않는다. + if (sendToSentry) { + await _captureToSentry(error, stackTrace, source, effectiveSeverity); + } + + // warning 은 디스코드로 알리지 않는다. (네트워크 끊김 등으로 알림이 묻히는 것을 막는다) + if (!sendToDiscord || effectiveSeverity == AppErrorSeverity.warning) return; + await _sendToDiscord(error, stackTrace, source); + } + + static Future _captureToSentry( + Object error, + StackTrace stackTrace, + String source, + AppErrorSeverity effectiveSeverity, + ) async { try { await Sentry.captureException( error, @@ -53,10 +80,13 @@ class AppErrorReporter { debugPrint( '[AppErrorReporter] Failed to report to Sentry\nError: $sentryError\n$sentryStackTrace'); } + } - // warning 은 디스코드로 알리지 않는다. (네트워크 끊김 등으로 알림이 묻히는 것을 막는다) - if (!sendToDiscord || effectiveSeverity == AppErrorSeverity.warning) return; - + static Future _sendToDiscord( + Object error, + StackTrace stackTrace, + String source, + ) async { final webhookUrl = _resolveDiscordWebhookUrl(); if (webhookUrl == null) { debugPrint( @@ -88,8 +118,19 @@ class AppErrorReporter { } } + /// 앱 결함이 아니라 통신이나 서버 쪽이 잠깐 흔들린 것. + /// + /// 서버가 502·503·504 를 본문 없이 주면 [ApiException] 으로 올라온다. + /// 그것까지 error 로 보내면 서버가 재시작하는 몇 분 사이에 Discord 가 + /// 알림으로 찼다. 500 은 서버 결함일 수 있어 그대로 둔다. static bool _isTransientNetworkError(Object error) { - return error is NetworkException || error is TimeoutException; + if (error is NetworkException || error is TimeoutException) return true; + final status = switch (error) { + ApiException(:final statusCode) => statusCode, + ServerException(:final statusCode) => statusCode, + _ => null, + }; + return status == 502 || status == 503 || status == 504; } static Future _ensureDotenvLoaded() async { diff --git a/lib/main.dart b/lib/main.dart index cfdcd039..2dd941d1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -63,6 +63,8 @@ Future main() async { details.stack ?? StackTrace.current, source: 'flutter_error', severity: AppErrorSeverity.fatal, + // Sentry 로는 SentryFlutter 의 FlutterError 통합이 보낸다. + sendToSentry: false, ), ); }; @@ -74,6 +76,8 @@ Future main() async { stackTrace, source: 'platform_dispatcher', severity: AppErrorSeverity.fatal, + // Sentry 로는 SentryFlutter 의 onError 통합이 보낸다. + sendToSentry: false, ), ); return true; @@ -81,7 +85,10 @@ Future main() async { await SentryFlutter.init( (options) { - options.dsn = dotenv.env['SENTRY_DSN'] ?? ''; + // DSN 이 비면 SentryFlutter 는 아무것도 보내지 않는다. E2E 가 + // 보고를 꺼 둔 경우다. + options.dsn = + AppErrorReporter.enabled ? dotenv.env['SENTRY_DSN'] ?? '' : ''; // 운영 사용자 에러만 갈라 볼 수 있게 ENV 를 environment 로 싣는다. // release 이름(패키지@버전+빌드)은 sentry_flutter 가 알아서 채운다. options.environment = SentryEnvironment.resolve( diff --git a/pubspec.yaml b/pubspec.yaml index e0b55995..829f6796 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 4.0.0+70 +version: 4.1.0+71 environment: sdk: '>=3.3.3 <4.0.0' diff --git a/test/module/dialog/theme_dialog_test.dart b/test/module/dialog/theme_dialog_test.dart index 2545fe0a..2dc9eafb 100644 --- a/test/module/dialog/theme_dialog_test.dart +++ b/test/module/dialog/theme_dialog_test.dart @@ -13,6 +13,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:ono/Model/Common/LoginStatus.dart'; import 'package:ono/Model/User/UserInfoModel.dart'; +import 'package:ono/Module/Design/AppColors.dart'; import 'package:ono/Module/Dialog/ThemeDialog.dart'; import 'package:ono/Module/Motion/TossDialog.dart'; import 'package:ono/Module/Theme/ThemeHandler.dart'; @@ -208,6 +209,45 @@ void main() { }); }); + group('맨 위 미리보기 판', () { + Color? panelColor(WidgetTester tester, String text) { + final panel = find.ancestor( + of: find.text(text), + matching: find.byWidgetPredicate( + (w) => + w is Container && + w.decoration is BoxDecoration && + (w.decoration! as BoxDecoration).borderRadius != null, + ), + ); + return (tester.widget(panel.first).decoration! + as BoxDecoration) + .color; + } + + testWidgets('잠긴 색을 눌러도 배경은 흰색이다', (tester) async { + // 전에는 미션 칸 색(출석 빨강, 오답노트 보라 …)을 옅게 깔아서 누를 + // 때마다 판 전체가 다른 색으로 바뀌었다. + await pumpThemeDialog(tester); + await tester.tap( + find.byKey(ThemeDialog.cellKey(ThemeLockManager.themeIndexAt(3, 0))), + ); + await tester.pumpAndSettle(); + + expect(panelColor(tester, '아직 잠긴 색'), AppColors.surface); + }); + + testWidgets('열린 색을 골라도 배경은 흰색이다', (tester) async { + await pumpThemeDialog(tester); + await tester.tap( + find.byKey(ThemeDialog.cellKey(ThemeLockManager.themeIndexAt(0, 1))), + ); + await tester.pumpAndSettle(); + + expect(panelColor(tester, '고른 색'), AppColors.surface); + }); + }); + group('잠긴 칸에 조건이 보인다', () { testWidgets('잠긴 칸마다 필요한 레벨이 칸 안에 적혀 있다', (tester) async { await pumpThemeDialog(tester); diff --git a/test/module/emoji/mood_picker_test.dart b/test/module/emoji/mood_picker_test.dart new file mode 100644 index 00000000..c3b04d5d --- /dev/null +++ b/test/module/emoji/mood_picker_test.dart @@ -0,0 +1,82 @@ +// 복습을 마치고 기분을 고르는 줄. 복습 세트 완료 화면과 문제 복습 인증 +// 화면이 같이 쓴다. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ono/Module/Emoji/MoodPicker.dart'; +import 'package:ono/Module/Emoji/OnoEmojiImage.dart'; + +import '../../helpers/helpers.dart'; + +/// 줄과 제목 옆 표를 함께 띄우고, 고른 것을 들고 있는 부모 흉내. +class _Host extends StatefulWidget { + const _Host(); + + @override + State<_Host> createState() => _HostState(); +} + +class _HostState extends State<_Host> { + String? selected; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: [ + SelectedMoodChip(selectedKey: selected, color: Colors.pink), + MoodPickerRow( + selectedKey: selected, + color: Colors.pink, + onChanged: (key) => setState(() => selected = key), + ), + ], + ), + ); + } +} + +void main() { + setUpOnoWidgetTest(); + + Finder emojiImage(String key) => find.byWidgetPredicate( + (w) => w is OnoEmojiImage && w.emoji?.key == key, + ); + + testWidgets('더보기에서 추천에 없는 것을 고르면 창을 닫은 뒤에도 보인다', (tester) async { + // 폰에서는 더보기 칸이 줄 맨 끝이라 화면 밖이다. 거기서 고른 것은 어느 + // 칸에도 없어서 창을 닫으면 무엇을 골랐는지 보이지 않았다. + await pumpOnoWidget(tester, const _Host()); + await tester.scrollUntilVisible( + find.byIcon(Icons.more_horiz), + 200, + scrollable: find.byType(Scrollable), + ); + await tester.ensureVisible(find.byIcon(Icons.more_horiz)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.more_horiz)); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('슬픔')); + await tester.pumpAndSettle(); + + expect(find.byType(BottomSheet), findsNothing); + // 제목 옆 그림과 줄 맨 앞 칸, 두 곳 모두 화면 안에 보인다. 이름 글자는 + // 적지 않는다. + expect(find.text('슬픔'), findsNothing); + expect(emojiImage('crying_in_rain').hitTestable(), findsNWidgets(2)); + }); + + testWidgets('추천 칸을 고르면 제목 옆에 그림이 뜨고 다시 누르면 사라진다', (tester) async { + await pumpOnoWidget(tester, const _Host()); + + final first = emojiImage(MoodPickerRow.recommendedKeys.first); + await tester.tap(first); + await tester.pumpAndSettle(); + expect(find.byType(SelectedMoodChip), findsOneWidget); + expect(first, findsNWidgets(2)); + + await tester.tap(first.last); + await tester.pumpAndSettle(); + expect(first, findsOneWidget); + }); +} diff --git a/test/module/motion/handwriting_reveal_test.dart b/test/module/motion/handwriting_reveal_test.dart index 8a0e24d0..ef338503 100644 --- a/test/module/motion/handwriting_reveal_test.dart +++ b/test/module/motion/handwriting_reveal_test.dart @@ -11,6 +11,29 @@ const _duration = Duration(milliseconds: 600); void main() { setUpOnoWidgetTest(); + testWidgets('다 쓰고 나면 가리개가 걷혀 마지막 글자가 흐리지 않다', (tester) async { + // 가리개의 흐린 끝자락이 다 쓴 뒤에도 문구 오른쪽 끝을 덮고 있어서 + // 스플래시의 닫는 따옴표만 흐릿하게 남았다. + await pumpOnoWidget( + tester, + Scaffold( + body: Center( + child: HandwritingReveal( + text: _phrase, + color: Colors.black, + duration: _duration, + ), + ), + ), + settle: false, + ); + expect(find.byType(ShaderMask), findsOneWidget); + + await tester.pumpAndSettle(); + expect(find.text(_phrase), findsOneWidget); + expect(find.byType(ShaderMask), findsNothing); + }); + testWidgets('다 써질 때까지는 onCompleted 가 불리지 않는다', (tester) async { var completed = false; diff --git a/test/screen/achievement/achievement_screen_test.dart b/test/screen/achievement/achievement_screen_test.dart index 4611599b..8f026054 100644 --- a/test/screen/achievement/achievement_screen_test.dart +++ b/test/screen/achievement/achievement_screen_test.dart @@ -181,7 +181,7 @@ void main() { await settleToast(tester); expect(gaugeIn(cardOf('phoenix')), findsNothing); - expect(find.text('한 번만 해내면 받아요'), findsOneWidget); + expect(find.text('1번만 해내면 받아요'), findsOneWidget); }); }); diff --git a/test/screen/character/character_screen_test.dart b/test/screen/character/character_screen_test.dart index 7aca2add..f9b2b1af 100644 --- a/test/screen/character/character_screen_test.dart +++ b/test/screen/character/character_screen_test.dart @@ -578,9 +578,9 @@ void main() { await tester.pumpAndSettle(); expect(find.text('출석 올리는 법'), findsOneWidget); - expect(find.text('하루에 한 번 앱을 열면'), findsOneWidget); + expect(find.text('하루에 1번 앱을 열면'), findsOneWidget); expect(find.text('+15점'), findsOneWidget); - expect(find.text('하루 한 번까지'), findsOneWidget); + expect(find.text('하루 1번까지'), findsOneWidget); }); testWidgets('능력치마다 다른 규칙을 말한다', (tester) async { @@ -592,7 +592,7 @@ void main() { expect(find.text('오답노트 올리는 법'), findsOneWidget); // 서버의 MissionType 과 MissionLogService 가 정한 값이다. expect(find.text('+10점'), findsOneWidget); - expect(find.text('하루 세 개까지'), findsOneWidget); + expect(find.text('하루 3개까지'), findsOneWidget); }); testWidgets('꾸미기 화면에서 더미 레벨을 옮기면 눈금판도 따라간다', (tester) async { diff --git a/test/screen/cosmetic/cosmetic_ability_style_test.dart b/test/screen/cosmetic/cosmetic_ability_style_test.dart index c3c47c54..42eebd2f 100644 --- a/test/screen/cosmetic/cosmetic_ability_style_test.dart +++ b/test/screen/cosmetic/cosmetic_ability_style_test.dart @@ -92,24 +92,23 @@ void main() { }); group('남은 거리', () { - test('지금 레벨과 남은 레벨을 같이 적는다', () { + test('지금 레벨은 빼고 남은 레벨만 적는다', () { expect( CosmeticAbilityStyle.progressOf( _item(level: 14, ability: CosmeticAbility.attendance), CosmeticAbilityLevels(attendance: 11), ), - '지금 출석 Lv.11 · 3 레벨 남았어요', + '획득까지 3레벨 남았어요', ); }); - test('한 칸 남았으면 한 칸 남았다고 말한다', () { - // 이 한 줄이 "한 번만 더" 를 만든다. + test('한 칸 남았어도 같은 말로 적는다', () { expect( CosmeticAbilityStyle.progressOf( _item(level: 14, ability: CosmeticAbility.attendance), CosmeticAbilityLevels(attendance: 13), ), - '지금 출석 Lv.13 · 한 레벨만 더!', + '획득까지 1레벨 남았어요', ); }); @@ -119,7 +118,7 @@ void main() { _item(level: 3, ability: CosmeticAbility.attendance), CosmeticAbilityLevels(attendance: 9), ), - '지금 출석 Lv.9 · 열려 있어요', + '지금 바로 얻을 수 있어요', ); }); diff --git a/test/screen/cosmetic/cosmetic_closet_screen_test.dart b/test/screen/cosmetic/cosmetic_closet_screen_test.dart index 8d194afd..31b3d4f9 100644 --- a/test/screen/cosmetic/cosmetic_closet_screen_test.dart +++ b/test/screen/cosmetic/cosmetic_closet_screen_test.dart @@ -378,11 +378,11 @@ void main() { expect(inCard(find.text('출석 Lv.14')), findsOneWidget); }); - testWidgets('지금 내가 그 능력치에서 몇 레벨인지 같이 적는다', (tester) async { + testWidgets('지금 레벨 없이 획득까지 남은 레벨만 적는다', (tester) async { // 필요 레벨만 적으면 코앞인지 한참 남았는지 알 수 없다. await pumpCloset(tester); - expect(inCard(find.text('지금 출석 Lv.12 · 2 레벨 남았어요')), findsOneWidget); + expect(inCard(find.text('획득까지 2레벨 남았어요')), findsOneWidget); }); // 배지를 Flexible 로 두면 제 너비만큼만 차지하는데, 앞의 이름이 이미 제 @@ -409,13 +409,13 @@ void main() { }); } - testWidgets('한 칸 남았으면 한 칸 남았다고 말한다', (tester) async { + testWidgets('한 칸 남아도 같은 말로 남은 레벨을 적는다', (tester) async { await pumpCloset( tester, levels: CosmeticAbilityLevels(attendance: 13), ); - expect(inCard(find.text('지금 출석 Lv.13 · 한 레벨만 더!')), findsOneWidget); + expect(inCard(find.text('획득까지 1레벨 남았어요')), findsOneWidget); }); testWidgets('남은 레벨이 가장 적은 것을 고른다', (tester) async { diff --git a/test/screen/cosmetic/goldens/ci/cosmetic_closet_screen_phone.png b/test/screen/cosmetic/goldens/ci/cosmetic_closet_screen_phone.png index 1a56229c..f83fb03f 100644 Binary files a/test/screen/cosmetic/goldens/ci/cosmetic_closet_screen_phone.png and b/test/screen/cosmetic/goldens/ci/cosmetic_closet_screen_phone.png differ diff --git a/test/screen/cosmetic/goldens/ci/cosmetic_closet_screen_phone_text_1_6.png b/test/screen/cosmetic/goldens/ci/cosmetic_closet_screen_phone_text_1_6.png index eedb981e..43a0ab00 100644 Binary files a/test/screen/cosmetic/goldens/ci/cosmetic_closet_screen_phone_text_1_6.png and b/test/screen/cosmetic/goldens/ci/cosmetic_closet_screen_phone_text_1_6.png differ diff --git a/test/screen/cosmetic/goldens/ci/cosmetic_closet_screen_small_phone.png b/test/screen/cosmetic/goldens/ci/cosmetic_closet_screen_small_phone.png index ee2de498..85e57b8b 100644 Binary files a/test/screen/cosmetic/goldens/ci/cosmetic_closet_screen_small_phone.png and b/test/screen/cosmetic/goldens/ci/cosmetic_closet_screen_small_phone.png differ diff --git a/test/screen/cosmetic/goldens/ci/cosmetic_closet_screen_tablet.png b/test/screen/cosmetic/goldens/ci/cosmetic_closet_screen_tablet.png index 4628185e..45b3441c 100644 Binary files a/test/screen/cosmetic/goldens/ci/cosmetic_closet_screen_tablet.png and b/test/screen/cosmetic/goldens/ci/cosmetic_closet_screen_tablet.png differ diff --git a/test/screen/folder/directory_screen_golden_test.dart b/test/screen/folder/directory_screen_golden_test.dart index 36013b1f..d1cb64ab 100644 --- a/test/screen/folder/directory_screen_golden_test.dart +++ b/test/screen/folder/directory_screen_golden_test.dart @@ -3,6 +3,9 @@ // 하위 폴더 둘과 문제 셋이 있는 책장을 뜬다. // // 기준 이미지를 다시 뜨는 방법은 test/README.md 의 「골든 테스트」 절에 있다. +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:ono/Model/Common/LoginStatus.dart'; import 'package:ono/Model/Common/PaginatedResponse.dart'; @@ -37,78 +40,99 @@ void main() { ); } - screenGoldenTest( - '책장 화면', - fileName: 'directory_screen', - surfaces: GoldenSurface.layouts, - buildApp: () async { - final userProvider = _FakeUserProvider(); - when(() => userProvider.isLoggedIn).thenReturn(LoginStatus.login); - when(() => userProvider.addListener(any())).thenReturn(null); - when(() => userProvider.removeListener(any())).thenReturn(null); - when(() => userProvider.dispose()).thenReturn(null); + Future buildApp() async { + final userProvider = _FakeUserProvider(); + when(() => userProvider.isLoggedIn).thenReturn(LoginStatus.login); + when(() => userProvider.addListener(any())).thenReturn(null); + when(() => userProvider.removeListener(any())).thenReturn(null); + when(() => userProvider.dispose()).thenReturn(null); - final folderService = MockFolderService(); - final problemService = MockProblemService(); + final folderService = MockFolderService(); + final problemService = MockProblemService(); - final root = FolderModel( - folderId: 1, - folderName: '책장', - problemIdList: const [], - subFolderList: const [], - ); - when(() => folderService.getRootFolder()).thenAnswer((_) async => root); - when(() => folderService.fetchFolder(any(), - showErrorSnackBar: any(named: 'showErrorSnackBar'))) - .thenAnswer((_) async => root); + final root = FolderModel( + folderId: 1, + folderName: '책장', + problemIdList: const [], + subFolderList: const [], + ); + when(() => folderService.getRootFolder()).thenAnswer((_) async => root); + when(() => folderService.fetchFolder(any(), + showErrorSnackBar: any(named: 'showErrorSnackBar'))) + .thenAnswer((_) async => root); - final subfolders = [ - FolderThumbnailModel(folderId: 10, folderName: '수학', problemCount: 3), - FolderThumbnailModel(folderId: 11, folderName: '영어', problemCount: 12), - ]; - when(() => folderService.getSubfoldersV2( - folderId: any(named: 'folderId'), - cursor: any(named: 'cursor'), - size: any(named: 'size'), - )).thenAnswer((_) async => PaginatedResponse( - content: subfolders, - nextCursor: null, - hasNext: false, - size: subfolders.length, - )); + final subfolders = [ + FolderThumbnailModel(folderId: 10, folderName: '수학', problemCount: 3), + FolderThumbnailModel(folderId: 11, folderName: '영어', problemCount: 12), + ]; + when(() => folderService.getSubfoldersV2( + folderId: any(named: 'folderId'), + cursor: any(named: 'cursor'), + size: any(named: 'size'), + )).thenAnswer((_) async => PaginatedResponse( + content: subfolders, + nextCursor: null, + hasNext: false, + size: subfolders.length, + )); - final problems = [ - problem(100, '수학 문제집 p.12'), - problem(101, '모의고사 21번'), - problem(102, '단어 시험'), - ]; - when(() => problemService.getFolderProblemsV2( - folderId: any(named: 'folderId'), - cursor: any(named: 'cursor'), - size: any(named: 'size'), - )).thenAnswer((_) async => PaginatedResponse( - content: problems, - nextCursor: null, - hasNext: false, - size: problems.length, - )); - when(() => problemService.getReviewDueProblems()).thenAnswer( - (_) async => - ReviewDueResponse(dueCount: 0, overdueCount: 0, problems: []), - ); + final problems = [ + problem(100, '수학 문제집 p.12'), + problem(101, '모의고사 21번'), + problem(102, '단어 시험'), + ]; + when(() => problemService.getFolderProblemsV2( + folderId: any(named: 'folderId'), + cursor: any(named: 'cursor'), + size: any(named: 'size'), + )).thenAnswer((_) async => PaginatedResponse( + content: problems, + nextCursor: null, + hasNext: false, + size: problems.length, + )); + when(() => problemService.getReviewDueProblems()).thenAnswer( + (_) async => + ReviewDueResponse(dueCount: 0, overdueCount: 0, problems: []), + ); - final problemsProvider = ProblemsProvider(problemService: problemService); - return buildOnoApp( - const DirectoryScreen(), - cosmeticProvider: await loadedCosmeticProvider(), - userProvider: userProvider, + final problemsProvider = ProblemsProvider(problemService: problemService); + return buildOnoApp( + const DirectoryScreen(), + cosmeticProvider: await loadedCosmeticProvider(), + userProvider: userProvider, + problemsProvider: problemsProvider, + foldersProvider: FoldersProvider( problemsProvider: problemsProvider, - foldersProvider: FoldersProvider( - problemsProvider: problemsProvider, - folderService: folderService, - ), - reviewDueProvider: ReviewDueProvider(problemService: problemService), - ); + folderService: folderService, + ), + reviewDueProvider: ReviewDueProvider(problemService: problemService), + ); + } + + screenGoldenTest( + '책장 화면', + fileName: 'directory_screen', + surfaces: GoldenSurface.layouts, + buildApp: buildApp, + ); + + // 공책을 꾹 눌러 다른 공책 위로 끌고 간 순간. 손에 붙은 카드와 놓을 자리의 + // 강조가 함께 잠긴다. 손가락은 떼지 않은 채로 찍는다. + screenGoldenTest( + '책장 화면 (공책을 끄는 중)', + fileName: 'directory_screen_dragging', + surfaces: const [GoldenSurface.phone], + buildApp: buildApp, + prepare: (tester) async { + final from = tester.getCenter(find.text('영어')); + final to = tester.getCenter(find.text('수학')); + final gesture = await tester.startGesture(from); + await tester.pump(kLongPressTimeout + const Duration(milliseconds: 100)); + await gesture.moveTo(Offset.lerp(from, to, 0.5)!); + await tester.pump(); + await gesture.moveTo(to); + await tester.pump(const Duration(milliseconds: 300)); }, ); } diff --git a/test/screen/folder/goldens/ci/directory_screen_dragging_phone.png b/test/screen/folder/goldens/ci/directory_screen_dragging_phone.png new file mode 100644 index 00000000..ee9c5c53 Binary files /dev/null and b/test/screen/folder/goldens/ci/directory_screen_dragging_phone.png differ diff --git a/test/screen/practice_note/goldens/ci/practice_completion_screen_phone.png b/test/screen/practice_note/goldens/ci/practice_completion_screen_phone.png index 9ed48e39..aa30702c 100644 Binary files a/test/screen/practice_note/goldens/ci/practice_completion_screen_phone.png and b/test/screen/practice_note/goldens/ci/practice_completion_screen_phone.png differ diff --git a/test/screen/practice_note/goldens/ci/practice_completion_screen_tablet.png b/test/screen/practice_note/goldens/ci/practice_completion_screen_tablet.png index ddc31f00..8f3934b6 100644 Binary files a/test/screen/practice_note/goldens/ci/practice_completion_screen_tablet.png and b/test/screen/practice_note/goldens/ci/practice_completion_screen_tablet.png differ diff --git a/test/screen/practice_note/goldens/ci/practice_detail_screen_with_mood_phone.png b/test/screen/practice_note/goldens/ci/practice_detail_screen_with_mood_phone.png deleted file mode 100644 index ca8ecb7b..00000000 Binary files a/test/screen/practice_note/goldens/ci/practice_detail_screen_with_mood_phone.png and /dev/null differ diff --git a/test/screen/practice_note/goldens/ci/practice_detail_screen_with_mood_tablet.png b/test/screen/practice_note/goldens/ci/practice_detail_screen_with_mood_tablet.png deleted file mode 100644 index 7b305c24..00000000 Binary files a/test/screen/practice_note/goldens/ci/practice_detail_screen_with_mood_tablet.png and /dev/null differ diff --git a/test/screen/practice_note/practice_completion_screen_test.dart b/test/screen/practice_note/practice_completion_screen_test.dart index 3caf3082..01c87d52 100644 --- a/test/screen/practice_note/practice_completion_screen_test.dart +++ b/test/screen/practice_note/practice_completion_screen_test.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:ono/Module/Emoji/OnoEmojiImage.dart'; import 'package:ono/Module/Motion/PressableScale.dart'; import 'package:mocktail/mocktail.dart'; import 'package:ono/Model/PracticeNote/PracticeNoteDetailModel.dart'; @@ -95,6 +98,31 @@ void main() { expect(find.byType(BottomSheet), findsOneWidget); }); + testWidgets('더보기에서 추천에 없는 것을 고르면 목록 맨 앞에 보인다', (tester) async { + // 폰에서는 더보기 칸이 화면 밖이라, 그 칸에만 골라 둔 모양을 그리면 + // 돌아왔을 때 무엇을 골랐는지 보이지 않았다. + await pumpScreen(tester); + await tester.dragUntilVisible( + find.byIcon(Icons.more_horiz), + find.byType(ListView).first, + const Offset(-200, 0), + ); + await tester.tap(find.byIcon(Icons.more_horiz)); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('슬픔')); + await tester.pumpAndSettle(); + + expect(find.byType(BottomSheet), findsNothing); + final picked = find.byWidgetPredicate( + (w) => w is OnoEmojiImage && w.emoji?.key == 'crying_in_rain', + ); + expect(picked.hitTestable(), findsWidgets); + // 제목 줄 오른쪽에도 고른 기분이 그림으로 뜬다. 이름 글자는 적지 않는다. + expect(picked.hitTestable(), findsNWidgets(2)); + expect(find.text('슬픔'), findsNothing); + }); + testWidgets('확인 버튼을 탭하면 addPracticeCount 를 호출하고 완료 스낵바를 띄운다', (tester) async { when(() => practiceNoteService.addPracticeNoteCount(1, moodEmojiKey: null)) .thenAnswer((_) async {}); @@ -150,4 +178,27 @@ void main() { expect(tester.takeException(), isNull); }); + + testWidgets('저장을 기다리는 동안 확인을 또 눌러도 한 번만 보낸다', (tester) async { + final completer = Completer(); + when(() => practiceNoteService.addPracticeNoteCount(1, moodEmojiKey: null)) + .thenAnswer((_) => completer.future); + + await pumpScreen(tester); + await tester.tap(find.text('확인')); + await tester.pump(); + await tester.tap(find.byType(ElevatedButton).last, warnIfMissed: false); + await tester.pump(); + + verify(() => + practiceNoteService.addPracticeNoteCount(1, moodEmojiKey: null)) + .called(1); + + // 성공으로 끝내면 완료 알림이 뜨는데, AppToast 는 같은 문구를 0.8초 + // 안에 두 번 띄우지 않아서 바로 다음 테스트의 알림이 삼켜진다. 실패로 + // 끝내고 이 테스트를 맨 끝에 둔다. + completer.completeError(Exception('network error')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + }); } diff --git a/test/screen/practice_note/practice_detail_screen_golden_test.dart b/test/screen/practice_note/practice_detail_screen_golden_test.dart index 57d08265..7b47517c 100644 --- a/test/screen/practice_note/practice_detail_screen_golden_test.dart +++ b/test/screen/practice_note/practice_detail_screen_golden_test.dart @@ -44,37 +44,4 @@ void main() { ); }, ); - - // 지난 복습 소감이 있는 모습은 따로 뜬다. 위 픽스처는 소감이 없어서 그 칸이 - // 아예 그려지지 않아, 생김새가 잠기지 않는다. - screenGoldenTest( - '복습 세트 상세 화면 (지난 복습 소감 있음)', - fileName: 'practice_detail_screen_with_mood', - surfaces: GoldenSurface.layouts, - buildApp: () async { - final practiceProvider = ProblemPracticeProvider( - problemsProvider: MockProblemsProvider(), - practiceNoteService: MockPracticeNoteService(), - ); - practiceProvider.currentProblems = [ - ProblemModel(problemId: 10, reference: '수학 문제집 p.12'), - ]; - - return buildOnoApp( - PracticeDetailScreen( - practice: PracticeNoteDetailModel( - practiceId: 1, - practiceTitle: '수학 오답노트', - practiceCount: 3, - createdAt: DateTime(2024, 1, 1), - lastSolvedAt: null, - lastSessionMoodEmojiKey: 'cool_sunglasses', - problemIdList: const [10], - ), - ), - cosmeticProvider: await loadedCosmeticProvider(), - practiceProvider: practiceProvider, - ); - }, - ); } diff --git a/test/screen/practice_note/practice_detail_screen_test.dart b/test/screen/practice_note/practice_detail_screen_test.dart index 9da1569f..f33dd25b 100644 --- a/test/screen/practice_note/practice_detail_screen_test.dart +++ b/test/screen/practice_note/practice_detail_screen_test.dart @@ -131,38 +131,8 @@ void main() { expect(find.text('기록 없음'), findsNothing); }); - testWidgets('지난 복습 소감 이모지 키가 없으면 그 행이 안 보인다', (tester) async { - practiceProvider.currentProblems = [_problem(10)]; - - await pumpOnoWidget( - tester, - PracticeDetailScreen( - practice: _practice(problemIdList: [10]), - ), - practiceProvider: practiceProvider, - ); - - expect(find.text('지난 복습 소감'), findsNothing); - }); - - testWidgets('지난 복습 소감 이모지 키가 있으면 그 행이 보인다', (tester) async { - practiceProvider.currentProblems = [_problem(10)]; - - await pumpOnoWidget( - tester, - PracticeDetailScreen( - practice: _practice( - problemIdList: [10], - lastSessionMoodEmojiKey: 'birthday_cake', - ), - ), - practiceProvider: practiceProvider, - ); - - expect(find.text('지난 복습 소감'), findsOneWidget); - }); - - testWidgets('지난 복습 소감에 고른 이모지의 이름이 같이 나온다', (tester) async { + testWidgets('지난 복습 소감은 상세 화면에 나오지 않는다', (tester) async { + // 소감은 복습 세트 목록의 날짜 아래에서 본다. practiceProvider.currentProblems = [_problem(10)]; await pumpOnoWidget( @@ -176,26 +146,8 @@ void main() { practiceProvider: practiceProvider, ); - // 그림만으로는 무엇을 고른 것인지 알기 어려워서 이름을 같이 쓴다. - expect(find.text('멋짐'), findsOneWidget); - }); - - testWidgets('이 앱이 모르는 이모지 키가 오면 소감 칸을 그리지 않는다', (tester) async { - practiceProvider.currentProblems = [_problem(10)]; - - await pumpOnoWidget( - tester, - PracticeDetailScreen( - practice: _practice( - problemIdList: [10], - lastSessionMoodEmojiKey: 'no_such_emoji_key', - ), - ), - practiceProvider: practiceProvider, - ); - - // 이름도 그림도 못 찾으면 빈 칸만 남는다. 아예 그리지 않는다. expect(find.text('지난 복습 소감'), findsNothing); + expect(find.text('멋짐'), findsNothing); }); testWidgets('문제가 없으면 빈 상태 그림(안내 문구·프로그·추가 버튼)이 보이고 복습하기 버튼은 없다', diff --git a/test/screen/practice_note/practice_thumbnail_screen_test.dart b/test/screen/practice_note/practice_thumbnail_screen_test.dart index 7d05949c..e27df8b5 100644 --- a/test/screen/practice_note/practice_thumbnail_screen_test.dart +++ b/test/screen/practice_note/practice_thumbnail_screen_test.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:ono/Module/Emoji/OnoEmojiImage.dart'; import 'package:ono/Module/Motion/Skeleton.dart'; import 'package:mocktail/mocktail.dart'; import 'package:ono/Model/Common/PaginatedResponse.dart'; @@ -121,6 +122,30 @@ void main() { expect(find.text('1회 복습'), findsOneWidget); }); + testWidgets('지난 복습 소감이 있으면 날짜 뒤에 그림만 붙는다', (tester) async { + when(() => practiceNoteService.getPracticeNoteThumbnailsV2( + cursor: null, + size: 20, + )).thenAnswer((_) async => _page([ + _thumb(1, lastSessionMoodEmojiKey: 'cool_sunglasses'), + _thumb(2), + _thumb(3, lastSessionMoodEmojiKey: 'no_such_emoji_key'), + ])); + await practiceProvider.loadInitialPracticeThumbnails(); + + await pumpOnoWidget( + tester, + const PracticeThumbnailScreen(), + practiceProvider: practiceProvider, + ); + + // 그림만 날짜 뒤에 붙고 이름은 길게 눌러야 뜬다. 소감이 없거나 이 앱이 + // 모르는 키면 그리지 않는다. + expect(find.byTooltip('지난 소감: 멋짐'), findsOneWidget); + expect(find.byType(OnoEmojiImage), findsOneWidget); + expect(find.text('멋짐'), findsNothing); + }); + testWidgets('복습 3회 이상이면 "복습 완료" 태그로 바뀐다', (tester) async { when(() => practiceNoteService.getPracticeNoteThumbnailsV2( cursor: null, diff --git a/test/screen/problem_solve/time_spent_chips_test.dart b/test/screen/problem_solve/time_spent_chips_test.dart index 7f3fb24d..cc919212 100644 --- a/test/screen/problem_solve/time_spent_chips_test.dart +++ b/test/screen/problem_solve/time_spent_chips_test.dart @@ -42,11 +42,13 @@ void main() { }); await tester.pump(const Duration(milliseconds: 300)); + // 칩에서 가까운 순서로 칩 묶음의 줄, 소요 시간 글자와 칩 묶음을 함께 + // 담은 줄이다. final row = find.ancestor( - of: find.byType(Wrap).first, + of: find.text('+10초'), matching: find.byType(Row), ); - final rowRight = tester.getRect(row.first).right; + final rowRight = tester.getRect(row.at(1)).right; final lastChipRight = tester.getRect(find.text('+10초')).right; // 칩 상자에 테두리와 여백이 있어서 글자 기준으로는 줄 끝보다 몇 픽셀 @@ -57,6 +59,28 @@ void main() { reason: '${entry.key}: 칩이 오른쪽 끝에서 ' '${(rowRight - lastChipRight).toStringAsFixed(1)} 만큼 떨어져 있다', ); + + // 칩 넷은 한 줄에 선다. 폰에서 +10초 가 아랫줄로 넘어갔었다. + final tops = ['-1분', '+1분', '-10초', '+10초'] + .map((label) => tester.getRect(find.text(label)).top) + .toSet(); + expect(tops, hasLength(1), reason: '${entry.key}: 칩이 두 줄로 나뉘었다'); + + // 칩끼리 딱 붙어 있지 않다. + final minus10 = tester.getRect(find + .ancestor( + of: find.text('-10초'), + matching: find.byType(Container), + ) + .first); + final plus10 = tester.getRect(find + .ancestor( + of: find.text('+10초'), + matching: find.byType(Container), + ) + .first); + expect(plus10.left - minus10.right, greaterThan(3), + reason: '${entry.key}: 칩 사이가 붙어 있다'); }); } } diff --git a/test/screen/user/goldens/ci/my_page_screen_phone.png b/test/screen/user/goldens/ci/my_page_screen_phone.png index 72af4c83..7a355acb 100644 Binary files a/test/screen/user/goldens/ci/my_page_screen_phone.png and b/test/screen/user/goldens/ci/my_page_screen_phone.png differ diff --git a/test/screen/user/goldens/ci/my_page_screen_tablet.png b/test/screen/user/goldens/ci/my_page_screen_tablet.png index e6bdf512..399c6e99 100644 Binary files a/test/screen/user/goldens/ci/my_page_screen_tablet.png and b/test/screen/user/goldens/ci/my_page_screen_tablet.png differ