Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e5abf1a
[Chore] 앱 버전을 4.1.0+71 로 올린다 - 개발 서버 연동 QA 빌드를 App Store Connect 에 올리…
KiSeungMin Sep 21, 2026
55b406a
[Fix] 스플래시 문구 오른쪽 끝에 따옴표가 먼저 비치던 것을 막는다 - 손글씨 폰트의 닫는 따옴표가 글자 상자 오른쪽 밖…
KiSeungMin Sep 21, 2026
ef3bba8
[Fix] 테마 다이얼로그의 잠긴 색 테두리를 그 테마색의 진한 색으로 바꾼다 - 미션 칸의 색을 쓰고 있어서 출석 줄은 빨…
KiSeungMin Sep 21, 2026
6f899bb
[Fix] 옷장 다음에 열려요 카드에서 지금 레벨을 빼고 남은 레벨만 적는다 - 윗줄의 필요 레벨 옆에 지금 레벨이 또 붙어…
KiSeungMin Sep 21, 2026
2505e26
[Fix] 오늘의 미션 카드를 추천 복습 문제 카드와 같은 크기로 맞춘다 - 안쪽 여백이 12/10 이라 14 인 추천 복습…
KiSeungMin Sep 21, 2026
3aa87a7
[Fix] 오답노트를 폴더에 끌어다 댈 때 강조 테두리 양옆이 잘리던 것을 고친다 - 목록은 제 영역 밖을 잘라내는데 좌우 …
KiSeungMin Sep 21, 2026
109fc5a
[Fix] 복습 세트를 누르면 화면부터 넘기고 그 안에서 불러온다 - 세트와 문제를 다 불러온 뒤에야 화면이 넘어가서 그동안…
KiSeungMin Sep 21, 2026
e51c2aa
[Fix] 지난 복습 소감을 칠한 칸 대신 구분선으로 나눈다 - 혼자 색이 들어간 덩어리였고 화면 끝까지 붙어 아래 문제 목…
KiSeungMin Sep 21, 2026
f608edd
[Fix] 복습 완료를 여러 번 눌러 횟수가 쌓이던 것과 더보기에서 고른 기분이 안 보이던 것을 고친다 - 완료 요청은 보낼…
KiSeungMin Sep 21, 2026
4d84acc
[Fix] 푼 날짜가 없는 문제에서 상세 화면이 멈추던 것과 Expanded 중첩을 고친다 (Sentry FLUTTER-18…
KiSeungMin Sep 21, 2026
1c31ca3
[Fix] 스터디룸 목록 조회와 반응 누르기의 실패를 받아 둔다 (Sentry FLUTTER-15G, FLUTTER-174)…
KiSeungMin Sep 21, 2026
c3e4c93
[Fix] 구글 로그인 창을 닫거나 창이 겹친 것을 취소로 본다 (Sentry FLUTTER-VM) - 12501 은 사용자…
KiSeungMin Sep 21, 2026
c097b9b
[Fix] Sentry 에 같은 에러가 두 번 쌓이고 E2E 실패까지 올라가던 것을 막는다 - FlutterError.onE…
KiSeungMin Sep 21, 2026
08148f5
[Fix] 오늘의 미션 카드와 추천 복습 카드의 높이를 같게 맞춘다 - 여백만 맞춰서는 밀린 문제 줄이 붙은 추천 복습 카드…
KiSeungMin Sep 21, 2026
8e5d950
[Test] 옷장 다음 해금 예고 테스트 이름을 바뀐 문구에 맞춘다
KiSeungMin Sep 21, 2026
438f10d
[Feat] 지난 복습 소감을 상세 화면에서 빼고 복습 세트 목록에서 보여 준다 - 상세 화면에서는 통계와 문제 목록 사이에…
KiSeungMin Sep 21, 2026
3c741f7
[Fix] 더보기에서 고른 기분이 폰에서 여전히 안 보이던 것을 고친다 - 앞 커밋은 더보기 칸에 골라 둔 모양을 그렸는데 …
KiSeungMin Sep 21, 2026
a8405bf
[Fix] 오늘의 미션 받기 태그와 추천 복습 개수 배지를 화살표 바로 앞으로 옮긴다 - 두 태그가 제목 옆에 붙어 있어 카…
KiSeungMin Sep 21, 2026
479706c
[Fix] 복습 세트 목록의 지난 소감을 날짜 줄 끝의 그림 하나로 줄인다 - 날짜 아래 한 줄을 더 써서 카드가 길어졌다,…
KiSeungMin Sep 21, 2026
9b7141b
[Fix] 복습 인증의 시간 조절 칩 넷을 폰에서도 한 줄에 둔다 - 소요 시간 글자와 칩 묶음이 폭을 반씩 나눠 가져서 글…
KiSeungMin Sep 21, 2026
e67b7df
[Fix] 이번 복습 어땠나요 제목 줄 오른쪽에 고른 기분을 적는다 - 칸 목록은 가로로 넘어가서 고른 칸이 화면 밖에 있을…
KiSeungMin Sep 21, 2026
65a8418
[Feat] 공책과 오답노트를 끌 때 손에 붙는 모양을 카드로 바꾼다 - 기본 Material 의 흰 네모 위에 아이콘이나 …
KiSeungMin Sep 21, 2026
54dd948
[Fix] 문제 복습 인증에서도 더보기로 고른 기분이 창을 닫은 뒤 보이게 한다 - 기분 고르는 줄이 복습 세트 완료 화면과…
KiSeungMin Sep 21, 2026
2073bee
[Fix] 복습 인증 시간 조절 칩 사이에 간격을 둔다 - 한 줄로 모으면서 Wrap 의 간격이 빠져 칩 넷이 딱 붙었다, …
KiSeungMin Sep 21, 2026
b977a7d
[Fix] 고른 기분을 제목 옆에 그림으로만 보여 준다 - 이모지 이름 글자를 빼고 그림만 둔다, 스크린 리더는 고른 기분의…
KiSeungMin Sep 21, 2026
339242c
[Fix] 앱 안의 횟수와 개수를 한글 수 대신 숫자로 적는다 - 능력치 올리는 법의 하루 세 개까지 한 번까지 같은 것과 …
KiSeungMin Sep 21, 2026
8f3551b
[Fix] 마이페이지 프로필 카드에서 사진과 이름을 바꿀 수 있어요 문구를 뺀다 - 카메라 배지와 이름 변경 버튼이 이미 같…
KiSeungMin Sep 21, 2026
b3cf753
[Fix] 테마 다이얼로그 맨 위 판의 배경을 흰색으로 둔다 - 고른 색이나 미션 칸 색을 옅게 깔아서 누를 때마다 판 전체…
KiSeungMin Sep 21, 2026
d971454
[Fix] 스플래시 문구의 닫는 따옴표가 다 쓴 뒤에도 흐리게 남던 것을 고친다 - 글자 색 문제가 아니라 가리개의 흐린 끝…
KiSeungMin Sep 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions integration_test/helpers/e2e_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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] 를 돌린다.
Expand All @@ -30,6 +31,8 @@ Future<void> runFreshApp(
await prefs.clear();

final testOnError = FlutterError.onError;
// 테스트의 실패와 시드 데이터가 Sentry 와 Discord 에 운영 이슈처럼 쌓였다.
AppErrorReporter.enabled = false;
unawaited(app.main());
try {
await body();
Expand Down
6 changes: 6 additions & 0 deletions lib/Model/Problem/ProblemModel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
33 changes: 16 additions & 17 deletions lib/Module/Dialog/ThemeDialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ class _ThemeDialogState extends State<ThemeDialog> {
mainAxisSize: MainAxisSize.min,
children: [
_buildPreview(userInfo, duration),
const Divider(height: 1, color: AppColors.border),
_buildLaneHeader(userInfo),
Flexible(child: _buildTracks(userInfo, duration)),
_buildFooter(themeProvider, duration),
Expand Down Expand Up @@ -359,14 +360,13 @@ class _ThemeDialogState extends State<ThemeDialog> {
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),
),
Expand Down Expand Up @@ -897,7 +897,7 @@ class _ThemeCell extends StatelessWidget {
peak: 1.12,
child: isUnlocked
? _buildUnlockedSwatch(color)
: _buildLockedSwatch(color, colors, requiredLevel),
: _buildLockedSwatch(color, requiredLevel),
),
),
),
Expand Down Expand Up @@ -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,
Expand All @@ -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,
),
Expand All @@ -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,
),
),
Expand Down
217 changes: 217 additions & 0 deletions lib/Module/Emoji/MoodPicker.dart
Original file line number Diff line number Diff line change
@@ -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<String> 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<String?> onChanged;

final Color color;

const MoodPickerRow({
super.key,
required this.selectedKey,
required this.onChanged,
required this.color,
});

@override
State<MoodPickerRow> createState() => _MoodPickerRowState();
}

class _MoodPickerRowState extends State<MoodPickerRow> {
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<String> 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),
),
),
);
}
}
12 changes: 9 additions & 3 deletions lib/Module/Motion/HandwritingReveal.dart
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ class _HandwritingRevealState extends State<HandwritingReveal>
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(
Expand Down Expand Up @@ -253,8 +256,11 @@ class _HandwritingRevealState extends State<HandwritingReveal>
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,
Expand Down
18 changes: 15 additions & 3 deletions lib/Provider/PracticeNoteProvider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,19 @@ class ProblemPracticeProvider with ChangeNotifier {
notifyListeners();
}

/// 가장 최근에 시작한 [moveToPractice] 의 번호.
///
/// 복습 세트를 열어 두고 불러오는 사이에 다른 세트를 열면 두 불러오기가
/// 겹친다. 늦게 끝난 쪽이 먼저 연 세트라면 그 결과로 덮지 않는다.
int _moveGeneration = 0;

Future<void> moveToPractice(int practiceId) async {
final generation = ++_moveGeneration;
final targetPractice = await getPracticeNote(practiceId);

currentProblems.clear();
// 다 모은 뒤에 한 번에 바꾼다. 공유 목록에 바로 넣으면 겹친 불러오기가
// 서로의 문제를 섞어 넣는다.
final loadedProblems = <ProblemModel>[];

// 복습 세트의 각 문제를 서버에서 조회 (지연 로딩 대응)
for (var problemId in targetPractice.problemIdList) {
Expand All @@ -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');
Expand All @@ -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();
}
Expand Down
Loading
Loading